eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// The composer typeahead brain — the native mirror of the web's three pieces:
4// ui.js composerToken (which token is the caret in), shell.js typeaheadItems
5// (candidate ranking, identical cutoffs), and emoji.js (the curated shortcode
6// map + emojiMatches + send-time emojify). iOS currently asks the embedded
7// chat.js for this over the bridge (window.lchatNative.typeahead); this port
8// lets both platforms rank natively and identically. Drift gates: the tests
9// PARSE web/static/emoji.js and bot-commands.js from the repo and compare maps.
10
11public enum ComposerTokenKind: Equatable { case command, emoji, mention }
12
13public struct ComposerToken: Equatable {
14 public let kind: ComposerTokenKind
15 public let query: String
16 public let start: Int // Character offset of the trigger — where a pick splices from
17}
18
19// ui.js TOKEN_CHAR: [a-z0-9_+-]i
20private func isTokenChar(_ ch: Character) -> Bool {
21 ch.isASCII && (ch.isLetter || ch.isNumber || ch == "_" || ch == "+" || ch == "-")
22}
23
24/// ui.js composerToken: the typeahead-worthy token the caret sits in, or nil.
25/// `/command` only at the start of the message (caret within the name);
26/// `:emoji` needs a non-empty query (a bare ":" is punctuation); both `@` and
27/// `:` triggers must follow whitespace or start-of-text.
28public func composerToken(_ text: String, caret caretIn: Int? = nil) -> ComposerToken? {
29 let chars = Array(text)
30 let caret = min(caretIn ?? chars.count, chars.count)
31 if chars.first == "/" {
32 let nameEnd = chars.firstIndex(of: " ") ?? chars.count
33 // max(1, …): caret 0 (before the slash) must yield an empty query like
34 // the web's slice(1, 0), not an invalid range that traps.
35 return caret <= nameEnd
36 ? ComposerToken(kind: .command, query: String(chars[1..<max(1, caret)]), start: 0)
37 : nil // caret past the name → args, no menu
38 }
39 var i = caret
40 while i > 0, isTokenChar(chars[i - 1]) { i -= 1 }
41 guard i > 0 else { return nil }
42 let trigger = chars[i - 1]
43 guard trigger == "@" || trigger == ":" else { return nil }
44 if i >= 2, !chars[i - 2].isWhitespace { return nil } // mid-word trigger → not a token
45 let query = String(chars[i..<caret])
46 if trigger == ":" {
47 guard !query.isEmpty else { return nil }
48 return ComposerToken(kind: .emoji, query: query, start: i - 1)
49 }
50 return ComposerToken(kind: .mention, query: query, start: i - 1)
51}
52
53// ---- candidates (shell.js typeaheadItems) --------------------------------
54
55public enum TypeaheadItem: Equatable, Identifiable {
56 case command(name: String, desc: String)
57 case emoji(glyph: String, code: String)
58 // `name` is the PUBLISHED name — what the mention inserts, since every
59 // member must resolve it; `display` is the name THIS member shows for the
60 // person (per-viewer names, #577) — what the row renders.
61 case mention(name: String, userId: String, display: String)
62
63 /// What a pick splices into the draft, replacing token.start…caret.
64 public var insert: String {
65 switch self {
66 case .command(let name, _): return name + " "
67 case .emoji(let glyph, _): return glyph
68 case .mention(let name, _, _): return "@" + name + " "
69 }
70 }
71
72 public var id: String {
73 switch self {
74 case .command(let name, _): return "c|" + name
75 case .emoji(_, let code): return "e|" + code
76 case .mention(_, let userId, _): return "m|" + userId
77 }
78 }
79}
80
81/// shell.js typeaheadItems: candidates for a token, ranked identically to the
82/// web (top 8; commands prefix-matched and bot-gated; mentions substring-matched
83/// against the roster minus yourself). The command catalog is `appCommands` —
84/// the thread's app-local verbs (web commandCatalog(threadId): they lead the
85/// list, bot or no bot; see AppDefaultCommand.localCommands) — then
86/// BOT_COMMANDS, which need a bot in the thread to answer them. The rest of the
87/// web's client-owned commands (/todo, /remind, /help…) still come with the app
88/// layer.
89public func typeaheadItems(_ token: ComposerToken, roster: [RosterEntry],
90 myUserId: String, hasBot: Bool,
91 appCommands: [(name: String, desc: String)] = [],
92 displayNames: [String: String] = [:],
93 rows: [CommandRow] = []) -> [TypeaheadItem] {
94 switch token.kind {
95 case .command:
96 let q = token.query.lowercased()
97 // With a thread's live commandItems() rows (the command host reported
98 // them — docs/native-apps.md phase 3), the web's own per-thread
99 // catalogue drives the menu: every registered app's commands,
100 // place-stamped and ranked by the framework. Bot-answered rows still
101 // gate on a bot being present; /integrity (native-only, absent from
102 // the web catalogue) rides along. Without rows — booting host, older
103 // server — the hard-coded floor below stands, exactly as before.
104 if !rows.isEmpty {
105 let live = rows
106 .filter { !($0.bot ?? false) || hasBot }
107 .map { (name: $0.name, desc: $0.desc ?? "") }
108 + [integrityCommand]
109 var seenLive = Set<String>()
110 return live
111 .filter { $0.name.dropFirst().lowercased().hasPrefix(q) }
112 .filter { cmd in
113 let key = cmd.name.lowercased()
114 guard !seenLive.contains(key) else { return false }
115 seenLive.insert(key)
116 return true
117 }
118 .prefix(8)
119 .map { .command(name: $0.name, desc: $0.desc) }
120 }
121 // The web's rule (shell.js typeaheadItems): bot-answered commands show
122 // only where a bot can answer them; client-answered ones show in every
123 // thread — a botless DM still offers what the client itself runs. The
124 // native split differs from the web's own: here the CLIENT answers
125 // theming and /me (SlashCommands.swift) while /help falls through to a
126 // bot — the web's mirror image — so each side ungates exactly what it
127 // can answer itself.
128 // Ordered like the web's commandCatalog: app verbs lead (appCommands,
129 // below), then the panel commands, then /me, then the bot floor — so
130 // the 8-item cap can only ever hide the TAIL of the bot list (the
131 // web's cap victim is /profile too), never /me, which the previous
132 // append-last ordering silently dropped in every botful thread.
133 let floor = WebPanelCommands.catalog + [meCommand, integrityCommand]
134 + botCommands.filter { hasBot || clientAnswered.contains($0.name) }
135 // Deduplicate by name (case-insensitive) to avoid showing the same command twice
136 var seen = Set<String>()
137 return (appCommands + floor)
138 .filter { $0.name.dropFirst().lowercased().hasPrefix(q) }
139 .filter { cmd in
140 let key = cmd.name.lowercased()
141 guard !seen.contains(key) else { return false }
142 seen.insert(key)
143 return true
144 }
145 .prefix(8)
146 .map { .command(name: $0.name, desc: $0.desc) }
147 case .emoji:
148 return emojiMatches(token.query, limit: 8)
149 .map { .emoji(glyph: $0.emoji, code: $0.code) }
150 case .mention:
151 let q = token.query.lowercased()
152 return roster
153 // NB: JS `includes("")` is true but Swift `contains("")` is false —
154 // an empty query must list everyone, like the web. Typing the name
155 // YOU call someone finds them too (#577, the web's rosterMatches);
156 // what inserts stays the published @name every member can resolve.
157 .filter { $0.user_id != myUserId && !$0.label.isEmpty
158 && (q.isEmpty || $0.label.lowercased().contains(q)
159 || (displayNames[$0.user_id]?.lowercased().contains(q) ?? false)) }
160 .prefix(8)
161 .map { e -> TypeaheadItem in
162 let shown = displayNames[e.user_id] ?? ""
163 return .mention(name: e.label, userId: e.user_id,
164 display: shown.isEmpty ? e.label : shown)
165 }
166 }
167}
168
169// bot-commands.js BOT_COMMANDS — Eleven's built-in floor, same order.
170public let botCommands: [(name: String, desc: String)] = [
171 ("/help", "what I can do"),
172 ("/theme", "switch colour theme, e.g. /theme lagoon"),
173 ("/themes", "browse the theme picker"),
174 ("/dark", "switch to dark mode"),
175 ("/light", "switch to light mode"),
176 ("/profile", "edit your screen name & photo"),
177]
178
179// The subset of the floor the NATIVE client answers itself (SlashCommands.swift
180// routes them locally) — offered in every thread, bot or no bot. /help, /dark,
181// /light and /profile still fall through to a bot, so they stay gated.
182let clientAnswered: Set<String> = ["/theme", "/themes"]
183
184// /me is handled by the composer itself (SlashCommand.parseMe) — listed so the
185// "/" typeahead surfaces it everywhere, like the web's commandCatalog does.
186let meCommand = (name: "/me", desc: "post an action line, e.g. /me waves")
187
188// /integrity is client-answered (SlashCommands.swift → the private report),
189// per-instance, so it's offered in every thread like /me — the native twin of
190// the web's registerFloorCommand("/integrity") entry (shell.js).
191let integrityCommand = (name: "/integrity", desc: "check this space's tamper-evidence (only you see it)")
192
193// ---- emoji (emoji.js) -----------------------------------------------------
194
195/// emoji.js emojiMatches: prefix hits first, then substring; shorter names win
196/// ties; aliases resolving to the same glyph de-duped keeping the first.
197public func emojiMatches(_ query: String, limit: Int = 8) -> [(code: String, emoji: String)] {
198 let q = query.lowercased()
199 guard !q.isEmpty else { return [] }
200 let names = EMOJI.keys.filter { $0.contains(q) }.sorted { a, b in
201 let ap = a.hasPrefix(q) ? 0 : 1
202 let bp = b.hasPrefix(q) ? 0 : 1
203 if ap != bp { return ap < bp }
204 if a.count != b.count { return a.count < b.count }
205 return a < b
206 }
207 var out: [(String, String)] = []
208 var seen = Set<String>()
209 for code in names {
210 let glyph = EMOJI[code]!
211 if seen.contains(glyph) { continue }
212 seen.insert(glyph)
213 out.append((code, glyph))
214 if out.count >= limit { break }
215 }
216 return out
217}
218
219private let shortcodeRE = try! NSRegularExpression(pattern: ":([a-z0-9_+-]+):", options: [.caseInsensitive])
220
221/// emoji.js emojify: replace known :shortcodes: with their emoji on send, so
222/// the encrypted message stores the real glyph. Unknown names (and 10:30-style
223/// text) pass through untouched.
224public func emojify(_ text: String) -> String {
225 let str = text as NSString
226 var out = ""
227 var last = 0
228 for m in shortcodeRE.matches(in: text, range: NSRange(location: 0, length: str.length)) {
229 let name = str.substring(with: m.range(at: 1)).lowercased()
230 out += str.substring(with: NSRange(location: last, length: m.range.location - last))
231 out += EMOJI[name] ?? str.substring(with: m.range)
232 last = m.range.location + m.range.length
233 }
234 out += str.substring(from: last)
235 return out
236}
237
238// emoji.js EMOJI — the curated shortcode map, transcribed verbatim. The drift
239// gate (ComposerTypeaheadTests) parses emoji.js from the repo and compares
240// every entry in both directions, so this can't silently diverge.
241public let EMOJI: [String: String] = [
242 // smileys & emotion
243 "grinning": "😀", "smiley": "😃", "smile": "😄", "grin": "😁", "laughing": "😆", "satisfied": "😆",
244 "sweat_smile": "😅", "rofl": "🤣", "joy": "😂", "slightly_smiling_face": "🙂", "upside_down_face": "🙃",
245 "wink": "😉", "blush": "😊", "innocent": "😇", "heart_eyes": "😍", "star_struck": "🤩", "kissing_heart": "😘",
246 "yum": "😋", "stuck_out_tongue": "😛", "stuck_out_tongue_winking_eye": "😜", "zany_face": "🤪",
247 "money_mouth_face": "🤑", "hugs": "🤗", "thinking": "🤔", "shushing_face": "🤫", "zipper_mouth_face": "🤐",
248 "neutral_face": "😐", "expressionless": "😑", "no_mouth": "😶", "smirk": "😏", "unamused": "😒",
249 "roll_eyes": "🙄", "grimacing": "😬", "lying_face": "🤥", "relieved": "😌", "pensive": "😔", "sleepy": "😪",
250 "sleeping": "😴", "mask": "😷", "face_with_thermometer": "🤒", "nauseated_face": "🤢", "vomiting": "🤮",
251 "sneezing_face": "🤧", "hot_face": "🥵", "cold_face": "🥶", "woozy_face": "🥴", "dizzy_face": "😵",
252 "exploding_head": "🤯", "cowboy_hat_face": "🤠", "partying_face": "🥳", "sunglasses": "😎", "nerd_face": "🤓",
253 "confused": "😕", "worried": "😟", "frowning_face": "🙁", "open_mouth": "😮", "hushed": "😯", "astonished": "😲",
254 "flushed": "😳", "pleading_face": "🥺", "cry": "😢", "sob": "😭", "scream": "😱", "confounded": "😖",
255 "persevere": "😣", "disappointed": "😞", "sweat": "😓", "weary": "😩", "tired_face": "😫", "yawning_face": "🥱",
256 "triumph": "😤", "rage": "😡", "angry": "😠", "cursing_face": "🤬", "smiling_imp": "😈", "imp": "👿",
257 "skull": "💀", "poop": "💩", "hankey": "💩", "clown_face": "🤡", "ghost": "👻", "alien": "👽", "robot": "🤖",
258 // hearts & symbols
259 "heart": "❤️", "orange_heart": "🧡", "yellow_heart": "💛", "green_heart": "💚", "blue_heart": "💙",
260 "purple_heart": "💜", "black_heart": "🖤", "broken_heart": "💔", "two_hearts": "💕", "sparkling_heart": "💖",
261 "heartbeat": "💓", "heartpulse": "💗", "cupid": "💘", "gift_heart": "💝", "100": "💯", "anger": "💢",
262 "boom": "💥", "collision": "💥", "sweat_drops": "💦", "dash": "💨", "zzz": "💤", "fire": "🔥", "star": "⭐",
263 "star2": "🌟", "sparkles": "✨", "dizzy": "💫", "tada": "🎉", "confetti_ball": "🎊", "balloon": "🎈",
264 "gift": "🎁", "white_check_mark": "✅", "heavy_check_mark": "✔️", "x": "❌", "warning": "⚠️", "question": "❓",
265 "exclamation": "❗", "bell": "🔔", "crown": "👑", "gem": "💎", "lock": "🔒", "key": "🔑", "bulb": "💡",
266 // hands & people
267 "+1": "👍", "thumbsup": "👍", "-1": "👎", "thumbsdown": "👎", "ok_hand": "👌", "punch": "👊", "fist": "✊",
268 "wave": "👋", "raised_hands": "🙌", "clap": "👏", "pray": "🙏", "muscle": "💪", "point_up": "☝️",
269 "point_down": "👇", "point_left": "👈", "point_right": "👉", "v": "✌️", "crossed_fingers": "🤞",
270 "handshake": "🤝", "writing_hand": "✍️", "eyes": "👀", "brain": "🧠", "baby": "👶",
271 "shrug": "🤷", "facepalm": "🤦", "strong": "💪", "lol": "😂",
272 // animals & nature
273 "dog": "🐶", "cat": "🐱", "mouse": "🐭", "fox": "🦊", "bear": "🐻", "panda_face": "🐼", "koala": "🐨",
274 "tiger": "🐯", "lion": "🦁", "cow": "🐮", "pig": "🐷", "frog": "🐸", "monkey_face": "🐵", "chicken": "🐔",
275 "penguin": "🐧", "bird": "🐦", "unicorn": "🦄", "bee": "🐝", "bug": "🐛", "butterfly": "🦋", "snail": "🐌",
276 "turtle": "🐢", "fish": "🐟", "dolphin": "🐬", "whale": "🐳", "paw_prints": "🐾", "sun": "☀️", "cloud": "☁️",
277 "rainbow": "🌈", "snowflake": "❄️", "snowman": "⛄", "zap": "⚡", "umbrella": "☔", "droplet": "💧",
278 "ocean": "🌊", "christmas_tree": "🎄", "earth_africa": "🌍", "moon": "🌙",
279 // food & drink
280 "pizza": "🍕", "hamburger": "🍔", "fries": "🍟", "hotdog": "🌭", "taco": "🌮", "popcorn": "🍿",
281 "doughnut": "🍩", "cookie": "🍪", "cake": "🍰", "birthday": "🎂", "icecream": "🍦", "candy": "🍬",
282 "lollipop": "🍭", "chocolate_bar": "🍫", "apple": "🍎", "banana": "🍌", "strawberry": "🍓",
283 "watermelon": "🍉", "grapes": "🍇", "avocado": "🥑", "coffee": "☕", "tea": "🍵", "beer": "🍺",
284 "wine_glass": "🍷", "cocktail": "🍸", "champagne": "🍾",
285 // activity, objects & travel
286 "soccer": "⚽", "basketball": "🏀", "football": "🏈", "baseball": "⚾", "tennis": "🎾", "trophy": "🏆",
287 "medal": "🏅", "dart": "🎯", "game_die": "🎲", "video_game": "🎮", "musical_note": "🎵", "notes": "🎶",
288 "guitar": "🎸", "microphone": "🎤", "headphones": "🎧", "art": "🎨", "camera": "📷", "iphone": "📱",
289 "computer": "💻", "money_with_wings": "💸", "moneybag": "💰", "dollar": "💵", "mag": "🔍", "link": "🔗",
290 "pushpin": "📌", "calendar": "📅", "memo": "📝", "pencil": "📝", "book": "📖", "books": "📚", "email": "✉️",
291 "package": "📦", "rocket": "🚀", "airplane": "✈️", "car": "🚗", "bike": "🚲", "house": "🏠", "school": "🏫",
292 // flags & misc
293 "checkered_flag": "🏁", "tada_alias": "🎉", "jack_o_lantern": "🎃", "santa": "🎅",
294]
295