| 1 | import Foundation |
| 2 | |
| 3 | // @mentions in message text — the render-layer scan (the twin of the web's |
| 4 | // renderRichText plain hook in apps/chat.js). A mention is plain text on the |
| 5 | // wire: "@" + the person's PUBLISHED label, so every member can resolve it |
| 6 | // against their own decrypted roster; nothing here touches the markdown tree |
| 7 | // or the payload (vector `mention_is_render_layer` pins that). Matching is on |
| 8 | // published labels; only what THIS viewer sees on the chip is their name for |
| 9 | // the person (#577). |
| 10 | public struct MentionIndex: Hashable { |
| 11 | public struct Entry: Hashable { |
| 12 | /// The published name mentions travel as — the match key. |
| 13 | public let label: String |
| 14 | public let uid: String |
| 15 | /// What this viewer shows on the chip (their nickname when set). |
| 16 | public let display: String |
| 17 | /// False for yourself — a self-mention stays a plain bold chip. |
| 18 | public let tappable: Bool |
| 19 | public init(label: String, uid: String, display: String, tappable: Bool) { |
| 20 | self.label = label |
| 21 | self.uid = uid |
| 22 | self.display = display |
| 23 | self.tappable = tappable |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | /// Longest label first, so "@Ada Lovelace" wins over a member named "Ada" |
| 28 | /// (the web sorts its alternation the same way). |
| 29 | let entries: [Entry] |
| 30 | /// Cheap cache-buster: bubbles bake mention runs into memoised |
| 31 | /// AttributedStrings, so a roster/nickname change must miss the memo — |
| 32 | /// the web's repaintMentionsOf problem, solved at the key. |
| 33 | public let rev: Int |
| 34 | |
| 35 | public init(entries: [Entry]) { |
| 36 | self.entries = entries |
| 37 | .filter { !$0.label.isEmpty && !$0.uid.isEmpty } |
| 38 | .sorted { $0.label.count > $1.label.count } |
| 39 | var h = Hasher() |
| 40 | h.combine(self.entries) |
| 41 | self.rev = h.finalize() |
| 42 | } |
| 43 | |
| 44 | public var isEmpty: Bool { entries.isEmpty } |
| 45 | |
| 46 | /// Fast pre-check so plain messages keep their no-AttributedString fast |
| 47 | /// paths: only text containing "@" can hold a mention. |
| 48 | public func mayMention(_ s: String) -> Bool { |
| 49 | !entries.isEmpty && s.utf8.contains(UInt8(ascii: "@")) |
| 50 | } |
| 51 | |
| 52 | public enum Segment: Equatable { |
| 53 | case text(String) |
| 54 | case mention(Entry) |
| 55 | } |
| 56 | |
| 57 | /// Split one plain text run into text + mention segments, by the web's |
| 58 | /// exact rules (chat.js renderRichText): the "@" must not sit mid-word or |
| 59 | /// in an email (previous char not [A-Za-z0-9_@.]), the longest published |
| 60 | /// label at that spot wins, and the label must not be followed by a word |
| 61 | /// char — so "@Ada," mentions Ada but "user@Ada.com" mentions no one. |
| 62 | public func split(_ s: String) -> [Segment] { |
| 63 | guard mayMention(s) else { return s.isEmpty ? [] : [.text(s)] } |
| 64 | var out: [Segment] = [] |
| 65 | var plainStart = s.startIndex |
| 66 | var i = s.startIndex |
| 67 | var prev: Character? |
| 68 | while i < s.endIndex { |
| 69 | if s[i] == "@", prev.map(Self.blocksMentionStart) != true, |
| 70 | let entry = match(at: s.index(after: i), in: s) { |
| 71 | if plainStart < i { out.append(.text(String(s[plainStart..<i]))) } |
| 72 | out.append(.mention(entry)) |
| 73 | i = s.index(s.index(after: i), offsetBy: entry.label.count) |
| 74 | plainStart = i |
| 75 | prev = "@" // any char that blocks a directly-adjacent "@" run |
| 76 | continue |
| 77 | } |
| 78 | prev = s[i] |
| 79 | i = s.index(after: i) |
| 80 | } |
| 81 | if plainStart < s.endIndex { out.append(.text(String(s[plainStart...]))) } |
| 82 | return out |
| 83 | } |
| 84 | |
| 85 | private func match(at start: String.Index, in s: String) -> Entry? { |
| 86 | for e in entries where s[start...].hasPrefix(e.label) { |
| 87 | let end = s.index(start, offsetBy: e.label.count) |
| 88 | if end == s.endIndex || !Self.isWordChar(s[end]) { return e } |
| 89 | } |
| 90 | return nil |
| 91 | } |
| 92 | |
| 93 | // JS \w — ASCII letters, digits, underscore (the web regex has no /u). |
| 94 | private static func isWordChar(_ c: Character) -> Bool { |
| 95 | c.isASCII && (c.isLetter || c.isNumber || c == "_") |
| 96 | } |
| 97 | |
| 98 | // The web's prevCh guard: /[\w@.]/ — mid-word and email domains never |
| 99 | // start a mention. |
| 100 | private static func blocksMentionStart(_ c: Character) -> Bool { |
| 101 | isWordChar(c) || c == "@" || c == "." |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // The tappable chip's URL — an app-internal scheme the feed containers |
| 106 | // intercept with an OpenURLAction (AttributedString's .link is the one way a |
| 107 | // run inside a wrapping Text can be tapped). Never registered with the OS; |
| 108 | // it exists only between buildInline and the feed's handler. |
| 109 | public enum MentionLink { |
| 110 | public static let scheme = "lchat-mention" |
| 111 | |
| 112 | public static func url(uid: String) -> URL? { |
| 113 | var c = URLComponents() |
| 114 | c.scheme = scheme |
| 115 | c.host = "" |
| 116 | c.path = "/" + uid // URLComponents percent-encodes what needs it |
| 117 | return c.url |
| 118 | } |
| 119 | |
| 120 | /// The uid a mention tap carries, nil for any other URL. |
| 121 | public static func uid(from url: URL) -> String? { |
| 122 | guard url.scheme == scheme else { return nil } |
| 123 | // URL.path is already percent-DEcoded — no second decode. |
| 124 | let uid = String(url.path.drop(while: { $0 == "/" })) |
| 125 | return uid.isEmpty ? nil : uid |
| 126 | } |
| 127 | } |
| 128 | |