| 1 | import 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 | |
| 11 | public enum ComposerTokenKind: Equatable { case command, emoji, mention } |
| 12 | |
| 13 | public 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 |
| 20 | private 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. |
| 28 | public 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 | |
| 55 | public 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. |
| 89 | public 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. |
| 170 | public 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. |
| 182 | let 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. |
| 186 | let 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). |
| 191 | let 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. |
| 197 | public 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 | |
| 219 | private 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. |
| 224 | public 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. |
| 241 | public 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 | |