Files
andros 711b46e4b7 Tappable inline @nick mentions route to profile
OrgBodyRenderer now writes the feed URL into AttributedString's `link`
attribute over every `@nick` occurrence after mentions have been
extracted. SwiftUI renders those spans in the accent colour and makes
them tappable for free.

PostRowView intercepts the tap via `.environment(\.openURL, …)`: if the
URL looks like an Org Social feed URL (scheme http(s) + path ending in
social.org) it sets `pendingMentionURL` and a `.navigationDestination
(isPresented:)` modifier pushes the matching ProfileView onto the
current NavigationStack. Anything else (plain `[[https://foo]]` body
links) falls through to `.systemAction` and opens normally.

The existing below-the-body chips list is kept for accessibility and
discoverability — inline taps are nice but the chips make every mention
reachable in one glance.
2026-04-24 11:11:09 +02:00

384 lines
17 KiB
Swift

import Foundation
/// Structured pieces extracted from a post body during rendering.
public struct OrgBlock: Sendable, Hashable {
public enum Kind: Sendable, Hashable {
case src(language: String?)
case quote
case example
}
public var kind: Kind
public var content: String
public init(kind: Kind, content: String) {
self.kind = kind
self.content = content
}
}
public struct OrgMention: Sendable, Hashable {
public var nick: String
public var feedURL: URL
public init(nick: String, feedURL: URL) {
self.nick = nick
self.feedURL = feedURL
}
}
public struct RenderedBody: Sendable {
public var inline: AttributedString
public var blocks: [OrgBlock]
public var mentions: [OrgMention]
public var imageURLs: [URL]
public init(
inline: AttributedString,
blocks: [OrgBlock] = [],
mentions: [OrgMention] = [],
imageURLs: [URL] = []
) {
self.inline = inline
self.blocks = blocks
self.mentions = mentions
self.imageURLs = imageURLs
}
}
/// Turns an Org Social post body into a SwiftUI-ready `AttributedString` plus
/// side-car lists of referenced images, mentions, and source/quote/example
/// blocks.
///
/// Scope intentionally ignores lists and nesting. Supported inline markers:
/// - `*bold*` `.stronglyEmphasized`
/// - `/italic/` `.emphasized`
/// - `~code~`, `=verbatim=` `.code`
/// - `+strike+` `.strikethrough`
/// - `_underline_` is **not** rendered with an attribute (Foundation's
/// `InlinePresentationIntent` has no underline case; we keep the markers
/// visible as `_text_` rather than linking against UIKit/AppKit just for
/// one attribute).
///
/// Also supported:
/// - Bracket links `[[URL][label]]` and `[[URL]]`.
/// - Image extraction for bracketed links whose URL ends in a known image
/// extension.
/// - Mention extraction for `[[org-social:URL][nick]]`.
/// - Bare http/https URLs in free text.
/// - Fenced blocks `#+BEGIN_SRC`, `#+BEGIN_QUOTE`, `#+BEGIN_EXAMPLE`
/// (content NOT parsed for emphasis; extracted for separate rendering).
public enum OrgBodyRenderer {
public static func render(_ body: String) -> RenderedBody {
var remaining = body
let blocks = extractBlocks(from: &remaining)
let mentions = extractMentions(from: &remaining)
let images = extractImages(from: &remaining)
let trimmed = remaining.trimmingCharacters(in: .whitespacesAndNewlines)
var inline = buildInline(from: trimmed)
// After inline formatting is applied, decorate each `@nick`
// occurrence with a tappable link to the mentioned feed URL and an
// accent colour so the UI can navigate on tap (handled by the
// view's `OpenURLAction`). Done last so earlier markup passes
// don't strip the attributes.
annotateMentionLinks(in: &inline, mentions: mentions)
return RenderedBody(inline: inline,
blocks: blocks,
mentions: mentions,
imageURLs: images)
}
private static func annotateMentionLinks(in attributed: inout AttributedString,
mentions: [OrgMention]) {
// `link` makes the span tappable; SwiftUI renders it in the accent
// colour automatically. No need to import SwiftUI from the lib.
//
// AttributedString's `range(of:)` only finds the first occurrence
// so we walk the backing String view to collect all ranges, then
// map them back via character offsets.
for mention in mentions {
let token = "@\(mention.nick)"
let text = String(attributed.characters)
var cursor = text.startIndex
while cursor < text.endIndex,
let match = text.range(of: token, range: cursor..<text.endIndex) {
let startOffset = text.distance(from: text.startIndex, to: match.lowerBound)
let endOffset = text.distance(from: text.startIndex, to: match.upperBound)
let attrStart = attributed.index(attributed.startIndex, offsetByCharacters: startOffset)
let attrEnd = attributed.index(attributed.startIndex, offsetByCharacters: endOffset)
attributed[attrStart..<attrEnd].link = mention.feedURL
cursor = match.upperBound
}
}
}
// MARK: - Static data
private static let imageExtensions: Set<String> = [
"png", "jpg", "jpeg", "gif", "webp", "heic"
]
// MARK: - Blocks
private static func extractBlocks(from text: inout String) -> [OrgBlock] {
let pattern = #"(?ms)^[ \t]*#\+BEGIN_(SRC|QUOTE|EXAMPLE)(?:[ \t]+([^\n]*))?\n(.*?)\n[ \t]*#\+END_\1[ \t]*$"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return [] }
let ns = text as NSString
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length))
let blocks: [OrgBlock] = matches.map { m in
let kindStr = ns.substring(with: m.range(at: 1)).uppercased()
let langRange = m.range(at: 2)
let content = ns.substring(with: m.range(at: 3))
let lang: String? = {
guard langRange.location != NSNotFound else { return nil }
let s = ns.substring(with: langRange).trimmingCharacters(in: .whitespaces)
return s.isEmpty ? nil : s
}()
switch kindStr {
case "SRC": return OrgBlock(kind: .src(language: lang), content: content)
case "QUOTE": return OrgBlock(kind: .quote, content: content)
default: return OrgBlock(kind: .example, content: content)
}
}
for m in matches.reversed() {
text = (text as NSString).replacingCharacters(in: m.range, with: "")
}
return blocks
}
// MARK: - Mentions
private static func extractMentions(from text: inout String) -> [OrgMention] {
let pattern = #"\[\[org-social:([^\]]+)\]\[([^\]]+)\]\]"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
let ns = text as NSString
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length))
var mentions: [OrgMention] = []
var seen: Set<URL> = []
for m in matches {
let urlStr = ns.substring(with: m.range(at: 1))
let nick = ns.substring(with: m.range(at: 2))
guard let url = URL(string: urlStr) else { continue }
if seen.insert(url).inserted {
mentions.append(OrgMention(nick: nick, feedURL: url))
}
}
// Replace each mention link in the body text with the inline `@nick`
// short form. This way the sentence reads as written ("Que tal
// @org-social") instead of being fragmented into prose + a detached
// chip. The side-car mentions list is still returned so callers that
// need it (e.g. the mention-aware visibility filter, tap-to-profile
// affordances) keep working.
for m in matches.reversed() {
let nick = ns.substring(with: m.range(at: 2))
text = (text as NSString).replacingCharacters(in: m.range, with: "@\(nick)")
}
return mentions
}
// MARK: - Images
private static func extractImages(from text: inout String) -> [URL] {
var images: [URL] = []
// [[URL][label]] whose URL is an image.
let labeledPattern = #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#
if let rx = try? NSRegularExpression(pattern: labeledPattern) {
let ns = text as NSString
let matches = rx.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length))
for m in matches {
let urlStr = ns.substring(with: m.range(at: 1))
if let url = URL(string: urlStr),
imageExtensions.contains(url.pathExtension.lowercased()) {
images.append(url)
}
}
for m in matches.reversed() {
let urlStr = (text as NSString).substring(with: m.range(at: 1))
guard let url = URL(string: urlStr),
imageExtensions.contains(url.pathExtension.lowercased()) else { continue }
text = (text as NSString).replacingCharacters(in: m.range, with: "")
}
}
// [[URL]] bare whose URL is an image.
let barePattern = #"\[\[([^\]\[]+)\]\]"#
if let rx = try? NSRegularExpression(pattern: barePattern) {
let ns = text as NSString
let matches = rx.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length))
for m in matches {
let urlStr = ns.substring(with: m.range(at: 1))
if let url = URL(string: urlStr),
imageExtensions.contains(url.pathExtension.lowercased()) {
images.append(url)
}
}
for m in matches.reversed() {
let urlStr = (text as NSString).substring(with: m.range(at: 1))
guard let url = URL(string: urlStr),
imageExtensions.contains(url.pathExtension.lowercased()) else { continue }
text = (text as NSString).replacingCharacters(in: m.range, with: "")
}
}
return images
}
// MARK: - Inline: bracket links + emphasis + bare URLs
private static func buildInline(from text: String) -> AttributedString {
// Split the text into plain segments and labeled-link segments first,
// so emphasis rules don't collide with bracketed URLs and we can keep
// the link target on the displayed label.
struct Segment { let text: String; let url: URL? }
var segments: [Segment] = []
let linkPattern = #"\[\[([^\]\[]+)\](?:\[([^\]]+)\])?\]"#
if let rx = try? NSRegularExpression(pattern: linkPattern) {
let ns = text as NSString
let matches = rx.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length))
var cursor = 0
for m in matches {
if m.range.location > cursor {
let plain = ns.substring(with: NSRange(location: cursor, length: m.range.location - cursor))
segments.append(Segment(text: plain, url: nil))
}
let urlStr = ns.substring(with: m.range(at: 1))
let url = URL(string: urlStr)
let labelRange = m.range(at: 2)
let label = labelRange.location != NSNotFound
? ns.substring(with: labelRange)
: urlStr
segments.append(Segment(text: label, url: url))
cursor = m.range.location + m.range.length
}
if cursor < ns.length {
segments.append(Segment(text: ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)), url: nil))
}
} else {
segments = [Segment(text: text, url: nil)]
}
var result = AttributedString()
for seg in segments {
if let url = seg.url {
var piece = AttributedString(seg.text)
piece.link = url
result.append(piece)
} else {
result.append(applyEmphasisAndBareURLs(to: seg.text))
}
}
return result
}
// MARK: - Emphasis + bare URLs
private static func applyEmphasisAndBareURLs(to text: String) -> AttributedString {
guard !text.isEmpty else { return AttributedString() }
var working = AttributedString(text)
for marker in Self.markers {
working = applyMarker(working: working, pattern: marker.pattern, apply: marker.apply)
}
return applyBareURLs(working: working)
}
private static let markers: [(pattern: String, apply: @Sendable (inout AttributedString) -> Void)] = [
emphasisPattern(#"\*"#) { $0.inlinePresentationIntent = .stronglyEmphasized },
emphasisPattern(#"/"#) { $0.inlinePresentationIntent = .emphasized },
emphasisPattern(#"\+"#) { $0.inlinePresentationIntent = .strikethrough },
emphasisPattern(#"~"#) { $0.inlinePresentationIntent = .code },
emphasisPattern(#"="#) { $0.inlinePresentationIntent = .code }
]
/// Builds the Org Mode-ish emphasis regex for a given marker.
///
/// Opening context: start of string or one of `\s ( { [ " '`.
/// Closing context: end of string or one of `\s ) } ] " ' . , ; : ! ? -`.
/// Content: non-whitespace at both ends, no markers or newlines inside.
///
/// Curly quotes (U+2018/U+2019) are not in the context class; an author
/// using curly quotes around an emphasised word won't get it emphasised.
/// Acceptable trade-off for avoiding Unicode escapes in the regex.
private static func emphasisPattern(
_ markerEscaped: String,
apply: @escaping @Sendable (inout AttributedString) -> Void
) -> (pattern: String, apply: @Sendable (inout AttributedString) -> Void) {
let pre = #"(^|[\s({\["'])"#
let post = #"(?=$|[\s)}\]"'\.,;:!?\-])"#
let content = #"([^\s"# + markerEscaped + #"\n](?:[^"# + markerEscaped + #"\n]*[^\s"# + markerEscaped + #"\n])?)"#
return (pre + markerEscaped + content + markerEscaped + post, apply)
}
private static func applyMarker(
working: AttributedString,
pattern: String,
apply: @Sendable (inout AttributedString) -> Void
) -> AttributedString {
let plain = String(working.characters)
guard let regex = try? NSRegularExpression(pattern: pattern) else { return working }
let ns = plain as NSString
let matches = regex.matches(in: plain, options: [], range: NSRange(location: 0, length: ns.length))
guard !matches.isEmpty else { return working }
var rebuilt = AttributedString()
var last = 0
for m in matches {
let prefixRange = m.range(at: 1)
let contentRange = m.range(at: 2)
let outerStart = m.range.location
let outerEnd = m.range.location + m.range.length
if outerStart > last {
rebuilt.append(slice(working: working, plain: plain,
nsRange: NSRange(location: last, length: outerStart - last)))
}
if prefixRange.length > 0 {
rebuilt.append(slice(working: working, plain: plain, nsRange: prefixRange))
}
var piece = slice(working: working, plain: plain, nsRange: contentRange)
apply(&piece)
rebuilt.append(piece)
last = outerEnd
}
if last < ns.length {
rebuilt.append(slice(working: working, plain: plain,
nsRange: NSRange(location: last, length: ns.length - last)))
}
return rebuilt
}
private static func applyBareURLs(working: AttributedString) -> AttributedString {
let plain = String(working.characters)
let pattern = #"https?://[^\s\)\"'\]<>]*[^\s\)\"'\]<>.,;:!?]"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return working }
let ns = plain as NSString
let matches = regex.matches(in: plain, options: [], range: NSRange(location: 0, length: ns.length))
guard !matches.isEmpty else { return working }
var rebuilt = AttributedString()
var last = 0
for m in matches {
if m.range.location > last {
rebuilt.append(slice(working: working, plain: plain,
nsRange: NSRange(location: last, length: m.range.location - last)))
}
var piece = slice(working: working, plain: plain, nsRange: m.range)
if let url = URL(string: ns.substring(with: m.range)) {
piece.link = url
}
rebuilt.append(piece)
last = m.range.location + m.range.length
}
if last < ns.length {
rebuilt.append(slice(working: working, plain: plain,
nsRange: NSRange(location: last, length: ns.length - last)))
}
return rebuilt
}
// MARK: - Slicing
private static func slice(
working: AttributedString,
plain: String,
nsRange: NSRange
) -> AttributedString {
guard let range = Range(nsRange, in: plain) else { return AttributedString() }
let startOffset = plain.distance(from: plain.startIndex, to: range.lowerBound)
let endOffset = plain.distance(from: plain.startIndex, to: range.upperBound)
let startIdx = working.characters.index(working.characters.startIndex, offsetBy: startOffset)
let endIdx = working.characters.index(working.characters.startIndex, offsetBy: endOffset)
return AttributedString(working[startIdx..<endIdx])
}
}