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// 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 // "Convert to thread" event fields (type == "threadconvert"): an inline
179 // reply chain moved out of the transcript into a thread rooted at `root`.
180 // The messages themselves are never rewritten (only their author may edit
181 // one, and only briefly), so the conversion is its own encrypted event that
182 // every client folds — the web twin is chat.js's chat.convert. `ids` is the
183 // explicit set that moved, derived by the converting client from the quote
184 // chain it could see, so no two clients disagree about what moved.
185 public var root: Int64?
186 public var ids: [Int64]?
187 public var targetText: String? // a replySnippet of the reacted-to message, stamped by the
188 // reacting client (inside the ciphertext) so the sidebar can
189 // say "reacted 👍 to “buy milk”" for a thread built from
190 // last_blob alone; nil on reactions sent before this shipped
191
192 // Tamper-evidence gossip (docs/tamper-evidence.md, #573): the sender's
193 // verified checkpoint head, `[seqTo, chain]`, riding inside the encrypted
194 // payload so the server can neither read nor selectively strip it. Host
195 // metadata, never chat content — read by the integrity monitor, then
196 // ignored by rendering (the web strips it before folds; native just never
197 // renders it). Optional, so it's absent on messages that don't gossip.
198 public var _ckpt: CkptGossip?
199
200 // Optimistic-send client id (web shell.js `_cid`): a per-message id the
201 // sender tucks inside the ciphertext so the hub's broadcast carries it back
202 // verbatim. It lets the sender pair its own echo with the optimistic copy it
203 // showed on send and upgrade that copy in place (adopt the server id, clear
204 // pending) instead of duplicating it. Host metadata, never chat content;
205 // absent on messages sent before this shipped, and ignored by every peer but
206 // the original sender. Vector contract: chat.json / EventVectorTests.
207 public var _cid: String?
208
209 /// Mirrors web/static/message.js acceptsMessageType: render untyped (legacy)
210 /// and "chat" payloads; skip unknown future kinds rather than mis-render them.
211 public var isRenderableChat: Bool { type == nil || type == "chat" }
212
213 /// Reaction event: type == "reaction" with targetSid, emoji, uid, and on
214 public var isReactionEvent: Bool { type == "reaction" }
215
216 /// "Convert to thread": type == "threadconvert" with root and ids. Like a
217 /// reaction it is neither renderable chat nor an app event, so a client
218 /// that predates it drops the payload and keeps showing the chain inline.
219 public var isThreadConvertEvent: Bool { type == "threadconvert" }
220}
221
222// The quoted-message reference riding inside a reply's payload — chat.js builds
223// { id: <server message id>, name, snippet } when the composer stages a reply.
224public struct ReplyRef: Codable, Hashable, Sendable {
225 public var id: Int64?
226 public var name: String?
227 public var snippet: String?
228 // A reply made IN a thread (the docked pane / the thread view's composer,
229 // "Reply in thread"): it lives only in the thread, Slack-style — the main
230 // feed shows the root with its "N replies" tally, never the reply itself.
231 // Absent/false = an ordinary inline quote reply (the pre-thread wire
232 // shape). The flag rides INSIDE replyTo, in the ciphertext; optional so
233 // history without it round-trips byte-identically (nil encodes as absent,
234 // like every other optional payload field). Vector: message_in_thread in
235 // test/event-vectors/chat.json.
236 public var thread: Bool?
237
238 public init(id: Int64? = nil, name: String? = nil, snippet: String? = nil,
239 thread: Bool? = nil) {
240 self.id = id
241 self.name = name
242 self.snippet = snippet
243 self.thread = thread
244 }
245
246 /// This ref flagged as a thread-only reply — what every thread-originated
247 /// send stages. Only ever writes `true`: an unflagged ref stays nil, so the
248 /// key never appears on the wire for plain replies.
249 public func inThread() -> ReplyRef {
250 var r = self
251 r.thread = true
252 return r
253 }
254}
255
256public struct Attachment: Codable, Hashable, Sendable {
257 public var id: String
258 public var name: String?
259 public var type: String?
260 public var w: Int? // image/video dimensions, for layout before the blob loads
261 public var h: Int?
262 public var size: Int64? // bytes, for the file chip's sub-line
263 public var poster: String? // video poster frame's blob id
264 public var dur: Double? // video duration, seconds
265 // BlurHash placeholder (blurha.sh) — derived from the image, so it's user
266 // content and rides ONLY inside the encrypted payload, like w/h/name.
267 public var bh: String?
268 public var isImage: Bool { type?.hasPrefix("image/") ?? false }
269 public var isVideo: Bool { type?.hasPrefix("video/") ?? false }
270 public var isGIF: Bool { type == "image/gif" } // what /gif posts (gif.js)
271}
272
273// A group's encrypted name payload (threads.name_blob decrypts to this).
274public struct GroupNamePayload: Codable { public var name: String? }
275
276// A self-thread's encrypted name payload. A bare "Notes" self-thread has none (or
277// no `app`); a mini-app thread (To-do, Reminders, …) carries the app id, and —
278// for threads created by newer web clients — its display title, so a new app needs
279// no native change to be labelled correctly. (web: createAppThread in shell.js.)
280public struct AppThreadPayload: Codable { public var app: String?; public var title: String? }
281
282// An open group's open_key decrypts (under the instance key) to {k: <the
283// thread key, base64url>} — written by createThread in web/static/shell.js.
284public struct OpenKeyPayload: Codable { public var k: String }
285
286// A community-directory row (GET /api/threads/open): enough to render and
287// self-join an open group. name_blob decrypts with the thread key carried
288// inside open_key; the server can open neither blob.
289public struct OpenThreadRow: Codable {
290 public let id: String
291 public var name_blob: String?
292 public var created_at: Int64?
293 public var avatar: String?
294 public var open_key: String
295 public var member_count: Int?
296 public var joined: Bool?
297 public var default_join: Bool?
298 public var open_tier: String? // level gate ("" = everyone)
299 public var tier_name: String? // the gate's plaintext name, for honest locks
300 public var eligible: Bool? // whether WE may join (server-checked too)
301}
302
303// One access level from GET /api/tiers, personalised to this user (mirrors
304// the web's Tier rows; names are plaintext space policy, like branding).
305public struct TierRow: Codable, Identifiable {
306 public let id: String
307 public var name: String?
308 public var wrapped: String? // our copy of the level key (ECIES), if granted
309 public var mine: Bool?
310 public var members: [String]?
311 public var have: [String]?
312 // Paid access (layer 3), same fields the web reads off a Tier. price_cents>0
313 // means the level is sellable; status/paid_until/grace_deadline describe THIS
314 // member's own subscription (source='stripe'). Plaintext commerce config.
315 public var price_cents: Int?
316 public var currency: String?
317 public var interval: String? // 'month' | 'year'
318 public var status: String? // '' | 'active' | 'past_due' | 'canceled'
319 public var paid_until: Int64?
320 public var grace_deadline: Int64?
321
322 /// A sellable level — a member can subscribe to it.
323 public var sellable: Bool { (price_cents ?? 0) > 0 }
324 /// The requesting member has (or recently had) a subscription here.
325 public var subscribed: Bool { !(status ?? "").isEmpty }
326 /// Price as "€5" (symbol-lite: falls back to the code when unknown), or nil.
327 public var priceText: String? {
328 guard let cents = price_cents, cents > 0 else { return nil }
329 let amount = Double(cents) / 100
330 let str = cents % 100 == 0 ? String(Int(amount)) : String(format: "%.2f", amount)
331 let sym = ["usd": "$", "eur": "€", "gbp": "£"][(currency ?? "").lowercased()]
332 ?? ((currency ?? "").uppercased() + " ")
333 return sym + str
334 }
335 /// The billing period word for the price ("month" / "year").
336 public var pricePer: String { interval == "year" ? "year" : "month" }
337}
338
339// A decrypted directory row, ready for the Discover list on either platform.
340// A level-gated group we're not in stays SEALED: locked=true, the title is the
341// honest placeholder ("A group for Coaches"), and there is nothing to join.
342// keyPending: it's ours but the level key hasn't reached this device yet.
343public struct DiscoverGroup: Identifiable, Equatable {
344 public let id: String
345 public let title: String
346 public let memberCount: Int
347 public let joined: Bool
348 public let defaultJoin: Bool
349 public let locked: Bool
350 public let keyPending: Bool
351 public let tierName: String
352
353 public init(id: String, title: String, memberCount: Int, joined: Bool, defaultJoin: Bool,
354 locked: Bool = false, keyPending: Bool = false, tierName: String = "") {
355 self.id = id
356 self.title = title
357 self.memberCount = memberCount
358 self.joined = joined
359 self.defaultJoin = defaultJoin
360 self.locked = locked
361 self.keyPending = keyPending
362 self.tierName = tierName
363 }
364}
365
366// An unfurled OpenGraph card for a posted URL (GET /api/unfurl — the one
367// deliberate E2E exception: the server sees the URL, never message text).
368public struct LinkPreview: Equatable {
369 public let url: URL
370 public let title: String
371 public let description: String
372 public let imageURL: URL?
373
374 public init(url: URL, title: String, description: String, imageURL: URL?) {
375 self.url = url
376 self.title = title
377 self.description = description
378 self.imageURL = imageURL
379 }
380}
381
382// ---- sidebar view model -------------------------------------------------
383
384// Where a native avatar circle fetches its photo: the encrypted blob id plus the
385// AES key to open it — the instance key for a member's profile photo, the thread
386// key for a group photo — and the transport bits to reach /api/blob. Pure data
387// (no UIKit) so it can ride in the shared model; AvatarLoader turns it into an
388// image. Mirrors avatarFor / threadAvatarFor in web/static/chat.js — the server
389// only ever serves ciphertext, and the key never leaves the device.
390public struct AvatarSource: Equatable, Codable {
391 public var blobID: String
392 public var keyB64: String
393 public var baseURL: URL
394 public var token: String
395
396 /// The token-gated ciphertext fetch URL — /api/blob/<id>?token=… . Blob ids
397 /// are base64url (URL-safe), so the path component needs no extra escaping.
398 public var url: URL? {
399 var comps = URLComponents(
400 url: baseURL.appending(path: "api/blob").appending(path: blobID),
401 resolvingAgainstBaseURL: false)
402 comps?.queryItems = [URLQueryItem(name: "token", value: token)]
403 return comps?.url
404 }
405}
406
407// One row in the native thread sidebar — fully decrypted, ready to render.
408// (ChatThread, not Thread: an unqualified `Thread` from a library would collide
409// with Foundation's in every importing file.)
410// Codable so ThreadListSnapshot can park the folded rows (Keychain — they
411// carry thread keys) for instant re-entry into a space.
412public struct ChatThread: Identifiable, Equatable, Codable {
413 /// Eleven-the-guide's conversation threads (setup / welcome) — they wear
414 /// the brand icon (ElevenBrand.icon) instead of an app glyph.
415 public var isEleven: Bool { appId == "setup" || appId == "welcome" }
416 public let id: String
417 public let kind: String // "self" | "dm" | "group"
418 public var title: String // "Notes" / the other person / the group name
419 public var preview: String // last-message snippet
420 public var lastAt: Int64 // seconds; drives ordering + the relative time
421 public var unread: Bool
422 // The fold's "last message is yours" attribution — the `!lastMine` input
423 // of the unread rule, carried so debug surfaces (ThreadDebug.swift) can
424 // show why a dot is on. Optional so parked snapshots from before the
425 // field decode; nil reads as unknown.
426 public var lastMine: Bool? = nil
427 public var pinned: Bool // user pinned this chat to the top (set on the web/Mac client)
428 public var hidden: Bool // user "closed" this chat: dropped from the list, still findable in search
429 public var dmClosed = false // an ended DM (mutual, server-enforced): the composer gives way to the closed banner (#578)
430 // User muted this chat: no unread dot, no notifications (the server skips
431 // its pushes too). Optional so parked ThreadListSnapshot rows from before
432 // the field decode — missing reads as unmuted, via isMuted.
433 public var muted: Bool?
434 public var members: [String]
435 public var threadKeyB64: String // unwrapped — handed to the webview to render the feed
436 public var avatar: AvatarSource? // the person's / group's photo, if one is set + we hold the key
437 public var isApp = false // a mini-app self-thread (To-do, …): renders as its webview interface, never a raw native feed
438 public var appId: String? // the mini-app's id ("todo", "remind", …) when isApp
439 public var glyph: String? // the app's emoji face for lists/headers (apps have no member photo)
440 public var badge: String? // list chip: "AI" / "BOT" (a DM with a helper) or "APP" (mini-app thread)
441 // The row's presence dot ("online" | "away"; nil = none) — set for DM/self
442 // rows only, from the other member's (or your own) roster presence.
443 public var presence: String? = nil
444
445 public var isMuted: Bool { muted ?? false }
446}
447
448// A one-line snippet for the sidebar: "Name: text" in groups, just text in DMs —
449// mirrors previewText in web/static/chat.js.
450public func previewText(kind: String, payload: MessagePayload, mine: Bool = false,
451 actor: String = "") -> String {
452 // A reaction event carries no text, so say what happened instead of
453 // rendering an empty row (web twin: the reaction branch in previewText).
454 // It names the actor itself, so it returns before the group prefix below.
455 // `actor` is the caller's by-uid resolution — my name for them (#577), the
456 // way the web resolves it; the payload's own `name` is only the fallback,
457 // and it is EMPTY whenever the reacting device had no screen name to stamp
458 // (which is what made these rows read "Someone reacted 👍").
459 if payload.isReactionEvent {
460 let who = actor.isEmpty ? (payload.name ?? "") : actor
461 return reactionLine(mine ? "You" : who, payload)
462 }
463 // A conversion has no text either — same treatment, same reasons. The count
464 // is what the FOLD would take, not what the payload asked for: a huge
465 // crafted list is capped there, and the line must not claim more than moved.
466 if payload.isThreadConvertEvent {
467 let who = actor.isEmpty ? (payload.name ?? "") : actor
468 return convertLine(mine ? "You" : who,
469 min(payload.ids?.count ?? 0, ChatFeedModel.maxConvertIDs))
470 }
471 // Through the markdown plain projection, never raw: raw text leaks the
472 // composer's opt-out escapes as visible backslashes (\*lit\*), and shows
473 // the marks the bubble renders as styling.
474 var body = markdownPlainText(payload.text ?? "")
475 .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
476 .trimmingCharacters(in: .whitespacesAndNewlines)
477 if body.isEmpty, let a = payload.attachments, !a.isEmpty {
478 // Match the web (previewText in shell.js): all-image → "📷 Photo(s)",
479 // otherwise a file chip "📎 name" so a Word doc doesn't read as a photo.
480 let n = a.count
481 if a.allSatisfy({ $0.isImage }) {
482 body = n > 1 ? "📷 \(n) photos" : "📷 Photo"
483 } else {
484 body = n > 1 ? "📎 \(n) files" : "📎 " + (a.first?.name ?? "File")
485 }
486 }
487 // The group prefix takes the same by-uid resolution as the reaction line
488 // above (web twin: previewText resolves `who` once, for both). So a group
489 // row names the sender by MY name for them and survives a rename, instead
490 // of quoting whatever the payload froze at send time — and a message from
491 // a device with no screen name stops rendering as a bare ": hello".
492 // An action line ("Ada waves") already names its actor: no prefix, or it
493 // doubles up.
494 let who = actor.isEmpty ? (payload.name ?? "") : actor
495 if kind == "group", !who.isEmpty, payload.action != true { return "\(who): \(body)" }
496 return body
497}
498