eleven / ocho Public

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>
James Adam a4d95ee6ab58b75f82df129bc78c80389eafa4f8 parent 97e5b27
8 files changed, +546 −1258
  • ios/LChat/ChatSocket.swift +0 −153
  • ios/LChat/ChatStore.swift +0 −347
  • ios/LChat/ImageProcessing.swift +0 −35
  • ios/LChat/Views/ChatView.swift +328 −299
  • ios/LChat/Views/ComposerField.swift +126 −0
  • ios/LChat/Views/MessageRow.swift +0 −424
  • web/static/chat.js +70 −0
  • web/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);