| 1 | import Foundation |
| 2 | |
| 3 | // A saved instance (workspace) on this device. Identity is per-instance: each |
| 4 | // instance gets its own ECDH P-256 keypair. The PRIVATE key is the load-bearing |
| 5 | // secret, so the whole struct lives in the Keychain (InstancesStore) — never |
| 6 | // UserDefaults. `userPriv` feeds the native ECIES unwrap (Crypto.swift) to open |
| 7 | // per-thread keys for the sidebar; it is NEVER handed to a webview, which only ever |
| 8 | // receives a single already-unwrapped thread key. |
| 9 | public struct SavedInstance: Codable, Identifiable, Equatable, Hashable { |
| 10 | public var origin: String // scheme://host[:port] — the instance's URL |
| 11 | public var deviceToken: String // transport auth (NOT a decryption key) |
| 12 | public var screenName: String // our display name in this instance |
| 13 | public var userPub: String // base64url raw (uncompressed) P-256 public key |
| 14 | public var userPriv: String // base64url PKCS#8 private key — Keychain only |
| 15 | public var myUserId: String = "" // our roster user_id, resolved on first roster load |
| 16 | public var groupName: String = ""// cached instance name, for the switcher row |
| 17 | // Whether this space is starred — it populates the rail; the rest collapse |
| 18 | // under "More". Optional so old saved lists (no key) still decode. Synced to |
| 19 | // the home vault's `starred` flag when signed in. |
| 20 | public var starred: Bool? |
| 21 | // Archived spaces hide from the rail AND the overflow list (reachable only |
| 22 | // via the "…" menu's Archived group). Same optional/sync shape as starred. |
| 23 | public var archived: Bool? |
| 24 | // When this device gained the space (epoch ms) — compared against the home |
| 25 | // vault's removal tombstones: a tombstone newer than addedAt means the space |
| 26 | // was deleted on the home page after we got it, so sync drops it here and |
| 27 | // never re-pushes it; an instance added AFTER the tombstone wins instead. |
| 28 | // Optional so pre-tombstone lists decode; missing reads as 0 (older than |
| 29 | // any tombstone) — exactly right for entries that predate the feature. |
| 30 | public var addedAt: Double? |
| 31 | |
| 32 | public var isStarred: Bool { starred ?? false } |
| 33 | public var isArchived: Bool { archived ?? false } |
| 34 | |
| 35 | public init(origin: String, deviceToken: String, screenName: String, |
| 36 | userPub: String, userPriv: String, |
| 37 | myUserId: String = "", groupName: String = "", starred: Bool? = nil, |
| 38 | archived: Bool? = nil, addedAt: Double? = nil) { |
| 39 | self.origin = origin |
| 40 | self.deviceToken = deviceToken |
| 41 | self.screenName = screenName |
| 42 | self.userPub = userPub |
| 43 | self.userPriv = userPriv |
| 44 | self.myUserId = myUserId |
| 45 | self.groupName = groupName |
| 46 | self.starred = starred |
| 47 | self.archived = archived |
| 48 | self.addedAt = addedAt |
| 49 | } |
| 50 | |
| 51 | // One identity per instance per device (v1). The origin is the stable handle. |
| 52 | public var id: String { origin } |
| 53 | |
| 54 | public var baseURL: URL? { URL(string: origin) } |
| 55 | |
| 56 | /// A human label for the switcher until the real group name loads. An |
| 57 | /// unnamed server shows as its host — the API layer already maps the |
| 58 | /// never-named default to "" (LChatAPI.chosenName), so a non-empty |
| 59 | /// groupName here is always admin-chosen. |
| 60 | public var displayName: String { groupName.isEmpty ? hostLabel : groupName } |
| 61 | |
| 62 | /// The server's host (dropping a leading www.), for the switcher subtitle. |
| 63 | public var hostLabel: String { |
| 64 | let h = baseURL?.host ?? origin |
| 65 | return h.hasPrefix("www.") ? String(h.dropFirst(4)) : h |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | // ---- server JSON -------------------------------------------------------- |
| 70 | |
| 71 | // A thread as the server returns it (GET /api/threads). Opaque blobs only: the |
| 72 | // server can't read name_blob/last_blob or unwrap `wrapped`. |
| 73 | public struct ServerThread: Codable { |
| 74 | public let id: String |
| 75 | public let kind: String // "self" | "dm" | "group" |
| 76 | public var name_blob: String? |
| 77 | public var created_at: Int64? |
| 78 | public var wrapped: String? // this user's thread key, wrapped to their pubkey |
| 79 | public var members: [String]? |
| 80 | public var last_blob: String? |
| 81 | public var last_at: Int64? |
| 82 | public var avatar: String? |
| 83 | public var pinned: Bool? |
| 84 | public var hidden: Bool? // this user "closed" the chat (per-user, server-synced) |
| 85 | public var dm_closed: Bool? // an ended DM — the server refuses new messages for BOTH parties (#578) |
| 86 | public var muted: Bool? // this user muted the chat (per-user, server-synced; server skips its pushes) |
| 87 | public var last_read_at: Int64? // this user's read watermark (per-user, server-synced) |
| 88 | public var visibility: String? // "open" for a community group anyone can join |
| 89 | public var open_key: String? // the thread key, encrypted under the instance key |
| 90 | public var open_tier: String? // access level gating the group ("" / nil = everyone) |
| 91 | } |
| 92 | |
| 93 | public struct RosterEntry: Codable, Equatable { |
| 94 | public let user_id: String |
| 95 | public var label: String // display name; decrypted from label_blob when present |
| 96 | public var label_blob: String? // name encrypted under the instance key (server blanks `label` once set this way) |
| 97 | public let public_key: String |
| 98 | // Key transparency (#573): HMAC of public_key under the invite secret T, so |
| 99 | // a T-holder (the inviter) can verify the roster key wasn't substituted |
| 100 | // (InviteCrypto.verifyBindMac). "" / nil for pre-KT or add-device members. |
| 101 | public var bind_mac: String? |
| 102 | public var avatar: String? |
| 103 | public var is_agent: Bool? // an agent/bot helper, not a person (BOT badge in pickers) |
| 104 | public var kind: String? = nil // "ai" = LLM-backed (AI badge); anything else = deterministic bot |
| 105 | // "online" | "away" | "offline" — the effective state the server stamps on |
| 106 | // the roster (presence.go); kept live between refreshes by "presence" frames. |
| 107 | public var presence: String? = nil |
| 108 | // "admin" | "moderator" | "member" — who runs the space (db.go RosterEntry). |
| 109 | // Optional so payloads from older servers still decode; gates UI only (the |
| 110 | // server re-checks the role on every admin API call). |
| 111 | public var role: String? = nil |
| 112 | |
| 113 | public var isAgent: Bool { is_agent ?? false } |
| 114 | public var isAI: Bool { kind == "ai" } |
| 115 | } |
| 116 | |
| 117 | // GET /api/messages row. The blob is opaque to the server; it may contain an |
| 118 | // encrypted reaction event (type="reaction"), which the client decrypts and folds |
| 119 | // into message reactions. edited_at marks server-side edits. A non-nil |
| 120 | // tombstone_of marks a delete tombstone (#581): the server hard-deleted that |
| 121 | // message id and left this empty row so forward catch-up conveys the delete — |
| 122 | // fold it exactly like the live "delete" frame (the tombstone_delete vector in |
| 123 | // chat.json pins this). |
| 124 | public struct ServerMessage: Codable, Identifiable { |
| 125 | public let id: Int64 |
| 126 | public let created_at: Int64 |
| 127 | public let blob: String |
| 128 | public var edited_at: Int64? |
| 129 | public var tombstone_of: Int64? |
| 130 | } |
| 131 | |
| 132 | // One emoji's tally on a message. Decodes both server shapes: history rows |
| 133 | // (ReactionView — has `mine`) and the live "reaction" broadcast (ReactionCount — |
| 134 | // no `mine`, the fan-out can't know the receiver; the client preserves its own). |
| 135 | public struct Reaction: Codable, Hashable, Sendable { |
| 136 | public var emoji: String |
| 137 | public var count: Int |
| 138 | public var mine: Bool? |
| 139 | |
| 140 | public var isMine: Bool { mine ?? false } |
| 141 | } |
| 142 | |
| 143 | // ---- decrypted payloads (native decrypts only previews + group names) ---- |
| 144 | |
| 145 | // The decrypted message body — the full envelope chat.js/shell.js build before |
| 146 | // encrypt(): the app event ({text, attachments?, replyTo?}) stamped with the |
| 147 | // sender's `name`, `uid` and `ts` (ms) by the host (shell.js emit), plus an |
| 148 | // optional content `type` (web/static/message.js — today's messages are |
| 149 | // untyped; typing hints ride ephemeral frames and never reach storage; reactions |
| 150 | // are type="reaction" and carry targetSid/emoji/on instead of text). The |
| 151 | // cross-implementation gate for this shape is chat.json in event-vectors. |
| 152 | public struct MessagePayload: Codable, Equatable, Sendable { |
| 153 | public var type: String? // content type; nil = legacy/untyped chat message; "reaction" = encrypted reaction event |
| 154 | public var uid: String? |
| 155 | public var name: String? |
| 156 | public var text: String? |
| 157 | public var ts: Int64? // sender clock, ms (display uses the server's created_at) |
| 158 | public var action: Bool? // a /me-style app action line, not a bubble |
| 159 | public var attachments: [Attachment]? |
| 160 | public var replyTo: ReplyRef? |
| 161 | // A bot-authored card (Card.swift; web twin cards.js). Held as raw JSON |
| 162 | // and validated at render time, so an unknown future card never fails the |
| 163 | // payload decode — `text` stays its mandatory plain fallback. |
| 164 | public var card: JSONValue? |
| 165 | // An admin warning (issue #578): an ordinary DM message whose payload |
| 166 | // carries warn:true — clients render the same bubble with a "notice from |
| 167 | // an admin" framing. Inside the ciphertext, so the server can't tell a |
| 168 | // warning from a hello. Vector: admin_warning_message in chat.json. |
| 169 | public var warn: Bool? |
| 170 | // Reaction event fields (type == "reaction"): |
| 171 | public var targetSid: Int64? // message id being reacted to |
| 172 | public var targetUid: String? // the reacted-to message's AUTHOR — stamped by the |
| 173 | // reacting client (inside the ciphertext, so the server |
| 174 | // stays blind) so push handlers can show the reaction |
| 175 | // only to the author; nil for legacy uid-less targets |
| 176 | public var emoji: String? // the reaction emoji |
| 177 | public var on: Bool? // true to add, false to remove |
| 178 | public var targetText: String? // a replySnippet of the reacted-to message, stamped by the |
| 179 | // reacting client (inside the ciphertext) so the sidebar can |
| 180 | // say "reacted 👍 to “buy milk”" for a thread built from |
| 181 | // last_blob alone; nil on reactions sent before this shipped |
| 182 | |
| 183 | // Tamper-evidence gossip (docs/tamper-evidence.md, #573): the sender's |
| 184 | // verified checkpoint head, `[seqTo, chain]`, riding inside the encrypted |
| 185 | // payload so the server can neither read nor selectively strip it. Host |
| 186 | // metadata, never chat content — read by the integrity monitor, then |
| 187 | // ignored by rendering (the web strips it before folds; native just never |
| 188 | // renders it). Optional, so it's absent on messages that don't gossip. |
| 189 | public var _ckpt: CkptGossip? |
| 190 | |
| 191 | // Optimistic-send client id (web shell.js `_cid`): a per-message id the |
| 192 | // sender tucks inside the ciphertext so the hub's broadcast carries it back |
| 193 | // verbatim. It lets the sender pair its own echo with the optimistic copy it |
| 194 | // showed on send and upgrade that copy in place (adopt the server id, clear |
| 195 | // pending) instead of duplicating it. Host metadata, never chat content; |
| 196 | // absent on messages sent before this shipped, and ignored by every peer but |
| 197 | // the original sender. Vector contract: chat.json / EventVectorTests. |
| 198 | public var _cid: String? |
| 199 | |
| 200 | /// Mirrors web/static/message.js acceptsMessageType: render untyped (legacy) |
| 201 | /// and "chat" payloads; skip unknown future kinds rather than mis-render them. |
| 202 | public var isRenderableChat: Bool { type == nil || type == "chat" } |
| 203 | |
| 204 | /// Reaction event: type == "reaction" with targetSid, emoji, uid, and on |
| 205 | public var isReactionEvent: Bool { type == "reaction" } |
| 206 | } |
| 207 | |
| 208 | // The quoted-message reference riding inside a reply's payload — chat.js builds |
| 209 | // { id: <server message id>, name, snippet } when the composer stages a reply. |
| 210 | public struct ReplyRef: Codable, Hashable, Sendable { |
| 211 | public var id: Int64? |
| 212 | public var name: String? |
| 213 | public var snippet: String? |
| 214 | |
| 215 | public init(id: Int64? = nil, name: String? = nil, snippet: String? = nil) { |
| 216 | self.id = id |
| 217 | self.name = name |
| 218 | self.snippet = snippet |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | public struct Attachment: Codable, Hashable, Sendable { |
| 223 | public var id: String |
| 224 | public var name: String? |
| 225 | public var type: String? |
| 226 | public var w: Int? // image/video dimensions, for layout before the blob loads |
| 227 | public var h: Int? |
| 228 | public var size: Int64? // bytes, for the file chip's sub-line |
| 229 | public var poster: String? // video poster frame's blob id |
| 230 | public var dur: Double? // video duration, seconds |
| 231 | // BlurHash placeholder (blurha.sh) — derived from the image, so it's user |
| 232 | // content and rides ONLY inside the encrypted payload, like w/h/name. |
| 233 | public var bh: String? |
| 234 | public var isImage: Bool { type?.hasPrefix("image/") ?? false } |
| 235 | public var isVideo: Bool { type?.hasPrefix("video/") ?? false } |
| 236 | public var isGIF: Bool { type == "image/gif" } // what /gif posts (gif.js) |
| 237 | } |
| 238 | |
| 239 | // A group's encrypted name payload (threads.name_blob decrypts to this). |
| 240 | public struct GroupNamePayload: Codable { public var name: String? } |
| 241 | |
| 242 | // A self-thread's encrypted name payload. A bare "Notes" self-thread has none (or |
| 243 | // no `app`); a mini-app thread (To-do, Reminders, …) carries the app id, and — |
| 244 | // for threads created by newer web clients — its display title, so a new app needs |
| 245 | // no native change to be labelled correctly. (web: createAppThread in shell.js.) |
| 246 | public struct AppThreadPayload: Codable { public var app: String?; public var title: String? } |
| 247 | |
| 248 | // An open group's open_key decrypts (under the instance key) to {k: <the |
| 249 | // thread key, base64url>} — written by createThread in web/static/shell.js. |
| 250 | public struct OpenKeyPayload: Codable { public var k: String } |
| 251 | |
| 252 | // A community-directory row (GET /api/threads/open): enough to render and |
| 253 | // self-join an open group. name_blob decrypts with the thread key carried |
| 254 | // inside open_key; the server can open neither blob. |
| 255 | public struct OpenThreadRow: Codable { |
| 256 | public let id: String |
| 257 | public var name_blob: String? |
| 258 | public var created_at: Int64? |
| 259 | public var avatar: String? |
| 260 | public var open_key: String |
| 261 | public var member_count: Int? |
| 262 | public var joined: Bool? |
| 263 | public var default_join: Bool? |
| 264 | public var open_tier: String? // level gate ("" = everyone) |
| 265 | public var tier_name: String? // the gate's plaintext name, for honest locks |
| 266 | public var eligible: Bool? // whether WE may join (server-checked too) |
| 267 | } |
| 268 | |
| 269 | // One access level from GET /api/tiers, personalised to this user (mirrors |
| 270 | // the web's Tier rows; names are plaintext space policy, like branding). |
| 271 | public struct TierRow: Codable, Identifiable { |
| 272 | public let id: String |
| 273 | public var name: String? |
| 274 | public var wrapped: String? // our copy of the level key (ECIES), if granted |
| 275 | public var mine: Bool? |
| 276 | public var members: [String]? |
| 277 | public var have: [String]? |
| 278 | // Paid access (layer 3), same fields the web reads off a Tier. price_cents>0 |
| 279 | // means the level is sellable; status/paid_until/grace_deadline describe THIS |
| 280 | // member's own subscription (source='stripe'). Plaintext commerce config. |
| 281 | public var price_cents: Int? |
| 282 | public var currency: String? |
| 283 | public var interval: String? // 'month' | 'year' |
| 284 | public var status: String? // '' | 'active' | 'past_due' | 'canceled' |
| 285 | public var paid_until: Int64? |
| 286 | public var grace_deadline: Int64? |
| 287 | |
| 288 | /// A sellable level — a member can subscribe to it. |
| 289 | public var sellable: Bool { (price_cents ?? 0) > 0 } |
| 290 | /// The requesting member has (or recently had) a subscription here. |
| 291 | public var subscribed: Bool { !(status ?? "").isEmpty } |
| 292 | /// Price as "€5" (symbol-lite: falls back to the code when unknown), or nil. |
| 293 | public var priceText: String? { |
| 294 | guard let cents = price_cents, cents > 0 else { return nil } |
| 295 | let amount = Double(cents) / 100 |
| 296 | let str = cents % 100 == 0 ? String(Int(amount)) : String(format: "%.2f", amount) |
| 297 | let sym = ["usd": "$", "eur": "€", "gbp": "£"][(currency ?? "").lowercased()] |
| 298 | ?? ((currency ?? "").uppercased() + " ") |
| 299 | return sym + str |
| 300 | } |
| 301 | /// The billing period word for the price ("month" / "year"). |
| 302 | public var pricePer: String { interval == "year" ? "year" : "month" } |
| 303 | } |
| 304 | |
| 305 | // A decrypted directory row, ready for the Discover list on either platform. |
| 306 | // A level-gated group we're not in stays SEALED: locked=true, the title is the |
| 307 | // honest placeholder ("A group for Coaches"), and there is nothing to join. |
| 308 | // keyPending: it's ours but the level key hasn't reached this device yet. |
| 309 | public struct DiscoverGroup: Identifiable, Equatable { |
| 310 | public let id: String |
| 311 | public let title: String |
| 312 | public let memberCount: Int |
| 313 | public let joined: Bool |
| 314 | public let defaultJoin: Bool |
| 315 | public let locked: Bool |
| 316 | public let keyPending: Bool |
| 317 | public let tierName: String |
| 318 | |
| 319 | public init(id: String, title: String, memberCount: Int, joined: Bool, defaultJoin: Bool, |
| 320 | locked: Bool = false, keyPending: Bool = false, tierName: String = "") { |
| 321 | self.id = id |
| 322 | self.title = title |
| 323 | self.memberCount = memberCount |
| 324 | self.joined = joined |
| 325 | self.defaultJoin = defaultJoin |
| 326 | self.locked = locked |
| 327 | self.keyPending = keyPending |
| 328 | self.tierName = tierName |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | // An unfurled OpenGraph card for a posted URL (GET /api/unfurl — the one |
| 333 | // deliberate E2E exception: the server sees the URL, never message text). |
| 334 | public struct LinkPreview: Equatable { |
| 335 | public let url: URL |
| 336 | public let title: String |
| 337 | public let description: String |
| 338 | public let imageURL: URL? |
| 339 | |
| 340 | public init(url: URL, title: String, description: String, imageURL: URL?) { |
| 341 | self.url = url |
| 342 | self.title = title |
| 343 | self.description = description |
| 344 | self.imageURL = imageURL |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | // ---- sidebar view model ------------------------------------------------- |
| 349 | |
| 350 | // Where a native avatar circle fetches its photo: the encrypted blob id plus the |
| 351 | // AES key to open it — the instance key for a member's profile photo, the thread |
| 352 | // key for a group photo — and the transport bits to reach /api/blob. Pure data |
| 353 | // (no UIKit) so it can ride in the shared model; AvatarLoader turns it into an |
| 354 | // image. Mirrors avatarFor / threadAvatarFor in web/static/chat.js — the server |
| 355 | // only ever serves ciphertext, and the key never leaves the device. |
| 356 | public struct AvatarSource: Equatable, Codable { |
| 357 | public var blobID: String |
| 358 | public var keyB64: String |
| 359 | public var baseURL: URL |
| 360 | public var token: String |
| 361 | |
| 362 | /// The token-gated ciphertext fetch URL — /api/blob/<id>?token=… . Blob ids |
| 363 | /// are base64url (URL-safe), so the path component needs no extra escaping. |
| 364 | public var url: URL? { |
| 365 | var comps = URLComponents( |
| 366 | url: baseURL.appending(path: "api/blob").appending(path: blobID), |
| 367 | resolvingAgainstBaseURL: false) |
| 368 | comps?.queryItems = [URLQueryItem(name: "token", value: token)] |
| 369 | return comps?.url |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | // One row in the native thread sidebar — fully decrypted, ready to render. |
| 374 | // (ChatThread, not Thread: an unqualified `Thread` from a library would collide |
| 375 | // with Foundation's in every importing file.) |
| 376 | // Codable so ThreadListSnapshot can park the folded rows (Keychain — they |
| 377 | // carry thread keys) for instant re-entry into a space. |
| 378 | public struct ChatThread: Identifiable, Equatable, Codable { |
| 379 | /// Eleven-the-guide's conversation threads (setup / welcome) — they wear |
| 380 | /// the brand icon (ElevenBrand.icon) instead of an app glyph. |
| 381 | public var isEleven: Bool { appId == "setup" || appId == "welcome" } |
| 382 | public let id: String |
| 383 | public let kind: String // "self" | "dm" | "group" |
| 384 | public var title: String // "Notes" / the other person / the group name |
| 385 | public var preview: String // last-message snippet |
| 386 | public var lastAt: Int64 // seconds; drives ordering + the relative time |
| 387 | public var unread: Bool |
| 388 | // The fold's "last message is yours" attribution — the `!lastMine` input |
| 389 | // of the unread rule, carried so debug surfaces (ThreadDebug.swift) can |
| 390 | // show why a dot is on. Optional so parked snapshots from before the |
| 391 | // field decode; nil reads as unknown. |
| 392 | public var lastMine: Bool? = nil |
| 393 | public var pinned: Bool // user pinned this chat to the top (set on the web/Mac client) |
| 394 | public var hidden: Bool // user "closed" this chat: dropped from the list, still findable in search |
| 395 | public var dmClosed = false // an ended DM (mutual, server-enforced): the composer gives way to the closed banner (#578) |
| 396 | // User muted this chat: no unread dot, no notifications (the server skips |
| 397 | // its pushes too). Optional so parked ThreadListSnapshot rows from before |
| 398 | // the field decode — missing reads as unmuted, via isMuted. |
| 399 | public var muted: Bool? |
| 400 | public var members: [String] |
| 401 | public var threadKeyB64: String // unwrapped — handed to the webview to render the feed |
| 402 | public var avatar: AvatarSource? // the person's / group's photo, if one is set + we hold the key |
| 403 | public var isApp = false // a mini-app self-thread (To-do, …): renders as its webview interface, never a raw native feed |
| 404 | public var appId: String? // the mini-app's id ("todo", "remind", …) when isApp |
| 405 | public var glyph: String? // the app's emoji face for lists/headers (apps have no member photo) |
| 406 | public var badge: String? // list chip: "AI" / "BOT" (a DM with a helper) or "APP" (mini-app thread) |
| 407 | // The row's presence dot ("online" | "away"; nil = none) — set for DM/self |
| 408 | // rows only, from the other member's (or your own) roster presence. |
| 409 | public var presence: String? = nil |
| 410 | |
| 411 | public var isMuted: Bool { muted ?? false } |
| 412 | } |
| 413 | |
| 414 | // A one-line snippet for the sidebar: "Name: text" in groups, just text in DMs — |
| 415 | // mirrors previewText in web/static/chat.js. |
| 416 | public func previewText(kind: String, payload: MessagePayload, mine: Bool = false, |
| 417 | actor: String = "") -> String { |
| 418 | // A reaction event carries no text, so say what happened instead of |
| 419 | // rendering an empty row (web twin: the reaction branch in previewText). |
| 420 | // It names the actor itself, so it returns before the group prefix below. |
| 421 | // `actor` is the caller's by-uid resolution — my name for them (#577), the |
| 422 | // way the web resolves it; the payload's own `name` is only the fallback, |
| 423 | // and it is EMPTY whenever the reacting device had no screen name to stamp |
| 424 | // (which is what made these rows read "Someone reacted 👍"). |
| 425 | if payload.isReactionEvent { |
| 426 | let who = actor.isEmpty ? (payload.name ?? "") : actor |
| 427 | return reactionLine(mine ? "You" : who, payload) |
| 428 | } |
| 429 | // Through the markdown plain projection, never raw: raw text leaks the |
| 430 | // composer's opt-out escapes as visible backslashes (\*lit\*), and shows |
| 431 | // the marks the bubble renders as styling. |
| 432 | var body = markdownPlainText(payload.text ?? "") |
| 433 | .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) |
| 434 | .trimmingCharacters(in: .whitespacesAndNewlines) |
| 435 | if body.isEmpty, let a = payload.attachments, !a.isEmpty { |
| 436 | // Match the web (previewText in shell.js): all-image → "📷 Photo(s)", |
| 437 | // otherwise a file chip "📎 name" so a Word doc doesn't read as a photo. |
| 438 | let n = a.count |
| 439 | if a.allSatisfy({ $0.isImage }) { |
| 440 | body = n > 1 ? "📷 \(n) photos" : "📷 Photo" |
| 441 | } else { |
| 442 | body = n > 1 ? "📎 \(n) files" : "📎 " + (a.first?.name ?? "File") |
| 443 | } |
| 444 | } |
| 445 | // The group prefix takes the same by-uid resolution as the reaction line |
| 446 | // above (web twin: previewText resolves `who` once, for both). So a group |
| 447 | // row names the sender by MY name for them and survives a rename, instead |
| 448 | // of quoting whatever the payload froze at send time — and a message from |
| 449 | // a device with no screen name stops rendering as a bare ": hello". |
| 450 | // An action line ("Ada waves") already names its actor: no prefix, or it |
| 451 | // doubles up. |
| 452 | let who = actor.isEmpty ? (payload.name ?? "") : actor |
| 453 | if kind == "group", !who.isEmpty, payload.action != true { return "\(who): \(body)" } |
| 454 | return body |
| 455 | } |
| 456 | |