eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import 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).
10public 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.
109public 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