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// πŸ€– The native side of the companion apps' Chat view: each mini-app event
4// projects into a chat line exactly the way the app's own chatMessage() does
5// on the web (apps/remind.js, apps/todo.js) β€” a bot bubble for something the
6// app tells YOU, an action line for something you did. Only the companion
7// apps (the ones implementing chatMessage) appear here; takeover apps have no
8// chat view to project into.
9public enum AppChatProjection {
10 /// The event payload fields the projections read (decoded from the same
11 /// decrypted blob as MessagePayload β€” apps emit richer objects).
12 public struct AppEvent: Codable {
13 public var type: String?
14 public var text: String?
15 public var app: String? // groupapps.add / groupapps.remove
16 public var when: Double? // ms since epoch (remind.add)
17 public var notice: Notice?
18 public struct Notice: Codable { public var body: String? }
19 }
20
21 /// The start-of-history intro each companion app shows above its chat β€”
22 /// apps/<id>.js chatIntro, verbatim.
23 public static let intros: [String: String] = [
24 "todo": "This is the start of your chat history with To-do. Just type something to add it as a to-do β€” e.g. buy milk. You can see, add, and check off to-dos in the Interface view.",
25 "remind": "This is the start of your chat history with Reminders. Just type what and when β€” e.g. call mum in 10 minutes, or standup at 9:00. You can see and manage your reminders in the Interface view.",
26 ]
27
28 // Inline (not ThreadStore.personalApps): projections run off-main in the
29 // detached history fold, and that catalog is main-actor state.
30 public static func title(_ appID: String) -> String {
31 ["todo": "To-do", "remind": "Reminders"][appID] ?? appID
32 }
33
34 /// One projected chat line answered by the APP HOST (docs/native-apps.md
35 /// phase 5 β€” window.lchatNative.projectEvent): the web's chatMessage /
36 /// groupFeedMessage output plus the owning app's title, so native builds
37 /// the same bubble renderable() synthesizes without re-deriving anything.
38 public struct ProjectedLine: Sendable, Equatable {
39 public let text: String
40 public let action: Bool
41 public let title: String
42 public init(text: String, action: Bool, title: String) {
43 self.text = text
44 self.action = action
45 self.title = title
46 }
47 }
48
49 /// One app event native has NO built-in projection for β€” the raw material
50 /// of a projectEvent query. Decoded when renderable() drew a blank: any
51 /// namespaced event type ("myhabits.checkin") plus the envelope fields an
52 /// answered line needs (attribution + timestamp) and the decrypted JSON
53 /// itself, handed verbatim to the host (native already holds the thread
54 /// key β€” no key material rides the query).
55 public struct UnprojectedAppEvent: Sendable, Equatable {
56 public let eventJSON: String
57 public let name: String
58 public let uid: String?
59 public let ts: Double?
60 }
61
62 nonisolated public static func unprojected(_ blob: String, key: GroupKey) -> UnprojectedAppEvent? {
63 guard let data = try? key.decryptData(blob: blob),
64 let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
65 let type = obj["type"] as? String,
66 let dot = type.firstIndex(of: "."), dot != type.startIndex,
67 let json = String(data: data, encoding: .utf8) else { return nil }
68 // Only namespaced app events query β€” chat.* internals (typing beats)
69 // and anything unnamespaced stay silent, exactly as they fold today.
70 if type.hasPrefix("chat.") { return nil }
71 return UnprojectedAppEvent(eventJSON: json,
72 name: obj["name"] as? String ?? "",
73 uid: obj["uid"] as? String,
74 ts: (obj["ts"] as? NSNumber)?.doubleValue)
75 }
76
77 /// Project one event into a chat line; nil = this event doesn't render
78 /// (state-only events like todo.toggle, mirroring chatMessage's nulls).
79 /// `group` = the event lives in a GROUP thread (chatMessage's opts.group):
80 /// a shared Reminders' fire speaks to everyone β€” the reminder may have
81 /// been set by another member, so "you asked me" would be wrong there.
82 public static func project(_ e: AppEvent, group: Bool = false)
83 -> (text: String, action: Bool, appID: String)? {
84 switch e.type {
85 case "remind.fire":
86 let body = e.notice?.body ?? ""
87 return (group ? "Reminder: β€œ\(body)”" : "You asked me to remind you: β€œ\(body)”",
88 false, "remind")
89 case "remind.add":
90 let at = e.when.map(absTime) ?? ""
91 // Group wording says when in prose ("for 9:00") β€” the line lands
92 // among unrelated conversation; the personal thread keeps the
93 // terse dash. Mirrors remind.js chatMessage exactly.
94 let when = at.isEmpty ? "" : (group ? " for " + at : " β€” " + at)
95 return ("set a reminder: β€œ\(e.text ?? "")”" + when, true, "remind")
96 case "todo.add":
97 // Mirrors todo.js chatMessage: a group line says where the item
98 // went; the personal thread IS the list.
99 return (group ? "added β€œ\(e.text ?? "")” to the to-dos"
100 : "added β€œ\(e.text ?? "")”", true, "todo")
101 // Adding/removing an app on a group is a visible act, like a rename β€”
102 // apps.js groupAppsApp.chatMessage, word for word.
103 case "groupapps.add":
104 return ("added \(groupAppTitle(e.app)) to this chat", true, "groupapps")
105 case "groupapps.remove":
106 return ("removed \(groupAppTitle(e.app)) from this chat", true, "groupapps")
107 default:
108 return nil
109 }
110 }
111
112 private static func groupAppTitle(_ app: String?) -> String {
113 guard let app, !app.isEmpty else { return "an app" }
114 return GroupApps.title(app)
115 }
116
117 // remind.js absTime: "Sat 4 Jul at 23:16". One shared formatter β€”
118 // DateFormatter allocation is the expensive part, and formatting through
119 // an existing one is thread-safe on every OS we ship.
120 private static let absTimeFormatter: DateFormatter = {
121 let f = DateFormatter()
122 f.dateFormat = "EEE d MMM 'at' HH:mm"
123 return f
124 }()
125
126 static func absTime(_ ms: Double) -> String {
127 absTimeFormatter.string(from: Date(timeIntervalSince1970: ms / 1000))
128 }
129}
130