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// 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.
9public 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`.
73public 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
93public 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).
124public 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).
135public 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.
152public 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.
210public 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
222public 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).
240public 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.)
246public 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.
250public 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.
255public 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).
271public 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.
309public 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).
334public 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.
356public 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.
378public 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.
416public 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