ios+web: render the chat feed in a WKWebView (hybrid spike) (#16)
Replace the native message-rendering stack (ChatSocket / ChatStore /
ImageProcessing / MessageRow) with a WKWebView that loads the web client's
feed in "embed" mode, so message styling and rendering ship from the web
layer. The native shell keeps the title bar and composer; typed text and
attachments are driven into chat.js through a window.lchatNative bridge,
preserving the single client-side crypto path (the server still only ever
sees ciphertext).
web:
- Embed mode (?embed=1 / window.__lchatEmbed) adds body.native-embed: hide
the web header/composer, go full-width, and keep the attachment preview
visible just above the native composer.
- Expose window.lchatNative { sendText, stageAttachment } and post
{staged}/{error} back to the shell so the native send button can enable
for image-only messages and failures surface instead of vanishing.
ios:
- ChatView hosts the feed in a WKWebView, seeds the session/key into
localStorage, and shows the group name as the title.
- Native composer + attachment picker stage into the web preview. Use
evaluateJavaScript for the bridge (callAsyncJavaScript silently no-ops on
Mac Catalyst) and UIKit's UIDocumentPickerViewController for files
(SwiftUI .fileImporter never fires its completion on Catalyst).
Co-authored-by: Claude <noreply@anthropic.com>
8 files changed,
+546
−1258
ios/LChat/ChatSocket.swift+0 −153ios/LChat/ChatStore.swift+0 −347ios/LChat/ImageProcessing.swift+0 −35ios/LChat/Views/ChatView.swift+328 −299ios/LChat/Views/ComposerField.swift+126 −0ios/LChat/Views/MessageRow.swift+0 −424web/static/chat.js+70 −0web/static/style.css+22 −0
diff --git a/ios/LChat/ChatSocket.swift b/ios/LChat/ChatSocket.swift| deleted file mode 100644 |
| index a1f8dbb..0000000 |
| --- a/ios/LChat/ChatSocket.swift |
| +++ /dev/null |
| @@ -1,153 +0,0 @@ |
| -import Foundation |
| - |
| -// Live feed over the same /ws endpoint the web client uses. Sends and receives |
| -// only opaque ciphertext blobs and reaction commands. Auto-reconnects, and runs |
| -// an app-level keepalive ping (idle proxies drop WebSockets after ~60s — the |
| -// web client does the same). |
| - |
| -enum WSEvent { |
| - case connected |
| - case disconnected |
| - case message(ServerMessage) |
| - case reaction(id: Int64, reactions: [ReactionView]) |
| - case typing(blob: String) |
| -} |
| - |
| -final class ChatSocket: NSObject { |
| - private let session: Session |
| - private var urlSession: URLSession! |
| - private var task: URLSessionWebSocketTask? |
| - private var pingTimer: Timer? |
| - private var closed = false |
| - |
| - /// Delivered on the main queue. |
| - var onEvent: ((WSEvent) -> Void)? |
| - |
| - init(session: Session) { |
| - self.session = session |
| - super.init() |
| - urlSession = URLSession(configuration: .default) |
| - } |
| - |
| - func connect() { |
| - closed = false |
| - var req = URLRequest(url: session.socketURL) |
| - // Satisfy the server's same-origin check (URLSession sends no Origin). |
| - req.setValue(session.origin, forHTTPHeaderField: "Origin") |
| - let task = urlSession.webSocketTask(with: req) |
| - self.task = task |
| - task.resume() |
| - emit(.connected) |
| - startPing() |
| - receive() |
| - } |
| - |
| - func close() { |
| - closed = true |
| - stopPing() |
| - task?.cancel(with: .goingAway, reason: nil) |
| - task = nil |
| - } |
| - |
| - // MARK: Sending |
| - |
| - func sendMessage(blob: String) { |
| - send(["type": "msg", "blob": blob]) |
| - } |
| - |
| - func sendTyping(blob: String) { |
| - send(["type": "typing", "blob": blob]) |
| - } |
| - |
| - func sendReaction(messageID: Int64, emoji: String, add: Bool) { |
| - send([ |
| - "type": add ? "react" : "unreact", |
| - "message_id": messageID, |
| - "emoji": emoji, |
| - ]) |
| - } |
| - |
| - private func send(_ obj: [String: Any]) { |
| - guard let task, |
| - let data = try? JSONSerialization.data(withJSONObject: obj), |
| - let json = String(data: data, encoding: .utf8) |
| - else { return } |
| - task.send(.string(json)) { _ in } |
| - } |
| - |
| - // MARK: Receiving |
| - |
| - private func receive() { |
| - // receive(completionHandler:) fires on a background delegate queue. Hop to |
| - // main so all socket state (task, pingTimer) is only ever touched there. |
| - task?.receive { [weak self] result in |
| - DispatchQueue.main.async { |
| - guard let self else { return } |
| - switch result { |
| - case .failure: |
| - self.handleDrop() |
| - case .success(let message): |
| - if case .string(let text) = message, let data = text.data(using: .utf8) { |
| - self.decode(data) |
| - } |
| - self.receive() |
| - } |
| - } |
| - } |
| - } |
| - |
| - private func decode(_ data: Data) { |
| - guard let type = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["type"] as? String |
| - else { return } |
| - switch type { |
| - case "msg": |
| - if let m = try? JSONDecoder().decode(ServerMessage.self, from: data) { |
| - emit(.message(m)) |
| - } |
| - case "reaction": |
| - struct Frame: Decodable { let message_id: Int64; let reactions: [ReactionView]? } |
| - if let f = try? JSONDecoder().decode(Frame.self, from: data) { |
| - emit(.reaction(id: f.message_id, reactions: f.reactions ?? [])) |
| - } |
| - case "typing": |
| - struct Frame: Decodable { let blob: String } |
| - if let f = try? JSONDecoder().decode(Frame.self, from: data) { |
| - emit(.typing(blob: f.blob)) |
| - } |
| - default: |
| - break |
| - } |
| - } |
| - |
| - // MARK: Reconnect + keepalive |
| - |
| - private func handleDrop() { |
| - guard !closed else { return } |
| - closed = false |
| - stopPing() |
| - task = nil |
| - emit(.disconnected) |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in |
| - guard let self, !self.closed, self.task == nil else { return } |
| - self.connect() |
| - } |
| - } |
| - |
| - private func startPing() { |
| - stopPing() |
| - let timer = Timer(timeInterval: 25, repeats: true) { [weak self] _ in |
| - self?.send(["type": "ping"]) |
| - } |
| - RunLoop.main.add(timer, forMode: .common) |
| - pingTimer = timer |
| - } |
| - |
| - private func stopPing() { |
| - pingTimer?.invalidate() |
| - pingTimer = nil |
| - } |
| - |
| - private func emit(_ event: WSEvent) { |
| - DispatchQueue.main.async { [weak self] in self?.onEvent?(event) } |
| - } |
| -} |
diff --git a/ios/LChat/ChatStore.swift b/ios/LChat/ChatStore.swift| deleted file mode 100644 |
| index e174eb7..0000000 |
| --- a/ios/LChat/ChatStore.swift |
| +++ /dev/null |
| @@ -1,347 +0,0 @@ |
| -import Foundation |
| -import UIKit |
| -import UserNotifications |
| - |
| -// Typing-indicator cadence, mirroring chat.js: re-broadcast every interval while |
| -// typing; tell receivers to keep showing it for the (longer) expiry so a single |
| -// late beat doesn't make it flicker. |
| -private let kTypingInterval: TimeInterval = 10 |
| -private let kTypingExpiry = 15 |
| - |
| -// One decrypted, ready-to-render message. |
| -struct ChatMessage: Identifiable { |
| - let id: Int64 |
| - let createdAt: Int64 |
| - var payload: MessagePayload |
| - var reactions: [ReactionView] |
| - let mine: Bool |
| - |
| - var date: Date { |
| - if let ts = payload.ts { return Date(timeIntervalSince1970: ts / 1000) } |
| - return Date(timeIntervalSince1970: TimeInterval(createdAt)) |
| - } |
| -} |
| - |
| -@MainActor |
| -final class ChatStore: ObservableObject { |
| - @Published private(set) var messages: [ChatMessage] = [] |
| - @Published private(set) var connected = true |
| - @Published private(set) var groupName = "lchat" |
| - @Published private(set) var loadError: String? |
| - @Published private(set) var unread = 0 |
| - |
| - // Who's currently typing (drives the subtle indicator above the composer). |
| - @Published private(set) var typingNames: [String] = [] |
| - |
| - // Whether the app is frontmost. New messages that land while it isn't count |
| - // toward the app-icon badge (works best on Mac, which keeps the socket alive |
| - // in the background; iOS suspends it without push). |
| - private var appActive = true |
| - |
| - private(set) var session: Session |
| - private let api: LChatAPI |
| - private var key: GroupKey? |
| - private var socket: ChatSocket? |
| - |
| - private var seen = Set<Int64>() |
| - private var lastId: Int64 = 0 |
| - private var myReactions: [Int64: Set<String>] = [:] // emojis this device applied |
| - private var replyCounts: [Int64: Int] = [:] |
| - private var imageCache: [String: UIImage] = [:] |
| - |
| - // Typing indicators. Received: sender name -> when their hint lapses, swept |
| - // every second while anyone's active. Sent: a beat re-broadcasts while you |
| - // type, an idle timer stops it once you pause (peers then expire it). |
| - private var typingUntil: [String: Date] = [:] |
| - private var typingSweep: Timer? |
| - private var typingBeat: Timer? |
| - private var typingIdle: Timer? |
| - |
| - init(session: Session) { |
| - self.session = session |
| - self.api = LChatAPI(baseURL: session.baseURL, token: session.token) |
| - self.key = try? GroupKey(base64url: session.keyB64) |
| - if key == nil { loadError = "This link's secret key is malformed." } |
| - } |
| - |
| - // MARK: Lifecycle |
| - |
| - func start() async { |
| - _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) |
| - groupName = await api.groupName() |
| - await loadHistory() |
| - let socket = ChatSocket(session: session) |
| - socket.onEvent = { [weak self] event in self?.handle(event) } |
| - self.socket = socket |
| - socket.connect() |
| - } |
| - |
| - /// Track foreground state. Becoming active clears the unread badge. |
| - func setAppActive(_ active: Bool) { |
| - appActive = active |
| - if active { |
| - unread = 0 |
| - updateBadge(0) |
| - } |
| - } |
| - |
| - private func updateBadge(_ n: Int) { |
| - UNUserNotificationCenter.current().setBadgeCount(n) |
| - } |
| - |
| - /// Highest message id seen so far (drives the chat list's unread badge). |
| - var latestId: Int64 { lastId } |
| - |
| - /// Stop the live connection (leaving the chat or removing it). Persistence of |
| - /// the chat list itself lives in AppState. |
| - func disconnect() { |
| - stopTyping() |
| - typingSweep?.invalidate(); typingSweep = nil |
| - socket?.close() |
| - } |
| - |
| - func rename(_ name: String) { |
| - let trimmed = String(name.trimmingCharacters(in: .whitespacesAndNewlines).prefix(32)) |
| - guard !trimmed.isEmpty, trimmed != session.name else { return } |
| - session.name = trimmed |
| - } |
| - |
| - private func loadHistory() async { |
| - guard key != nil else { return } |
| - do { |
| - let msgs = try await api.messages(after: 0) |
| - for m in msgs { ingest(m) } |
| - } catch { |
| - loadError = "Couldn't load messages. Pull to retry." |
| - } |
| - } |
| - |
| - // MARK: Socket events |
| - |
| - private func handle(_ event: WSEvent) { |
| - switch event { |
| - case .connected: |
| - connected = true |
| - Task { await fillGap() } |
| - case .disconnected: |
| - connected = false |
| - case .message(let m): |
| - ingest(m, live: true) |
| - case .reaction(let id, let reactions): |
| - applyReactions(id: id, reactions: reactions) |
| - case .typing(let blob): |
| - handleTyping(blob: blob) |
| - } |
| - } |
| - |
| - private func fillGap() async { |
| - guard let missed = try? await api.messages(after: lastId) else { return } |
| - for m in missed { ingest(m, live: true) } |
| - } |
| - |
| - // MARK: Ingest |
| - |
| - private func ingest(_ m: ServerMessage, live: Bool = false) { |
| - guard let key, !seen.contains(m.id) else { return } |
| - seen.insert(m.id) |
| - lastId = max(lastId, m.id) |
| - // Skip messages we can't decrypt or whose kind this client doesn't |
| - // render (a nil type is a legacy chat). They stay in `seen`/lastId so |
| - // gap fill won't re-fetch them — chat.js advances both before decrypt. |
| - guard let payload = try? key.decryptJSON(MessagePayload.self, blob: m.blob), |
| - MessageType.accepts(payload.type) else { return } |
| - |
| - var mine = Set<String>() |
| - if let rs = m.reactions { |
| - mine = Set(rs.filter { $0.mine == true }.map(\.emoji)) |
| - if !mine.isEmpty { myReactions[m.id] = mine } |
| - } |
| - if let parent = payload.replyTo?.id { |
| - replyCounts[parent, default: 0] += 1 |
| - } |
| - let msg = ChatMessage( |
| - id: m.id, |
| - createdAt: m.created_at, |
| - payload: payload, |
| - reactions: m.reactions ?? [], |
| - mine: payload.name == session.name |
| - ) |
| - insertSorted(msg) |
| - |
| - // A message that arrives while we're backgrounded bumps the app-icon badge. |
| - if live, !appActive, !msg.mine { |
| - unread += 1 |
| - updateBadge(unread) |
| - } |
| - } |
| - |
| - private func insertSorted(_ msg: ChatMessage) { |
| - if let idx = messages.firstIndex(where: { $0.id > msg.id }) { |
| - messages.insert(msg, at: idx) |
| - } else { |
| - messages.append(msg) |
| - } |
| - } |
| - |
| - func replyCount(for id: Int64) -> Int { replyCounts[id] ?? 0 } |
| - |
| - // MARK: Reactions |
| - |
| - /// Server broadcast: authoritative counts, but no per-device "mine" flag — |
| - /// recompute that from what this device has applied (same as chat.js). |
| - private func applyReactions(id: Int64, reactions: [ReactionView]) { |
| - guard let idx = messages.firstIndex(where: { $0.id == id }) else { return } |
| - let mine = myReactions[id] ?? [] |
| - messages[idx].reactions = reactions.map { |
| - ReactionView(emoji: $0.emoji, count: $0.count, mine: mine.contains($0.emoji)) |
| - } |
| - } |
| - |
| - func isMine(_ id: Int64, _ emoji: String) -> Bool { |
| - myReactions[id]?.contains(emoji) ?? false |
| - } |
| - |
| - func toggleReaction(_ id: Int64, _ emoji: String) { |
| - // Bail before touching local state if we can't actually send — an offline |
| - // toggle would never reach the server and would drift permanently (the web |
| - // client guards the same way). |
| - guard connected, let idx = messages.firstIndex(where: { $0.id == id }) else { return } |
| - var mine = myReactions[id] ?? [] |
| - let adding = !mine.contains(emoji) |
| - var list = messages[idx].reactions |
| - |
| - if adding { |
| - mine.insert(emoji) |
| - if let i = list.firstIndex(where: { $0.emoji == emoji }) { |
| - list[i] = ReactionView(emoji: emoji, count: list[i].count + 1, mine: true) |
| - } else { |
| - list.append(ReactionView(emoji: emoji, count: 1, mine: true)) |
| - } |
| - } else { |
| - mine.remove(emoji) |
| - if let i = list.firstIndex(where: { $0.emoji == emoji }) { |
| - let c = list[i].count - 1 |
| - if c <= 0 { list.remove(at: i) } |
| - else { list[i] = ReactionView(emoji: emoji, count: c, mine: false) } |
| - } |
| - } |
| - myReactions[id] = mine |
| - messages[idx].reactions = list |
| - socket?.sendReaction(messageID: id, emoji: emoji, add: adding) |
| - } |
| - |
| - // MARK: Typing indicator |
| - |
| - /// The composer changed: beat a typing hint while there's text, stop on empty. |
| - func composerChanged(isEmpty: Bool) { |
| - if isEmpty { stopTyping(); return } |
| - if typingBeat == nil { |
| - sendTyping() |
| - typingBeat = scheduled(every: kTypingInterval, repeats: true) { [weak self] in |
| - self?.sendTyping() |
| - } |
| - } |
| - // Restart the idle timer: one quiet interval and we stop beating. |
| - typingIdle?.invalidate() |
| - typingIdle = scheduled(every: kTypingInterval, repeats: false) { [weak self] in |
| - self?.stopTyping() |
| - } |
| - } |
| - |
| - private func sendTyping() { |
| - guard let key, connected else { return } |
| - let payload = TypingPayload(name: session.name, expiry: kTypingExpiry) |
| - guard let blob = try? key.encryptJSON(payload) else { return } |
| - socket?.sendTyping(blob: blob) |
| - } |
| - |
| - private func stopTyping() { |
| - typingBeat?.invalidate(); typingBeat = nil |
| - typingIdle?.invalidate(); typingIdle = nil |
| - } |
| - |
| - /// A received typing hint: ignore our own echo, then (re)set that sender's |
| - /// expiry to now + their requested duration and start the sweep. |
| - private func handleTyping(blob: String) { |
| - guard let key, let p = try? key.decryptJSON(TypingPayload.self, blob: blob) else { return } |
| - guard p.name != session.name else { return } |
| - let secs = min(max(p.expiry, 0), 60) // clamp; a hostile peer can't pin it forever |
| - guard secs > 0 else { return } |
| - typingUntil[p.name] = Date().addingTimeInterval(TimeInterval(secs)) |
| - refreshTyping() |
| - if typingSweep == nil { |
| - typingSweep = scheduled(every: 1, repeats: true) { [weak self] in self?.sweepTyping() } |
| - } |
| - } |
| - |
| - private func sweepTyping() { |
| - refreshTyping() |
| - if typingUntil.isEmpty { typingSweep?.invalidate(); typingSweep = nil } |
| - } |
| - |
| - /// Drop lapsed senders and republish the (sorted, stable) name list. |
| - private func refreshTyping() { |
| - let now = Date() |
| - typingUntil = typingUntil.filter { $0.value > now } |
| - typingNames = typingUntil.keys.sorted() |
| - } |
| - |
| - // A main-runloop timer whose body runs back on the main actor. Mirrors the |
| - // keepalive timer in ChatSocket; the @MainActor hop keeps store state on one |
| - // thread. |
| - private func scheduled(every seconds: TimeInterval, repeats: Bool, _ body: @escaping () -> Void) -> Timer { |
| - let t = Timer(timeInterval: seconds, repeats: repeats) { _ in |
| - Task { @MainActor in body() } |
| - } |
| - RunLoop.main.add(t, forMode: .common) |
| - return t |
| - } |
| - |
| - // MARK: Sending |
| - |
| - func send(text: String, attachment: Attachment?, replyTo: ReplyTo?) { |
| - guard let key else { return } |
| - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| - guard !trimmed.isEmpty || attachment != nil else { return } |
| - stopTyping() // sending means you've stopped typing this message |
| - var payload = MessagePayload(name: session.name) |
| - payload.type = MessageType.chat |
| - payload.text = trimmed |
| - payload.ts = (Date().timeIntervalSince1970 * 1000).rounded() |
| - if let attachment { payload.attachments = [attachment] } |
| - if let replyTo { payload.replyTo = replyTo } |
| - guard let blob = try? key.encryptJSON(payload) else { return } |
| - socket?.sendMessage(blob: blob) |
| - } |
| - |
| - /// Encrypt + upload a file, returning its attachment metadata. Images are |
| - /// downscaled/re-encoded first; any other file uploads as-is. |
| - func uploadAttachment(_ data: Data, fileName: String, fileType: String?) async -> Attachment? { |
| - guard let key else { return nil } |
| - let processed = processImageForUpload(data, fileType: fileType) |
| - let bytes = processed?.data ?? data |
| - let type = processed?.type ?? fileType ?? "application/octet-stream" |
| - guard let enc = try? key.encryptBytes(bytes), let id = try? await api.upload(enc) else { |
| - return nil |
| - } |
| - var att = Attachment(id: id, name: fileName, type: type, size: bytes.count) |
| - att.w = processed?.w |
| - att.h = processed?.h |
| - return att |
| - } |
| - |
| - // MARK: Attachment download/decrypt |
| - |
| - func loadImage(_ att: Attachment) async -> UIImage? { |
| - if let cached = imageCache[att.id] { return cached } |
| - guard let data = await decryptedBytes(att) else { return nil } |
| - guard let img = UIImage(data: data) else { return nil } |
| - imageCache[att.id] = img |
| - return img |
| - } |
| - |
| - func decryptedBytes(_ att: Attachment) async -> Data? { |
| - guard let key, let blob = try? await api.blob(id: att.id) else { return nil } |
| - return try? key.decryptBytes(blob) |
| - } |
| -} |
diff --git a/ios/LChat/ImageProcessing.swift b/ios/LChat/ImageProcessing.swift| deleted file mode 100644 |
| index 868e75e..0000000 |
| --- a/ios/LChat/ImageProcessing.swift |
| +++ /dev/null |
| @@ -1,35 +0,0 @@ |
| -import UIKit |
| - |
| -struct ProcessedImage { |
| - let data: Data |
| - let type: String |
| - let w: Int |
| - let h: Int |
| -} |
| - |
| -// Downscale to a 2048px long edge and re-encode before upload. Re-encoding also |
| -// strips EXIF/GPS — the same reasoning as processImage() in chat.js. There is no |
| -// server-side image proxy (it only ever holds ciphertext), so this happens here. |
| -func processImageForUpload(_ data: Data, fileType: String?) -> ProcessedImage? { |
| - guard let src = UIImage(data: data), let cg = src.cgImage else { return nil } |
| - let pw = CGFloat(cg.width), ph = CGFloat(cg.height) |
| - let maxDim: CGFloat = 2048 |
| - let scale = min(1, maxDim / max(pw, ph)) |
| - let w = max(1, Int((pw * scale).rounded())) |
| - let h = max(1, Int((ph * scale).rounded())) |
| - let isPNG = (fileType == "image/png") |
| - |
| - let target = CGSize(width: w, height: h) |
| - let format = UIGraphicsImageRendererFormat() |
| - format.scale = 1 |
| - format.opaque = !isPNG |
| - let rendered = UIGraphicsImageRenderer(size: target, format: format).image { _ in |
| - src.draw(in: CGRect(origin: .zero, size: target)) |
| - } |
| - |
| - if isPNG, let out = rendered.pngData() { |
| - return ProcessedImage(data: out, type: "image/png", w: w, h: h) |
| - } |
| - guard let out = rendered.jpegData(compressionQuality: 0.85) else { return nil } |
| - return ProcessedImage(data: out, type: "image/jpeg", w: w, h: h) |
| -} |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index 55d4cb7..0fd81f6 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -1,279 +1,162 @@ |
| import SwiftUI |
| +import WebKit |
| import PhotosUI |
| import UniformTypeIdentifiers |
| +import UIKit |
| +// Webview spike (hybrid): the message *feed* is the web client (chat.js) running |
| +// in a WKWebView — so styling and how messages render ship for free — while the |
| +// native shell keeps the title bar and composer for a native feel. |
| +// |
| +// The webview loads in "embed" mode (window.__lchatEmbed): chat.js hides its own |
| +// header + composer and goes full-width, leaving just the feed. The native |
| +// composer forwards typed text to chat.js's send() via window.lchatNative, so all |
| +// encryption + WebSocket logic stays in one place (the web layer). |
| +// |
| +// E2E is preserved: the Keychain (ChatsStore) stays the source of truth for keys, |
| +// and ChatWebView seeds this chat's key into the page's localStorage so crypto |
| +// runs in chat.js — the key is never transmitted; the server sees only ciphertext. |
| +// |
| +// Attachments: the native + button (and drag-drop onto the feed) stage a photo/ |
| +// file into the web composer's preview — shown just above the native composer — |
| +// to be sent with the next message. All upload/encrypt stays in the web layer. |
| +// |
| +// Deferred follow-ups: reply/edit banners (replying still works by tapping a |
| +// bubble in the feed), the web menu's rename/notifications, and unread-badge sync |
| +// on close. |
| struct ChatView: View { |
| @EnvironmentObject var app: AppState |
| - @Environment(\.scenePhase) private var scenePhase |
| - @StateObject private var store: ChatStore |
| + @StateObject private var bridge = WebBridge() |
| + @State private var groupName: String? |
| @State private var text = "" |
| - @State private var replyingTo: ChatMessage? |
| - @State private var pickerItem: PhotosPickerItem? |
| - @State private var pendingAttachment: Attachment? |
| - @State private var pendingPreview: UIImage? |
| - @State private var pendingName: String? |
| - @State private var uploadingAttachment = false |
| - @State private var showPhotos = false |
| - @State private var showFiles = false |
| - @State private var showRename = false |
| - @State private var renameText = "" |
| + @State private var composerFocused = false |
| @State private var showLeave = false |
| - @State private var flashID: Int64? |
| - @State private var dropTargeted = false |
| - @FocusState private var composerFocused: Bool |
| + @State private var showPhotos = false |
| + @State private var pickerItem: PhotosPickerItem? |
| + @State private var attachError: String? |
| let chat: SavedChat |
| private var isMac: Bool { ProcessInfo.processInfo.isMacCatalystApp } |
| - init(chat: SavedChat) { |
| - self.chat = chat |
| - // chat.session is only nil if the saved URL is malformed; fall back to an |
| - // unreachable session so the view shows an error rather than crashing. |
| - let session = chat.session ?? Session( |
| - baseURL: URL(string: "https://invalid.invalid")!, token: "", keyB64: "", name: chat.name) |
| - _store = StateObject(wrappedValue: ChatStore(session: session)) |
| + // The chat's own name (group name), fetched from the server; falls back to the |
| + // host until it loads. This is the title — not the URL/host. |
| + private var title: String { groupName ?? chat.session?.baseURL.host ?? "lchat" } |
| + |
| + private var canSend: Bool { |
| + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || bridge.attachmentStaged |
| } |
| var body: some View { |
| NavigationStack { |
| VStack(spacing: 0) { |
| - if !store.connected { |
| - Text("Reconnecting…") |
| - .font(.footnote) |
| - .frame(maxWidth: .infinity) |
| - .padding(.vertical, 4) |
| - .background(.yellow.opacity(0.25)) |
| - } |
| - feed |
| - if !store.typingNames.isEmpty { |
| - typingIndicator |
| + if let session = chat.session { |
| + ChatWebView(session: session, bridge: bridge) |
| + .frame(maxWidth: .infinity, maxHeight: .infinity) |
| + composer |
| + } else { |
| + ContentUnavailableView( |
| + "Can't open this chat", |
| + systemImage: "exclamationmark.triangle", |
| + description: Text("The saved invite link is malformed.") |
| + ) |
| } |
| - composer |
| } |
| - // Beat a typing hint to others while there's text in the composer. |
| - .onChange(of: text) { _, new in |
| - store.composerChanged(isEmpty: new.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) |
| - } |
| - // On the Mac there's no soft keyboard to worry about, so focus the |
| - // composer on entry — the Return-to-send shortcut then works without |
| - // first clicking into the field. On iOS we leave it unfocused so the |
| - // keyboard doesn't immediately cover the feed. |
| - .onAppear { if isMac { composerFocused = true } } |
| - // On the Mac the name shows in the native titlebar, so don't repeat it |
| - // in the nav bar (which carries just the back chevron + menu there). |
| - .navigationTitle(isMac ? "" : store.groupName) |
| + .navigationTitle(isMac ? "" : title) |
| .navigationBarTitleDisplayMode(.inline) |
| .toolbar { toolbarMenu } |
| - .overlay { |
| - if dropTargeted { |
| - RoundedRectangle(cornerRadius: 12) |
| - .strokeBorder(Color.accentColor, lineWidth: 3) |
| - .background(Color.accentColor.opacity(0.06)) |
| - .allowsHitTesting(false) |
| - .ignoresSafeArea() |
| - } |
| - } |
| - .onDrop(of: [.item], isTargeted: $dropTargeted) { providers in handleDrop(providers) } |
| + .onAppear { if isMac { composerFocused = true } } |
| .background(EdgeSwipeBack { goBack() }) |
| #if targetEnvironment(macCatalyst) |
| - .background(MacWindowTitle(title: store.groupName)) |
| + .background(MacWindowTitle(title: title)) |
| #endif |
| } |
| - .task { await store.start() } |
| - .onChange(of: scenePhase, initial: true) { _, phase in |
| - store.setAppActive(phase == .active) |
| - if phase != .active { app.markSeen(store.latestId, in: chat) } |
| - } |
| - .onDisappear { app.markSeen(store.latestId, in: chat) } |
| - .alert("Screen name", isPresented: $showRename) { |
| - TextField("Name", text: $renameText) |
| - Button("Save") { store.rename(renameText); app.setName(store.session.name, for: chat) } |
| - Button("Cancel", role: .cancel) {} |
| - } |
| + .task { await loadGroupName() } |
| .confirmationDialog("Leave this chat?", isPresented: $showLeave, titleVisibility: .visible) { |
| Button("Leave", role: .destructive) { leaveChat() } |
| Button("Cancel", role: .cancel) {} |
| } message: { |
| Text("This removes it from this device. You'll need the invite link to rejoin.") |
| } |
| - } |
| - |
| - // MARK: Feed |
| - |
| - private var feed: some View { |
| - GeometryReader { geo in |
| - // Cap bubbles at ~78% of the feed width (Messages-style), so they read |
| - // wide on a phone yet don't stretch the whole way across a Mac window. |
| - let maxBubble = min(geo.size.width * 0.78, 560) |
| - ScrollViewReader { proxy in |
| - ScrollView { |
| - LazyVStack(spacing: 3) { |
| - if let err = store.loadError { |
| - Text(err).font(.callout).foregroundStyle(.secondary).padding() |
| - } |
| - ForEach(store.messages) { msg in |
| - MessageRow( |
| - msg: msg, |
| - store: store, |
| - replyCount: store.replyCount(for: msg.id), |
| - flashing: flashID == msg.id, |
| - maxBubbleWidth: maxBubble, |
| - onReply: { startReply($0) }, |
| - onTapQuote: { scrollTo($0, proxy: proxy) } |
| - ) |
| - .id(msg.id) |
| - } |
| - Color.clear.frame(height: 1).id(bottomAnchor) |
| - } |
| - .padding(.horizontal, 10) |
| - .padding(.vertical, 8) |
| - } |
| - .scrollDismissesKeyboard(.interactively) |
| - .onChange(of: store.messages.last?.id) { _, _ in |
| - withAnimation { proxy.scrollTo(bottomAnchor, anchor: .bottom) } |
| - } |
| - // Resizing the window reflows the bubbles; re-pin to the bottom so |
| - // the view doesn't drift around mid-resize. |
| - .onChange(of: geo.size) { _, _ in |
| - proxy.scrollTo(bottomAnchor, anchor: .bottom) |
| - } |
| - .onAppear { |
| - proxy.scrollTo(bottomAnchor, anchor: .bottom) |
| - } |
| - } |
| + .alert( |
| + "Couldn’t attach that", |
| + isPresented: Binding(get: { attachError != nil }, set: { if !$0 { attachError = nil } }) |
| + ) { |
| + Button("OK", role: .cancel) {} |
| + } message: { |
| + Text(attachError ?? "") |
| } |
| - } |
| - |
| - private let bottomAnchor = "bottom" |
| - |
| - private func scrollTo(_ id: Int64, proxy: ScrollViewProxy) { |
| - withAnimation { proxy.scrollTo(id, anchor: .center) } |
| - flashID = id |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { |
| - if flashID == id { flashID = nil } |
| + // The web layer reports upload/stage problems back through the bridge; show |
| + // them rather than letting an attachment silently vanish. |
| + .onChange(of: bridge.lastError) { _, msg in |
| + if let msg { attachError = msg; bridge.lastError = nil } |
| } |
| } |
| - // Subtle "… is typing" line between the feed and the composer. |
| - private var typingIndicator: some View { |
| - Text(typingSummary(store.typingNames)) |
| - .font(.caption) |
| - .italic() |
| - .foregroundStyle(.secondary) |
| - .frame(maxWidth: .infinity, alignment: .leading) |
| - .padding(.horizontal, 16) |
| - .padding(.vertical, 2) |
| + // Toggling a picker's isPresented binding directly inside a Menu action races |
| + // with the menu's own dismissal (the picker can fail to appear on Mac Catalyst). |
| + // Defer the toggle until after the menu has gone. |
| + private func presentAfterMenu(_ action: @escaping () -> Void) { |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: action) |
| } |
| - // MARK: Composer |
| + // MARK: Composer (native input, web send) |
| private var composer: some View { |
| - VStack(spacing: 6) { |
| - if let replyingTo { |
| - replyBanner(replyingTo) |
| - } |
| - if pendingAttachment != nil || uploadingAttachment { |
| - attachmentPreview |
| + HStack(alignment: .bottom, spacing: 8) { |
| + // Attach a photo or file. The bytes are uploaded + encrypted by the web |
| + // layer (one crypto path) and staged in the preview above the composer, |
| + // to be sent with the next message — same path as a drag-drop. |
| + Menu { |
| + Button { presentAfterMenu { showPhotos = true } } label: { Label("Photo Library", systemImage: "photo") } |
| + Button { presentAfterMenu { pickDocument() } } label: { Label("Choose File…", systemImage: "doc") } |
| + } label: { |
| + Image(systemName: "plus") |
| + .font(.system(size: 18, weight: .medium)) |
| + .frame(width: 32, height: 32) |
| + .background(Color(.secondarySystemFill), in: Circle()) |
| } |
| - HStack(alignment: .bottom, spacing: 8) { |
| - Menu { |
| - Button { showPhotos = true } label: { Label("Photo Library", systemImage: "photo") } |
| - Button { showFiles = true } label: { Label("Choose File…", systemImage: "doc") } |
| - } label: { |
| - Image(systemName: "plus") |
| - .font(.system(size: 18, weight: .medium)) |
| - .frame(width: 32, height: 32) |
| - .background(Color(.secondarySystemFill), in: Circle()) |
| - } |
| - .buttonStyle(.plain) |
| - |
| - // A rounded, grey, growing field reads as a native chat composer on |
| - // both iOS and the Mac (the default bordered style looks like a form). |
| - TextField("Message", text: $text, axis: .vertical) |
| - .textFieldStyle(.plain) |
| - .lineLimit(1...6) |
| - .focused($composerFocused) |
| - .padding(.horizontal, 12) |
| - .padding(.vertical, 7) |
| - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 18)) |
| - .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color(.separator).opacity(0.5), lineWidth: 0.5)) |
| - // Desktop convention (matches the web composer): a bare Return |
| - // sends, Shift+Return inserts a newline. We intercept the key on |
| - // the field itself rather than via a Button keyboardShortcut — |
| - // a focused multiline field swallows the Button's shortcut, which |
| - // is what made Return need a second press. Touch keyboards keep |
| - // Return as a newline (the send button sends there), so only the |
| - // Mac opts in. |
| - .onKeyPress(.return, phases: .down) { press in |
| - guard isMac, press.modifiers.isEmpty else { return .ignored } |
| - if canSend { send() } |
| - return .handled |
| - } |
| - |
| - Button(action: send) { |
| - Image(systemName: "arrow.up.circle.fill") |
| - .font(.system(size: 28)) |
| - .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel)) |
| - } |
| - .buttonStyle(.plain) |
| - .disabled(!canSend) |
| + .buttonStyle(.plain) |
| + |
| + // A rounded, grey, growing field reads as a native chat composer. On the |
| + // Mac a bare Return sends and Shift+Return inserts a newline (handled in |
| + // ComposerField at the UITextView level). |
| + ComposerField( |
| + text: $text, |
| + focused: $composerFocused, |
| + placeholder: "Message", |
| + sendOnReturn: isMac, |
| + onSend: { if canSend { send() } } |
| + ) |
| + .padding(.horizontal, 12) |
| + .padding(.vertical, 7) |
| + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 18)) |
| + .overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color(.separator).opacity(0.5), lineWidth: 0.5)) |
| + |
| + Button(action: send) { |
| + Image(systemName: "arrow.up.circle.fill") |
| + .font(.system(size: 28)) |
| + .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel)) |
| } |
| + .buttonStyle(.plain) |
| + .disabled(!canSend) |
| } |
| .padding(.horizontal, 10) |
| .padding(.vertical, 8) |
| .background(.bar) |
| .photosPicker(isPresented: $showPhotos, selection: $pickerItem, matching: .images) |
| .onChange(of: pickerItem) { _, item in stagePhoto(item) } |
| - .fileImporter(isPresented: $showFiles, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in |
| - if case .success(let urls) = result, let url = urls.first { stageFile(url) } |
| - } |
| - } |
| - |
| - private var canSend: Bool { |
| - !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || pendingAttachment != nil |
| } |
| - private func replyBanner(_ msg: ChatMessage) -> some View { |
| - HStack(spacing: 8) { |
| - Image(systemName: "arrowshape.turn.up.left.fill").foregroundStyle(.tint) |
| - VStack(alignment: .leading, spacing: 1) { |
| - Text("Replying to \(msg.payload.name)").font(.caption.weight(.semibold)) |
| - Text(replySnippet(msg.payload)).font(.caption).foregroundStyle(.secondary).lineLimit(1) |
| - } |
| - Spacer() |
| - Button { replyingTo = nil } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } |
| + // Present UIKit's document picker directly. SwiftUI's .fileImporter does not |
| + // reliably fire its completion on Mac Catalyst (the picker opens, you choose a |
| + // file, and nothing comes back), so we drive UIDocumentPickerViewController and |
| + // its delegate ourselves — that path is reliable. |
| + private func pickDocument() { |
| + presentDocumentPicker { url in |
| + if let url { stageFile(url) } |
| } |
| - .padding(8) |
| - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) |
| - } |
| - |
| - private var attachmentPreview: some View { |
| - HStack(spacing: 8) { |
| - if let img = pendingPreview { |
| - Image(uiImage: img).resizable().scaledToFill() |
| - .frame(width: 44, height: 44).clipShape(RoundedRectangle(cornerRadius: 8)) |
| - } else { |
| - Image(systemName: "doc.fill") |
| - .font(.title2).foregroundStyle(.secondary) |
| - .frame(width: 44, height: 44) |
| - } |
| - Text(uploadingAttachment ? "Uploading…" : (pendingName ?? "Ready")) |
| - .font(.caption).foregroundStyle(.secondary).lineLimit(1) |
| - Spacer() |
| - if uploadingAttachment { |
| - ProgressView() |
| - } else { |
| - Button { |
| - pendingAttachment = nil |
| - pendingPreview = nil |
| - pendingName = nil |
| - } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } |
| - } |
| - } |
| - .padding(8) |
| - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) |
| } |
| @ToolbarContentBuilder |
| @@ -286,10 +169,6 @@ struct ChatView: View { |
| } |
| ToolbarItem(placement: .topBarTrailing) { |
| Menu { |
| - Text("Signed in as \(store.session.name)") |
| - Button { renameText = store.session.name; showRename = true } label: { |
| - Label("Change screen name", systemImage: "pencil") |
| - } |
| Button(role: .destructive) { showLeave = true } label: { |
| Label("Leave chat", systemImage: "rectangle.portrait.and.arrow.right") |
| } |
| @@ -301,97 +180,247 @@ struct ChatView: View { |
| // MARK: Actions |
| - // Back to the chat list: stop the socket and record what we've seen so the |
| - // list's unread badge clears. |
| - private func goBack() { |
| - store.disconnect() |
| - app.close(lastSeenId: store.latestId) |
| - } |
| - |
| - // Remove this chat from the device entirely. |
| - private func leaveChat() { |
| - store.disconnect() |
| - app.remove(chat) |
| - app.current = nil |
| - } |
| - |
| - private func startReply(_ msg: ChatMessage) { |
| - replyingTo = msg |
| - } |
| - |
| private func send() { |
| - let replyMeta = replyingTo.map { |
| - ReplyTo(id: $0.id, name: $0.payload.name, snippet: replySnippet($0.payload)) |
| + bridge.sendText(text) { sent in |
| + // Only clear on success: if the socket wasn't ready, keep the text so |
| + // nothing is lost and the user can retry. |
| + if sent { text = "" } |
| + // Keep focus so you can fire off several messages without re-tapping |
| + // the field; the send button resigns first responder, so re-assert. |
| + composerFocused = true |
| } |
| - store.send(text: text, attachment: pendingAttachment, replyTo: replyMeta) |
| - text = "" |
| - pendingAttachment = nil |
| - pendingPreview = nil |
| - pendingName = nil |
| - replyingTo = nil |
| - // Keep the composer focused so you can fire off several messages in a row |
| - // without clicking back into the field after each send. Tapping the send |
| - // button resigns the field's first responder, so re-assert focus on the |
| - // next runloop tick — setting it inline here gets clobbered by the tap. |
| - DispatchQueue.main.async { composerFocused = true } |
| } |
| - // Shared staging: show a preview, encrypt + upload, then arm the attachment. |
| - private func stage(data: Data, fileName: String, fileType: String?, preview: UIImage?) { |
| - Task { |
| - uploadingAttachment = true |
| - defer { uploadingAttachment = false } |
| - pendingPreview = preview |
| - pendingName = fileName |
| - if let att = await store.uploadAttachment(data, fileName: fileName, fileType: fileType) { |
| - pendingAttachment = att |
| - } else { |
| - pendingPreview = nil |
| - pendingName = nil |
| - } |
| - } |
| + // Hand picked bytes to the web layer to encrypt, upload and stage. The web |
| + // composer shows an "Uploading…" preview (above this composer) and then the |
| + // staged chip. Fire-and-forget: success/failure comes back via the bridge. |
| + private func upload(data: Data, name: String, type: String?) { |
| + bridge.stageAttachment(data, name: name, type: type) |
| } |
| private func stagePhoto(_ item: PhotosPickerItem?) { |
| guard let item else { return } |
| Task { |
| pickerItem = nil |
| - guard let data = try? await item.loadTransferable(type: Data.self) else { return } |
| - stage(data: data, fileName: "image.jpg", fileType: nil, preview: UIImage(data: data)) |
| + do { |
| + guard let data = try await item.loadTransferable(type: Data.self) else { |
| + attachError = "Couldn’t read that photo." |
| + return |
| + } |
| + upload(data: data, name: "image.jpg", type: "image/jpeg") |
| + } catch { |
| + attachError = "Couldn’t read that photo: \(error.localizedDescription)" |
| + } |
| } |
| } |
| private func stageFile(_ url: URL) { |
| let scoped = url.startAccessingSecurityScopedResource() |
| defer { if scoped { url.stopAccessingSecurityScopedResource() } } |
| - guard let data = try? Data(contentsOf: url) else { return } |
| - let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType |
| - let preview = (type?.hasPrefix("image/") ?? false) ? UIImage(data: data) : nil |
| - stage(data: data, fileName: url.lastPathComponent, fileType: type, preview: preview) |
| + do { |
| + let data = try Data(contentsOf: url) |
| + let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType |
| + upload(data: data, name: url.lastPathComponent, type: type) |
| + } catch { |
| + attachError = "Couldn’t read that file: \(error.localizedDescription)" |
| + } |
| } |
| - // Drag-and-drop a file or image onto the chat: stage it for the next send, |
| - // exactly like the picker. Finder/Files drops arrive as a file (read its |
| - // bytes before the temp copy is reclaimed); a dragged image may be raw data. |
| - private func handleDrop(_ providers: [NSItemProvider]) -> Bool { |
| - guard let provider = providers.first else { return false } |
| - if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { |
| - provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, _ in |
| - guard let url, let data = try? Data(contentsOf: url) else { return } |
| - let name = url.lastPathComponent |
| - let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType |
| - let preview = (type?.hasPrefix("image/") ?? false) ? UIImage(data: data) : nil |
| - Task { @MainActor in stage(data: data, fileName: name, fileType: type, preview: preview) } |
| - } |
| - return true |
| + private func loadGroupName() async { |
| + guard let s = chat.session else { return } |
| + let name = await LChatAPI(baseURL: s.baseURL, token: s.token).groupName() |
| + groupName = name |
| + } |
| + |
| + private func goBack() { |
| + app.close(lastSeenId: chat.lastSeenId) |
| + } |
| + |
| + private func leaveChat() { |
| + app.remove(chat) |
| + app.current = nil |
| + } |
| +} |
| + |
| +// Holds a reference to the live WKWebView so the native composer can drive the |
| +// web client's send() path, and receives staged-attachment state + errors back. |
| +final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler { |
| + weak var webView: WKWebView? |
| + |
| + // Whether the web composer currently has an attachment staged (from the native |
| + // + button or a drag-drop onto the feed). Drives the native send button so an |
| + // image-only message can be sent. Kept in sync by the "lchat" message handler. |
| + @Published var attachmentStaged = false |
| + |
| + // A user-facing problem reported by the web layer (upload/stage failure) or by |
| + // the bridge call itself. ChatView surfaces it as an alert, then clears it — |
| + // so an attachment never just silently disappears. |
| + @Published var lastError: String? |
| + |
| + // Forwards typed text to chat.js's window.lchatNative.sendText. JSON-encoding |
| + // the string safely escapes quotes/newlines/unicode before it crosses into JS. |
| + // Empty text is allowed (the web side sends it only if an attachment is staged). |
| + // The completion reports whether the web layer accepted it (socket open, hook |
| + // present, something to send), so the caller knows whether to clear its field. |
| + func sendText(_ text: String, completion: @escaping (Bool) -> Void) { |
| + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard let webView, |
| + let data = try? JSONSerialization.data(withJSONObject: [trimmed]), |
| + let arg = String(data: data, encoding: .utf8) else { return completion(false) } |
| + // arg is `["the text"]`; pass element 0 to the hook. The `?:` yields false |
| + // if the hook isn't installed yet, so an early send is reported as not sent. |
| + webView.evaluateJavaScript("window.lchatNative ? window.lchatNative.sendText(\(arg)[0]) : false") { result, _ in |
| + completion((result as? Bool) ?? false) |
| } |
| - if provider.canLoadObject(ofClass: UIImage.self) { |
| - provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in |
| - guard let data else { return } |
| - Task { @MainActor in stage(data: data, fileName: "image.jpg", fileType: nil, preview: UIImage(data: data)) } |
| + } |
| + |
| + // Hands attachment bytes to chat.js's window.lchatNative.stageAttachment, which |
| + // uploads + encrypts + previews it (same path as a drag-drop). Uses |
| + // evaluateJavaScript — the same call the text send uses, which reliably reaches |
| + // window.lchatNative here; callAsyncJavaScript silently did nothing on Catalyst. |
| + // Fire-and-forget: staging runs async in JS and reports back via the "lchat" |
| + // message handler ({staged:true} on success, {error} on failure). We only flag |
| + // the kickoff problems (no hook yet, or a JS error) synchronously here. |
| + func stageAttachment(_ data: Data, name: String, type: String?) { |
| + guard let webView else { |
| + lastError = "Internal error: the chat view isn’t ready." |
| + return |
| + } |
| + let payload: [String: Any] = [ |
| + "b64": data.base64EncodedString(), |
| + "name": name, |
| + "type": type ?? "application/octet-stream", |
| + ] |
| + guard let json = try? JSONSerialization.data(withJSONObject: payload), |
| + let arg = String(data: json, encoding: .utf8) else { |
| + lastError = "Couldn’t prepare that attachment." |
| + return |
| + } |
| + // arg is a JSON object literal (valid JS). Return a sentinel so a missing |
| + // hook is distinguishable from a successful kickoff (the upload itself |
| + // reports completion/failure asynchronously via the message handler). |
| + let js = "(function(){ if (!window.lchatNative) return 'no-bridge'; window.lchatNative.stageAttachment(\(arg)); return 'ok'; })()" |
| + webView.evaluateJavaScript(js) { [weak self] result, err in |
| + if let err = err { |
| + self?.lastError = "Couldn’t attach that: \(err.localizedDescription)" |
| + } else if (result as? String) == "no-bridge" { |
| + self?.lastError = "The chat isn’t ready yet. Try again in a moment." |
| } |
| - return true |
| } |
| - return false |
| + } |
| + |
| + // chat.js posts {staged: Bool} whenever an attachment is staged or cleared |
| + // (picker, drag-drop, send, or the preview's remove button) and {error: String} |
| + // when an upload/stage fails — so the native send button can enable/disable and |
| + // failures surface instead of vanishing. |
| + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| + guard message.name == "lchat", let body = message.body as? [String: Any] else { return } |
| + if let staged = body["staged"] as? Bool { attachmentStaged = staged } |
| + if let err = body["error"] as? String { lastError = err } |
| + } |
| +} |
| + |
| +// WKWebView host for one chat's message feed. We load the origin root (`/`), NOT |
| +// the saved `/join/<token>#k=` URL: join.js re-prompts for a screen name every |
| +// visit, whereas `/` (chat.js) renders the chat directly when a session is present |
| +// in localStorage. So we seed that session — and the embed flag — before any page |
| +// script runs. |
| +// |
| +// The default (persistent) data store means this storage and chat.js's cached |
| +// state survive between opens. Each chat is its own origin (per-instance |
| +// subdomain), so the browser partitions their storage automatically. |
| +struct ChatWebView: UIViewRepresentable { |
| + let session: Session |
| + let bridge: WebBridge |
| + |
| + func makeUIView(context: Context) -> WKWebView { |
| + let cfg = WKWebViewConfiguration() |
| + cfg.websiteDataStore = .default() |
| + cfg.allowsInlineMediaPlayback = true |
| + if let script = bootScript() { |
| + cfg.userContentController.addUserScript(script) |
| + } |
| + // Receive staged-attachment state from chat.js (window.webkit.messageHandlers.lchat). |
| + cfg.userContentController.add(bridge, name: "lchat") |
| + let web = WKWebView(frame: .zero, configuration: cfg) |
| + bridge.webView = web |
| + // In debug builds, let Safari's Web Inspector attach to the feed |
| + // (Develop ▸ device ▸ this view) so the web console is reachable. |
| + #if DEBUG |
| + if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true } |
| + #endif |
| + web.load(URLRequest(url: session.baseURL)) // origin root → chat.js renders the feed |
| + return web |
| + } |
| + |
| + func updateUIView(_ web: WKWebView, context: Context) {} |
| + |
| + // Runs before any page script: marks this as the native embed (so chat.js hides |
| + // its header/composer and exposes the send hook) and seeds the web session |
| + // (token/key/screen name) into localStorage so chat.js renders directly. |
| + private func bootScript() -> WKUserScript? { |
| + let payload = ["token": session.token, "key": session.keyB64, "name": session.name] |
| + guard let data = try? JSONSerialization.data(withJSONObject: payload), |
| + let objJSON = String(data: data, encoding: .utf8) else { return nil } |
| + // objJSON is a JSON object literal (valid JS); JSON.stringify turns it back |
| + // into the string chat.js JSON.parses, sidestepping manual escaping of a |
| + // screen name that might contain quotes. |
| + let js = """ |
| + window.__lchatEmbed = true; |
| + try { localStorage.setItem('lchat_session', JSON.stringify(\(objJSON))); } catch (e) {} |
| + """ |
| + return WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true) |
| + } |
| +} |
| + |
| +// Presents UIKit's document picker directly (SwiftUI's .fileImporter doesn't fire |
| +// its completion on Mac Catalyst). asCopy:true hands back a readable temp copy, so |
| +// the caller can read bytes without the security-scoped-resource dance. |
| +@MainActor |
| +private func presentDocumentPicker(onPick: @escaping (URL?) -> Void) { |
| + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true) |
| + picker.allowsMultipleSelection = false |
| + let delegate = DocumentPickerDelegate(onPick: onPick) |
| + picker.delegate = delegate |
| + guard let presenter = topViewController() else { |
| + onPick(nil) |
| + return |
| + } |
| + presenter.present(picker, animated: true) |
| +} |
| + |
| +// Walks to the front-most view controller of the active foreground scene to present |
| +// from. Catalyst exposes a single UIWindowScene. |
| +@MainActor |
| +private func topViewController() -> UIViewController? { |
| + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } |
| + let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first |
| + guard var top = scene?.keyWindow?.rootViewController |
| + ?? scene?.windows.first(where: { $0.isKeyWindow })?.rootViewController |
| + ?? scene?.windows.first?.rootViewController else { return nil } |
| + while let presented = top.presentedViewController { top = presented } |
| + return top |
| +} |
| + |
| +// UIDocumentPickerDelegate whose callbacks reliably fire on Catalyst. It retains |
| +// itself until a callback lands (the picker only holds the delegate weakly). |
| +private final class DocumentPickerDelegate: NSObject, UIDocumentPickerDelegate { |
| + private let onPick: (URL?) -> Void |
| + private var selfRef: DocumentPickerDelegate? |
| + |
| + init(onPick: @escaping (URL?) -> Void) { |
| + self.onPick = onPick |
| + super.init() |
| + selfRef = self |
| + } |
| + |
| + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { |
| + onPick(urls.first) |
| + selfRef = nil |
| + } |
| + |
| + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { |
| + onPick(nil) |
| + selfRef = nil |
| } |
| } |
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift| new file mode 100644 |
| index 0000000..7e0f360 |
| --- /dev/null |
| +++ b/ios/LChat/Views/ComposerField.swift |
| @@ -0,0 +1,126 @@ |
| +import SwiftUI |
| +import UIKit |
| + |
| +// The composer's text input. We drop to a UITextView (via a representable) |
| +// instead of SwiftUI's TextField because we need to intercept Return *before* it |
| +// inserts a newline: `.onKeyPress` sits above the field and the focused text |
| +// input swallows Return first, so a SwiftUI-level handler can't make Return send. |
| +// UITextView's `shouldChangeTextIn` delegate gates every edit — the one place the |
| +// interception reliably wins. |
| +// |
| +// `sendOnReturn` follows the CLAUDE.md platform split: on the Mac a bare Return |
| +// sends and Shift+Return inserts a newline (matching the web composer on |
| +// desktop); on touch, Return always inserts a newline and the send button sends. |
| +struct ComposerField: UIViewRepresentable { |
| + @Binding var text: String |
| + @Binding var focused: Bool |
| + var placeholder: String = "" |
| + var sendOnReturn: Bool |
| + var onSend: () -> Void |
| + |
| + func makeCoordinator() -> Coordinator { Coordinator(self) } |
| + |
| + func makeUIView(context: Context) -> GrowingTextView { |
| + let tv = GrowingTextView() |
| + tv.delegate = context.coordinator |
| + tv.font = .preferredFont(forTextStyle: .body) |
| + tv.backgroundColor = .clear |
| + tv.textContainerInset = .zero |
| + tv.textContainer.lineFragmentPadding = 0 |
| + tv.isScrollEnabled = false |
| + return tv |
| + } |
| + |
| + func updateUIView(_ tv: GrowingTextView, context: Context) { |
| + context.coordinator.parent = self |
| + if tv.text != text { tv.text = text } |
| + tv.placeholderLabel.text = placeholder |
| + tv.placeholderLabel.isHidden = !text.isEmpty |
| + // Drive first responder from the focus binding — async so we never mutate |
| + // UIKit responder state in the middle of a SwiftUI update. Comparing to the |
| + // live isFirstResponder (not the previous binding value) means a re-assert |
| + // to `true` after a send-button tap still re-grabs focus. |
| + if focused != tv.isFirstResponder { |
| + DispatchQueue.main.async { |
| + if focused { tv.becomeFirstResponder() } else { tv.resignFirstResponder() } |
| + } |
| + } |
| + } |
| + |
| + // Grow with the text up to six lines, then let it scroll. |
| + func sizeThatFits(_ proposal: ProposedViewSize, uiView: GrowingTextView, context: Context) -> CGSize? { |
| + let width = proposal.width ?? uiView.bounds.width |
| + guard width > 0, width.isFinite else { return nil } |
| + let fit = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height |
| + let scroll = fit > uiView.capHeight |
| + if uiView.isScrollEnabled != scroll { |
| + DispatchQueue.main.async { uiView.isScrollEnabled = scroll } |
| + } |
| + return CGSize(width: width, height: ceil(min(fit, uiView.capHeight))) |
| + } |
| + |
| + final class Coordinator: NSObject, UITextViewDelegate { |
| + var parent: ComposerField |
| + init(_ parent: ComposerField) { self.parent = parent } |
| + |
| + func textViewDidChange(_ tv: UITextView) { |
| + parent.text = tv.text |
| + (tv as? GrowingTextView)?.placeholderLabel.isHidden = !tv.text.isEmpty |
| + } |
| + |
| + func textView(_ tv: UITextView, shouldChangeTextIn range: NSRange, |
| + replacementText replacement: String) -> Bool { |
| + guard parent.sendOnReturn, replacement == "\n" else { return true } |
| + // Shift+Return falls through as a newline; a bare Return sends. Either |
| + // way we swallow the keystroke so no stray newline lands in the field. |
| + if (tv as? GrowingTextView)?.shiftHeld == true { return true } |
| + parent.onSend() |
| + return false |
| + } |
| + |
| + func textViewDidBeginEditing(_ tv: UITextView) { |
| + if !parent.focused { parent.focused = true } |
| + } |
| + func textViewDidEndEditing(_ tv: UITextView) { |
| + if parent.focused { parent.focused = false } |
| + } |
| + } |
| +} |
| + |
| +// A UITextView that reports a placeholder, caps its growth at six lines, and |
| +// tracks whether Shift is held so Shift+Return can mean "newline" while a bare |
| +// Return sends. |
| +final class GrowingTextView: UITextView { |
| + let placeholderLabel = UILabel() |
| + private(set) var shiftHeld = false |
| + |
| + private var lineHeight: CGFloat { (font ?? .preferredFont(forTextStyle: .body)).lineHeight } |
| + var capHeight: CGFloat { ceil(lineHeight * 6) } |
| + |
| + override init(frame: CGRect, textContainer: NSTextContainer?) { |
| + super.init(frame: frame, textContainer: textContainer) |
| + placeholderLabel.font = .preferredFont(forTextStyle: .body) |
| + placeholderLabel.textColor = .placeholderText |
| + placeholderLabel.numberOfLines = 1 |
| + addSubview(placeholderLabel) |
| + } |
| + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } |
| + |
| + override func layoutSubviews() { |
| + super.layoutSubviews() |
| + placeholderLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight) |
| + } |
| + |
| + override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) { |
| + if let mods = presses.first?.key?.modifierFlags { shiftHeld = mods.contains(.shift) } |
| + super.pressesBegan(presses, with: event) |
| + } |
| + override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) { |
| + shiftHeld = false |
| + super.pressesEnded(presses, with: event) |
| + } |
| + override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) { |
| + shiftHeld = false |
| + super.pressesCancelled(presses, with: event) |
| + } |
| +} |
diff --git a/ios/LChat/Views/MessageRow.swift b/ios/LChat/Views/MessageRow.swift| deleted file mode 100644 |
| index 63c9582..0000000 |
| --- a/ios/LChat/Views/MessageRow.swift |
| +++ /dev/null |
| @@ -1,424 +0,0 @@ |
| -import SwiftUI |
| - |
| -struct MessageRow: View { |
| - let msg: ChatMessage |
| - @ObservedObject var store: ChatStore |
| - let replyCount: Int |
| - let flashing: Bool |
| - let maxBubbleWidth: CGFloat |
| - let onReply: (ChatMessage) -> Void |
| - let onTapQuote: (Int64) -> Void |
| - |
| - @State private var hovering = false |
| - |
| - var body: some View { |
| - swipeToReply( |
| - HStack(spacing: 6) { |
| - if msg.mine { Spacer(minLength: 28) } |
| - column.contextMenu { reactionMenu } |
| - if !msg.mine { Spacer(minLength: 28) } |
| - } |
| - ) |
| - .padding(flashing ? 6 : 0) |
| - .background(flashing ? Color.yellow.opacity(0.3) : .clear, in: RoundedRectangle(cornerRadius: 12)) |
| - .animation(.easeOut(duration: 0.3), value: flashing) |
| - .onHover { hovering = $0 } |
| - } |
| - |
| - // MARK: Swipe to reply (touch) |
| - |
| - // Mobile (touch) gets the WhatsApp/Messages affordance: drag a message to the |
| - // right to reply. Pointer devices use the hover button above and Mac has no |
| - // touch, so this is gated out of the Mac Catalyst build entirely. Mirrors the |
| - // web (attachSwipeReply in chat.js): a 56pt trigger, 80pt cap, and a |
| - // horizontal-dominant lock so it never steals the feed's vertical scroll. |
| -#if targetEnvironment(macCatalyst) |
| - private func swipeToReply<Content: View>(_ content: Content) -> some View { content } |
| -#else |
| - @State private var dragOffset: CGFloat = 0 |
| - @State private var swipeEngaged: Bool? |
| - |
| - private static let swipeTrigger: CGFloat = 56 |
| - private static let swipeMax: CGFloat = 80 |
| - |
| - private func swipeToReply<Content: View>(_ content: Content) -> some View { |
| - // Offset first, then lay the reply arrow in the *background*: the arrow |
| - // stays pinned to the leading edge while the message slides off it. |
| - content |
| - .offset(x: dragOffset) |
| - .background(alignment: .leading) { |
| - Image(systemName: "arrowshape.turn.up.left.fill") |
| - .foregroundStyle(.tint) |
| - .padding(.leading, 12) |
| - .opacity(Double(min(1, dragOffset / Self.swipeTrigger))) |
| - } |
| - .simultaneousGesture( |
| - // Simultaneous (not exclusive) so the ScrollView still owns |
| - // vertical drags; we only act on ones that read as a rightward swipe. |
| - DragGesture(minimumDistance: 10) |
| - .onChanged { value in |
| - let dx = value.translation.width |
| - let dy = value.translation.height |
| - if swipeEngaged == nil { |
| - swipeEngaged = dx > 0 && abs(dx) > abs(dy) * 1.3 |
| - } |
| - guard swipeEngaged == true else { return } |
| - dragOffset = min(max(0, dx), Self.swipeMax) |
| - } |
| - .onEnded { _ in |
| - let fire = dragOffset >= Self.swipeTrigger |
| - withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { |
| - dragOffset = 0 |
| - } |
| - swipeEngaged = nil |
| - if fire { |
| - UIImpactFeedbackGenerator(style: .medium).impactOccurred() |
| - onReply(msg) |
| - } |
| - } |
| - ) |
| - } |
| -#endif |
| - |
| - private var column: some View { |
| - VStack(alignment: msg.mine ? .trailing : .leading, spacing: 3) { |
| - if !msg.mine { |
| - Text(msg.payload.name) |
| - .font(.caption).foregroundStyle(.secondary) |
| - .padding(.leading, 10) |
| - } |
| - if let rt = msg.payload.replyTo { |
| - quote(rt) |
| - } |
| - if let text = msg.payload.text, !text.isEmpty { |
| - if let size = jumboEmojiSize(text) { |
| - withReplyButton( |
| - Text(text) |
| - .font(.system(size: size)) |
| - .padding(.horizontal, 4).padding(.vertical, 2) |
| - ) |
| - } else { |
| - bubble(text) |
| - } |
| - } |
| - if let atts = msg.payload.attachments { |
| - let stack = VStack(alignment: msg.mine ? .trailing : .leading, spacing: 3) { |
| - ForEach(atts, id: \.id) { att in attachment(att) } |
| - } |
| - // Only the text bubble carries the button when both are present, |
| - // so attachment-only messages keep their hover reply affordance. |
| - if hasText { stack } else { withReplyButton(stack) } |
| - } |
| - if !msg.reactions.isEmpty { |
| - reactionChips |
| - } |
| - if replyCount > 0 { |
| - Text(replyCount == 1 ? "1 Reply" : "\(replyCount) Replies") |
| - .font(.caption2.weight(.medium)).foregroundStyle(.tint) |
| - } |
| - Text(timeString) |
| - .font(.caption2).foregroundStyle(.secondary) |
| - } |
| - } |
| - |
| - // Hover-revealed reply affordance (pointer devices only — iPhone touch never |
| - // fires .onHover, so it falls back to the long-press menu). Mirrors the web. |
| - private var replyButton: some View { |
| - Button { onReply(msg) } label: { |
| - Image(systemName: "arrowshape.turn.up.left.fill") |
| - .font(.footnote) |
| - .foregroundStyle(.secondary) |
| - .padding(6) |
| - } |
| - .buttonStyle(.plain) |
| - .opacity(hovering ? 1 : 0) |
| - .disabled(!hovering) |
| - .animation(.easeInOut(duration: 0.12), value: hovering) |
| - } |
| - |
| - // Pin the reply button just outside `content`, on the side away from the |
| - // bubble (left of my own right-aligned bubbles, right of incoming ones). The |
| - // overridden alignment guide butts the button's near edge against the pill's |
| - // far edge plus a small gap, so it tracks the *visible* content width rather |
| - // than the greedy max-width frame the bubble lives in. |
| - private func withReplyButton<Content: View>(_ content: Content) -> some View { |
| - let onLeading = msg.mine |
| - return content.overlay(alignment: onLeading ? .leading : .trailing) { |
| - replyButton |
| - .alignmentGuide(onLeading ? .leading : .trailing) { d in |
| - onLeading ? d[.trailing] + 4 : d[.leading] - 4 |
| - } |
| - } |
| - } |
| - |
| - private var hasText: Bool { |
| - if let t = msg.payload.text { return !t.isEmpty } |
| - return false |
| - } |
| - |
| - // MARK: Bubble |
| - |
| - // A message that's nothing but a handful of emoji reads better "jumbo" and |
| - // bubble-less, the way Messages/WhatsApp render them. Returns the point size |
| - // to use, or nil when the text isn't emoji-only (so it gets a normal bubble). |
| - private func jumboEmojiSize(_ text: String) -> CGFloat? { |
| - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| - let chars = trimmed.filter { !$0.isWhitespace } |
| - guard !chars.isEmpty, chars.count <= 6, chars.allSatisfy(\.isEmoji) else { return nil } |
| - switch chars.count { |
| - case 1: return 56 |
| - case 2, 3: return 44 |
| - default: return 34 |
| - } |
| - } |
| - |
| - private func bubble(_ text: String) -> some View { |
| - withReplyButton( |
| - Text(linkified(text, mine: msg.mine)) |
| - .tint(msg.mine ? .white : .accentColor) |
| - .padding(.horizontal, 12).padding(.vertical, 8) |
| - .background(msg.mine ? Color.accentColor : Color(.secondarySystemBackground)) |
| - .foregroundStyle(msg.mine ? Color.white : Color.primary) |
| - .clipShape(RoundedRectangle(cornerRadius: 18)) |
| - ) |
| - // Cap wrap width *after* the overlay so the button hugs the pill, not |
| - // this greedy max-width frame. |
| - .frame(maxWidth: maxBubbleWidth, alignment: msg.mine ? .trailing : .leading) |
| - } |
| - |
| - // MARK: Reply quote |
| - |
| - private func quote(_ rt: ReplyTo) -> some View { |
| - Button { onTapQuote(rt.id) } label: { |
| - HStack(spacing: 6) { |
| - Rectangle().fill(.tint).frame(width: 3) |
| - VStack(alignment: .leading, spacing: 1) { |
| - Text(rt.name ?? "").font(.caption2.weight(.semibold)).foregroundStyle(.tint) |
| - Text(rt.snippet ?? "").font(.caption2).foregroundStyle(.secondary).lineLimit(2) |
| - } |
| - } |
| - .padding(6) |
| - .frame(maxWidth: 260, alignment: .leading) |
| - .background(Color(.tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 8)) |
| - } |
| - .buttonStyle(.plain) |
| - } |
| - |
| - // MARK: Attachment |
| - |
| - @ViewBuilder |
| - private func attachment(_ att: Attachment) -> some View { |
| - if att.isImage { |
| - AttachmentImageView(store: store, att: att, maxWidth: min(maxBubbleWidth, 320)) |
| - } else { |
| - FileChip(store: store, att: att) |
| - } |
| - } |
| - |
| - // MARK: Reactions |
| - |
| - private var reactionChips: some View { |
| - HStack(spacing: 4) { |
| - ForEach(msg.reactions.filter { $0.count > 0 }, id: \.emoji) { r in |
| - Button { store.toggleReaction(msg.id, r.emoji) } label: { |
| - Text("\(r.emoji) \(r.count)") |
| - .font(.caption) |
| - .padding(.horizontal, 7).padding(.vertical, 3) |
| - .background( |
| - (r.mine == true ? Color.accentColor.opacity(0.2) : Color(.secondarySystemBackground)), |
| - in: Capsule() |
| - ) |
| - .overlay(Capsule().stroke(r.mine == true ? Color.accentColor : .clear, lineWidth: 1)) |
| - } |
| - .buttonStyle(.plain) |
| - } |
| - } |
| - } |
| - |
| - @ViewBuilder |
| - private var reactionMenu: some View { |
| - ForEach(kReactions, id: \.self) { emoji in |
| - Button { |
| - store.toggleReaction(msg.id, emoji) |
| - } label: { |
| - Text(store.isMine(msg.id, emoji) ? "\(emoji) ✓" : emoji) |
| - } |
| - } |
| - Divider() |
| - Button { onReply(msg) } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } |
| - } |
| - |
| - private var timeString: String { |
| - Self.timeFormatter.string(from: msg.date) |
| - } |
| - |
| - private static let timeFormatter: DateFormatter = { |
| - let f = DateFormatter() |
| - f.dateFormat = "h:mm a" |
| - return f |
| - }() |
| -} |
| - |
| -// MARK: - Inline image with a tap-to-zoom lightbox |
| - |
| -struct AttachmentImageView: View { |
| - @ObservedObject var store: ChatStore |
| - let att: Attachment |
| - var maxWidth: CGFloat = 240 |
| - @State private var image: UIImage? |
| - @State private var showFull = false |
| - |
| - private var aspect: CGFloat? { |
| - if let w = att.w, let h = att.h, w > 0, h > 0 { return CGFloat(w) / CGFloat(h) } |
| - return nil |
| - } |
| - |
| - var body: some View { |
| - Group { |
| - if let image { |
| - Image(uiImage: image) |
| - .resizable().scaledToFit() |
| - .frame(maxWidth: maxWidth, maxHeight: 360) |
| - .clipShape(RoundedRectangle(cornerRadius: 14)) |
| - .onTapGesture { showFull = true } |
| - } else { |
| - RoundedRectangle(cornerRadius: 14) |
| - .fill(Color(.secondarySystemBackground)) |
| - .frame(width: min(maxWidth, 220), height: aspect.map { min(maxWidth, 220) / $0 } ?? 150) |
| - .overlay(ProgressView()) |
| - } |
| - } |
| - .task { image = await store.loadImage(att) } |
| - .fullScreenCover(isPresented: $showFull) { |
| - if let image { Lightbox(image: image) { showFull = false } } |
| - } |
| - } |
| -} |
| - |
| -struct Lightbox: View { |
| - let image: UIImage |
| - let onClose: () -> Void |
| - var body: some View { |
| - ZStack(alignment: .topTrailing) { |
| - Color.black.ignoresSafeArea() |
| - Image(uiImage: image).resizable().scaledToFit().ignoresSafeArea() |
| - Button(action: onClose) { |
| - Image(systemName: "xmark.circle.fill") |
| - .font(.title).foregroundStyle(.white) |
| - .padding() |
| - } |
| - } |
| - .onTapGesture(perform: onClose) |
| - } |
| -} |
| - |
| -// MARK: - Non-image attachment: a chip that shares the decrypted file |
| - |
| -struct FileChip: View { |
| - @ObservedObject var store: ChatStore |
| - let att: Attachment |
| - @State private var shareURL: ShareURL? |
| - @State private var loading = false |
| - |
| - var body: some View { |
| - Button { |
| - Task { await prepareShare() } |
| - } label: { |
| - HStack(spacing: 6) { |
| - Image(systemName: "paperclip") |
| - Text(label).lineLimit(1) |
| - if loading { ProgressView().scaleEffect(0.7) } |
| - } |
| - .font(.callout) |
| - .padding(.horizontal, 12).padding(.vertical, 8) |
| - .background(Color(.secondarySystemBackground), in: Capsule()) |
| - } |
| - .buttonStyle(.plain) |
| - .sheet(item: $shareURL) { item in ShareSheet(items: [item.url]) } |
| - } |
| - |
| - private var label: String { |
| - var s = att.name ?? "file" |
| - if let size = att.size { s += " · " + formatBytes(size) } |
| - return s |
| - } |
| - |
| - private func prepareShare() async { |
| - guard !loading else { return } |
| - loading = true |
| - defer { loading = false } |
| - guard let data = await store.decryptedBytes(att) else { return } |
| - let url = FileManager.default.temporaryDirectory.appendingPathComponent(att.name ?? "file") |
| - do { |
| - try data.write(to: url, options: .atomic) |
| - shareURL = ShareURL(url: url) |
| - } catch {} |
| - } |
| -} |
| - |
| -struct ShareURL: Identifiable { let id = UUID(); let url: URL } |
| - |
| -struct ShareSheet: UIViewControllerRepresentable { |
| - let items: [Any] |
| - func makeUIViewController(context: Context) -> UIActivityViewController { |
| - UIActivityViewController(activityItems: items, applicationActivities: nil) |
| - } |
| - func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} |
| -} |
| - |
| -// MARK: - Helpers |
| - |
| -extension Character { |
| - // True for a grapheme that is an emoji — including multi-scalar flags and |
| - // ZWJ sequences — but not for plain ASCII digits/`#`/`*`, which carry the |
| - // Emoji property only as keycap bases (value < 0x203C, single scalar). |
| - var isEmoji: Bool { |
| - if unicodeScalars.contains(where: { $0.properties.isEmojiPresentation }) { return true } |
| - guard let first = unicodeScalars.first else { return false } |
| - return first.properties.isEmoji && (first.value >= 0x203C || unicodeScalars.count > 1) |
| - } |
| -} |
| - |
| -// Human-readable file size, mirroring formatBytes in format.js. |
| -func formatBytes(_ n: Int) -> String { |
| - if n < 1024 { return "\(n) B" } |
| - let units = ["KB", "MB", "GB"] |
| - var v = Double(n) / 1024, i = 0 |
| - while v >= 1024, i < units.count - 1 { v /= 1024; i += 1 } |
| - return (v < 10 ? String(format: "%.1f", v) : String(Int(v.rounded()))) + " " + units[i] |
| -} |
| - |
| -// Turn http(s)/www URLs into tappable links — same matching rules as |
| -// tokenizeText in format.js, building an AttributedString so message text can't |
| -// inject anything. |
| -func linkified(_ text: String, mine: Bool) -> AttributedString { |
| - let pattern = "(https?://[^\\s<]+|www\\.[^\\s<]+)" |
| - guard let re = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { |
| - return AttributedString(text) |
| - } |
| - let trailing = Set(".,!?;:'\")]}") |
| - let ns = text as NSString |
| - var result = AttributedString("") |
| - var last = 0 |
| - for m in re.matches(in: text, range: NSRange(location: 0, length: ns.length)) { |
| - let r = m.range |
| - if r.location > last { |
| - result += AttributedString(ns.substring(with: NSRange(location: last, length: r.location - last))) |
| - } |
| - var url = ns.substring(with: r) |
| - var trail = "" |
| - while let c = url.last, trailing.contains(c) { trail = String(c) + trail; url.removeLast() } |
| - var seg = AttributedString(url) |
| - let href = url.lowercased().hasPrefix("http") ? url : "https://" + url |
| - if let u = URL(string: href) { |
| - seg.link = u |
| - seg.underlineStyle = .single |
| - if mine { seg.foregroundColor = .white } |
| - } |
| - result += seg |
| - if !trail.isEmpty { result += AttributedString(trail) } |
| - last = r.location + r.length |
| - } |
| - if last < ns.length { result += AttributedString(ns.substring(from: last)) } |
| - return result |
| -} |
diff --git a/web/static/chat.js b/web/static/chat.js| index bffe000..3715d07 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -626,6 +626,17 @@ async function start(session) { |
| app.innerHTML = ""; |
| app.appendChild(tpl("chat-tpl")); |
| + // The native shell (iOS/macOS) embeds only the message feed: it draws the title |
| + // bar and composer itself, so hide the web header + composer and go full-width |
| + // (rules live in style.css), and expose send hooks its native composer drives. |
| + // The app sets window.__lchatEmbed before load; `?embed=1` is an equivalent |
| + // trigger for testing the embedded layout in a plain browser. Normal browsers |
| + // hit neither, so this is a no-op on the web. |
| + const embedded = |
| + window.__lchatEmbed === true || |
| + new URLSearchParams(location.search).get("embed") === "1"; |
| + if (embedded) document.body.classList.add("native-embed"); |
| + |
| const key = await importKey(session.key); |
| const feed = document.getElementById("feed"); |
| const isCoarse = |
| @@ -1709,11 +1720,27 @@ async function start(session) { |
| input.style.height = Math.min(input.scrollHeight + 2, 140) + "px"; |
| }; |
| + // In native embed mode, tell the shell whether an attachment is staged so its |
| + // (native) send button can enable for an image-only message. No-op in a plain |
| + // browser, where window.webkit is undefined. |
| + function notifyNativeStaged(staged) { |
| + if (!embedded) return; |
| + try { window.webkit?.messageHandlers?.lchat?.postMessage({ staged }); } catch (e) {} |
| + } |
| + |
| + // Surface an attachment problem to the native shell so it can alert the user |
| + // (rather than the upload silently failing). No-op in a plain browser. |
| + function notifyNativeError(message) { |
| + if (!embedded) return; |
| + try { window.webkit?.messageHandlers?.lchat?.postMessage({ error: String(message || "") }); } catch (e) {} |
| + } |
| + |
| function clearPreview() { |
| pendingAttachment = null; |
| previewBox.classList.add("hidden"); |
| previewBox.innerHTML = ""; |
| syncSendBtn(); |
| + notifyNativeStaged(false); |
| feed.scrollTop = feed.scrollHeight; |
| } |
| @@ -1829,11 +1856,13 @@ async function start(session) { |
| if (!meta) { |
| clearPreview(); |
| showBanner("Couldn't upload that file. Try again.", true); |
| + notifyNativeError("Couldn't upload that file. Try again."); |
| return; |
| } |
| pendingAttachment = meta; |
| showPreview(file, false, meta); |
| syncSendBtn(); |
| + notifyNativeStaged(true); |
| input.focus(); |
| } |
| @@ -1963,6 +1992,47 @@ async function start(session) { |
| const blob = await encrypt(key, payload); |
| ws.send(JSON.stringify({ type: "msg", blob })); |
| }; |
| + // The native composer drives the same send() path, so all crypto + WebSocket |
| + // logic stays here (one implementation). It routes through the hidden textarea |
| + // so a staged reply (replyingTo, set by tapping a bubble) still rides along. |
| + if (embedded) { |
| + window.lchatNative = { |
| + // Returns whether the message was accepted for sending, so the native |
| + // composer only clears its field on success — if the socket isn't open yet |
| + // (e.g. sending the instant the chat opens), the text is kept for retry. |
| + // An empty message with a staged attachment is valid (image-only send). |
| + sendText(t) { |
| + if (!ws || ws.readyState !== WebSocket.OPEN) return false; |
| + const text = typeof t === "string" ? t : ""; |
| + if (!text.trim() && !pendingAttachment) return false; |
| + input.value = text; |
| + send(); |
| + return true; |
| + }, |
| + // The native + button hands {b64, name, type} here (a single object so the |
| + // native side can pass it via evaluateJavaScript); we reuse the web |
| + // upload+encrypt+preview path (stageFile) so the attachment shows above the |
| + // native composer and rides along with the next send — exactly like a |
| + // drag-drop. Fire-and-forget: stageFile posts {staged:true} on success and |
| + // {error} on failure via the native message handler. Staging uses HTTP |
| + // upload, so it doesn't need the WebSocket open. |
| + async stageAttachment(arg) { |
| + try { |
| + const b64 = (arg && arg.b64) || ""; |
| + const name = (arg && arg.name) || "file"; |
| + const type = (arg && arg.type) || "application/octet-stream"; |
| + const bin = atob(b64); |
| + const bytes = new Uint8Array(bin.length); |
| + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); |
| + const file = new File([bytes], name, { type }); |
| + await stageFile(file); |
| + } catch (e) { |
| + console.log("[lchat] stageAttachment failed", e); |
| + notifyNativeError("Couldn't attach that file."); |
| + } |
| + }, |
| + }; |
| + } |
| input.addEventListener("input", () => { |
| autosize(); |
| syncSendBtn(); |
diff --git a/web/static/style.css b/web/static/style.css| index 6dec23b..7e7f27f 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -83,6 +83,28 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| border-right: 1px solid var(--border); |
| position: relative; /* anchors the drag-and-drop overlay */ |
| } |
| +/* Native shell (iOS/macOS) embeds only the message feed — the app draws its own |
| + title bar and composer. Drop the centered max-width column + side borders so the |
| + feed is full width, and hide the web header/composer. Gated on body.native-embed, |
| + which only chat.js sets when window.__lchatEmbed is injected; browsers are |
| + unaffected. */ |
| +.native-embed .chat { |
| + max-width: none; |
| + border-left: none; |
| + border-right: none; |
| +} |
| +.native-embed .chat header, |
| +.native-embed .composer, |
| +.native-embed .composer-reply, |
| +.native-embed .composer-edit { |
| + display: none; |
| +} |
| +/* Keep the attachment preview visible: it's how a staged photo/file shows up just |
| + above the native composer (drag-drop or the native + button stage into it). The |
| + composer below is hidden, so add the bottom padding the composer would have |
| + provided. */ |
| +.native-embed .composer-preview { padding-bottom: 10px; } |
| + |
| .chat header { |
| padding: 14px 18px; |
| border-bottom: 1px solid var(--border); |