eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// πŸ€– The native side of the web's `groupapps` registry (apps.js groupAppsApp):
4// which mini-apps a GROUP chat has added is itself E2E fold state β€” encrypted
5// groupapps.add / groupapps.remove events in the group's own log, seeded from
6// the thread's encrypted slot-"apps" snapshot β€” so the server never learns
7// which app (or that one) was added. This file is the pure, testable half:
8// the catalog, the snapshot decode and the fold. ChatFeedModel feeds it.
9public enum GroupApps {
10 /// One added app, as the web folds it: the item id IS the app name, so
11 /// re-adding is idempotent and a remove needs no lookup.
12 public struct Item: Equatable, Identifiable, Sendable {
13 public let id: String // the app name ("todo", "bookmarks", …)
14 public var by: String
15 public var ts: Int64 // ms, as stamped by the adder
16 public init(id: String, by: String, ts: Int64) {
17 self.id = id
18 self.by = by
19 self.ts = ts
20 }
21 }
22
23 /// One groupapps.* wire event, keyed by its server message id so pages can
24 /// fold in any arrival order and a recompute stays deterministic.
25 public struct Event: Equatable, Sendable {
26 public let type: String // "groupapps.add" | "groupapps.remove"
27 public let app: String
28 public let by: String
29 public let ts: Int64
30 public init(type: String, app: String, by: String, ts: Int64) {
31 self.type = type
32 self.app = app
33 self.by = by
34 self.ts = ts
35 }
36 }
37
38 // The old 4-app `catalog` (and its `known` read-filter) collapsed into
39 // AppRegistry (docs/native-apps.md phase 2): pickers render the
40 // registry's groupApps, and the install read-filter is registry.known β€”
41 // custom and member apps included, nothing hand-kept per app.
42
43 /// Display titles for the CHAT PROJECTION only ("Ana added To-do to this
44 /// chat") β€” AppChatProjection runs on detached fold paths and can't read
45 /// the MainActor registry, so this nonisolated table stays until phase
46 /// 5's projection queries retire it. Everything UI-facing reads
47 /// AppRegistry instead.
48 public static func title(_ id: String) -> String {
49 let titles = [
50 "todo": "To-do", "remind": "Reminders", "bookmarks": "Bookmarks",
51 "vault": "Vault", "board": "Whiteboard", "poll": "Poll",
52 "webhook-monitor": "Webhook Monitor",
53 ]
54 return titles[id] ?? id
55 }
56
57 /// Recompute the added-apps list from the snapshot seed plus every event
58 /// NEWER than the snapshot's horizon, in log order β€” the web's fold
59 /// (addItem dedupes by id and keeps the original entry; remove deletes).
60 /// `events` is keyed by server message id; ids <= upTo are already inside
61 /// the seed, so they're skipped (apps.js foldInto's `mid <= upTo` rule).
62 public static func fold(seed: [Item], upTo: Int64, events: [Int64: Event]) -> [Item] {
63 var apps = seed
64 for (id, e) in events.sorted(by: { $0.key < $1.key }) {
65 guard id > upTo, !e.app.isEmpty else { continue }
66 switch e.type {
67 case "groupapps.add":
68 if !apps.contains(where: { $0.id == e.app }) {
69 apps.append(Item(id: e.app, by: e.by, ts: e.ts))
70 }
71 case "groupapps.remove":
72 apps.removeAll { $0.id == e.app }
73 default:
74 break
75 }
76 }
77 return apps
78 }
79
80 /// Decode the decrypted slot-"apps" snapshot into a seed. The registry is
81 /// one app on the web now ("installs" owns both the groupapps and
82 /// spaceapps families), so new snapshots hold its state under "installs";
83 /// snapshots written before the unification used the per-family
84 /// "groupapps" key β€” read both, unified winning (apps.js seedThread's
85 /// shim, mirrored; without the new key a current web client's snapshot
86 /// seeds nothing here). Tolerant by design β€” other apps' states share the
87 /// blob and any of it may be missing.
88 public static func parseSnapshot(_ json: Data) -> (seed: [Item], upTo: Int64)? {
89 guard let root = (try? JSONSerialization.jsonObject(with: json)) as? [String: Any] else {
90 return nil
91 }
92 let upTo = (root["upTo"] as? NSNumber)?.int64Value ?? 0
93 let states = root["states"] as? [String: Any]
94 let ga = (states?["installs"] ?? states?["groupapps"]) as? [String: Any]
95 let rows = ga?["apps"] as? [[String: Any]] ?? []
96 let seed: [Item] = rows.compactMap { row in
97 guard let id = row["id"] as? String, !id.isEmpty else { return nil }
98 return Item(id: id,
99 by: row["by"] as? String ?? "",
100 ts: (row["ts"] as? NSNumber)?.int64Value ?? 0)
101 }
102 return (seed, upTo)
103 }
104}
105