eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3import WebKit
4
5// ๐Ÿค– An app thread without a native interface (Admin, webhook-monitor, member
6// apps): the CHAT view is the native feed โ€” NativeChatScreen, the same surface
7// every chat uses โ€” and only the app's own INTERFACE stays a webview, pinned
8// to view:"interface". Chat never renders in a webview (the product rule:
9// webviews are for custom app UI the native primitives can't express, nothing
10// else); this replaced ChatView, the old whole-thread webview fallback.
11//
12// The Chat โ†” Interface switch lives in the โ‹ฎ menu as picker rows โ€” never a
13// segmented control in the bar (it collided with the floating avatar header,
14// and Paul's call 2026-07-25: dropdown items, not tabs). In chat mode the
15// rows ride NativeChatScreen's own โ‹ฎ; in interface mode this screen's.
16//
17// The registry's presentation decides the shape, mirroring the Mac's ladder
18// (NativeChatView.companionSplit / appInterfacePane):
19// companion โ†’ Chat + Interface, switched from the โ‹ฎ (iOS has no
20// split, so defaultView "split" opens chat-first,
21// exactly as ChatView read it)
22// companion, chatOnly โ†’ just the native chat, no switch
23// takeover โ†’ just the interface webview, no chat at all
24// schema (fallback) โ†’ SchemaAppScreen couldn't build its store; same
25// companion shape, with the webview as the interface
26struct AppThreadScreen: View {
27 let instance: SavedInstance
28 let thread: ChatThread
29 let store: ThreadStore
30
31 @State private var mode: String // "chat" | "interface"
32 private let hasChat: Bool
33 private let hasInterface: Bool
34 // The app posts into its chat, the member doesn't (Admin's update stream โ€”
35 // the manifest's chatReadOnly): the native feed drops its composer.
36 private let chatReadOnly: Bool
37 // Passed through to the chat view: its profile cards hop threads.
38 @Binding private var path: [Route]
39 @Environment(\.dismiss) private var dismiss
40
41 @MainActor
42 init(instance: SavedInstance, thread: ChatThread, store: ThreadStore,
43 path: Binding<[Route]>) {
44 self.instance = instance
45 self.thread = thread
46 self.store = store
47 self._path = path
48 switch store.registry.presentation(thread.appId ?? "") {
49 case .companion(let defaultView, let chatOnly, let readOnly):
50 hasChat = true
51 hasInterface = !chatOnly
52 chatReadOnly = readOnly
53 _mode = State(initialValue:
54 (defaultView == "interface" && !chatOnly) ? "interface" : "chat")
55 case .schema(let companion):
56 hasChat = companion
57 hasInterface = true
58 chatReadOnly = false
59 _mode = State(initialValue: "interface")
60 default: // .takeover; .flow routes to ElevenChatScreen before here
61 hasChat = false
62 hasInterface = true
63 chatReadOnly = false
64 _mode = State(initialValue: "interface")
65 }
66 }
67
68 var body: some View {
69 if mode == "chat", hasChat {
70 NativeChatScreen(instance: instance, thread: thread, store: store,
71 path: $path, readOnlyChat: chatReadOnly,
72 appView: hasInterface ? $mode : nil)
73 } else {
74 AppInterfacePane(instance: instance, thread: thread,
75 myUserId: store.myUserId,
76 instanceKeyB64: store.instanceKey())
77 .navigationTitle(thread.title)
78 .navigationBarTitleDisplayMode(.inline)
79 .toolbar {
80 ToolbarItem(placement: .primaryAction) {
81 AppInterfaceKebab(store: store, thread: thread,
82 appView: hasChat ? $mode : nil,
83 onClose: { dismiss() })
84 }
85 }
86 }
87 }
88}
89
90// The Chat โ†” Interface switch as dropdown picker rows: Chat plus the app
91// itself (its own title + symbol), checkmarked by selection. Rides inside a
92// โ‹ฎ Menu โ€” NativeChatScreen's in chat mode, AppInterfaceKebab's in interface
93// mode โ€” so the nav bar stays clear for the floating header.
94struct AppViewPicker: View {
95 let store: ThreadStore
96 let thread: ChatThread
97 @Binding var view: String // "chat" | "interface"
98
99 var body: some View {
100 Picker("View", selection: $view) {
101 Label("Chat", systemImage: "bubble.left").tag("chat")
102 // Labels with SF-symbol icons only โ€” iOS menus silently drop
103 // non-image icons (the emoji glyphs never rendered).
104 Label(store.registry.title(thread.appId ?? ""),
105 systemImage: AppIdentity.symbol(for: thread.appId ?? "") ?? "square.grid.2x2")
106 .tag("interface")
107 }
108 .pickerStyle(.inline)
109 }
110}
111
112// The interface view's โ‹ฎ: the view switch (when the app has a Chat view at
113// all) plus the chat-management basics the chat chrome would otherwise carry.
114struct AppInterfaceKebab: View {
115 let store: ThreadStore
116 let thread: ChatThread
117 /// nil = no Chat view to switch to (takeover apps).
118 let appView: Binding<String>?
119 let onClose: () -> Void
120
121 var body: some View {
122 Menu {
123 if let appView {
124 AppViewPicker(store: store, thread: thread, view: appView)
125 }
126 Button {
127 store.setPinned(thread, !isPinned)
128 } label: {
129 Label(isPinned ? "Unpin chat" : "Pin chat",
130 systemImage: isPinned ? "pin.slash" : "pin")
131 }
132 Button(role: .destructive) {
133 store.setHidden(thread, true)
134 onClose()
135 } label: {
136 Label("Close chat", systemImage: "xmark.circle")
137 }
138 } label: {
139 Image(systemName: "ellipsis.circle")
140 }
141 .accessibilityLabel("Chat options")
142 }
143
144 // Live pinned state: the store row changes as the menu toggles it.
145 private var isPinned: Bool {
146 store.threads.first { $0.id == thread.id }?.pinned ?? thread.pinned
147 }
148}
149
150// The app's own face: the embed page (FeedWebCache) seeded โ€” and held โ€” on
151// view:"interface". The page can carry the whole web client, but this surface
152// only ever shows the app pane; the re-asserts pin a parked or freshly booted
153// page there so it can never sit on a chat view (GroupAppPane's pattern).
154struct AppInterfacePane: View {
155 @ObservedObject private var bridge: WebBridge
156 let instance: SavedInstance
157 let thread: ChatThread
158 let myUserId: String
159 let instanceKeyB64: String?
160
161 @MainActor
162 init(instance: SavedInstance, thread: ChatThread, myUserId: String,
163 instanceKeyB64: String?) {
164 self.instance = instance
165 self.thread = thread
166 self.myUserId = myUserId
167 self.instanceKeyB64 = instanceKeyB64
168 bridge = FeedWebCache.shared.bridge(for: instance, thread: thread,
169 myUserId: myUserId,
170 instanceKeyB64: instanceKeyB64)
171 }
172
173 var body: some View {
174 VStack(spacing: 0) {
175 AppUpdateBar(bridge: bridge)
176 ZStack {
177 AppInterfaceWebView(instance: instance, thread: thread,
178 myUserId: myUserId, instanceKeyB64: instanceKeyB64)
179 if !bridge.pageReady {
180 WebLoadingVeil().transition(.opacity)
181 }
182 }
183 }
184 .animation(.easeOut(duration: 0.25), value: bridge.pageReady)
185 .onAppear { bridge.setAppView("interface") }
186 .onChange(of: bridge.pageReady) { _, ready in
187 if ready { bridge.setAppView("interface") }
188 }
189 }
190}
191
192private struct AppInterfaceWebView: UIViewRepresentable {
193 let instance: SavedInstance
194 let thread: ChatThread
195 let myUserId: String
196 let instanceKeyB64: String?
197
198 func makeUIView(context: Context) -> WKWebView {
199 FeedWebCache.shared.webView(for: instance, thread: thread, myUserId: myUserId,
200 instanceKeyB64: instanceKeyB64)
201 }
202
203 func updateUIView(_ web: WKWebView, context: Context) {}
204}
205