| 1 | import 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. |
| 9 | public 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 | |