Native iOS & macOS app (Mac Catalyst) (#4)
* ios: native SwiftUI client for iOS and macOS (Mac Catalyst) A native client that interoperates with the existing server and web app — no server changes. Same end-to-end encryption contract: the AES-GCM group key lives only in the invite link's #fragment, stays on-device (Keychain), and the server sees only opaque ciphertext and blob ids. The wire format is byte-identical to crypto.js in both directions (CryptoKit SealedBox.combined == iv||ciphertext||tag), so iOS, Mac, and browser users share one chat. Features: join by pasting an invite link, read history + live messages over /ws (auto-reconnect + gap-fill), send text and photos (downscaled/re-encoded on-device, stripping EXIF), tap-to-react, and quoted replies. Builds for iPhone/iPad and as a real Mac app via Mac Catalyst (network.client entitlement required for the sandboxed Mac build). The Xcode project is generated from project.yml by xcodegen (gitignored). Co-Authored-By: Claude <noreply@anthropic.com> * ios: hover reply, wider bubbles, Enter-to-send, badge, file picker, app icon Follow-up polish on the iOS/macOS client: - Reply on hover: a reply icon reveals beside the bubble on pointer devices (Mac/iPad), mirroring the web's hover affordance; iPhone touch keeps the long-press menu (.onHover never fires there). - Wider bubbles: cap scales to ~78% of the measured feed width (Messages-style) instead of a fixed narrow column. - Enter-to-send on a hardware keyboard (Mac/iPad); Shift+Return inserts a newline. - App-icon unread badge: messages that land while the app isn't frontmost bump the badge (works best on Mac, which keeps the socket alive in the background; iOS needs push for the suspended case, which is out of scope). - Attachments: pick any file (.fileImporter) as well as from the photo library; the uploader already handles non-images (raw bytes, rendered as a file chip). - App icon + accent: the web speech-bubble favicon, upscaled to 1024 and flattened (no alpha), plus a matching #5b6cff AccentColor. Mac build needs the network.client + user-selected file-read entitlements (sandboxed Catalyst). Verified: Mac Catalyst build succeeds; app icon and accent compile into the asset catalog. Co-Authored-By: Claude <noreply@anthropic.com> * ios: wider bubbles, drag-and-drop attachments, Mac titlebar - Bubbles now scale to ~78% of the feed width (Messages-style) via a direct GeometryReader. The previous PreferenceKey-based width never updated, so the cap sat at its ~304pt default — visually unchanged from the old fixed 300. - Drag-and-drop: dropping a file or image anywhere on the chat stages it for the next send (Finder file URLs and raw image data both handled), with an accent border while dragging over the window. - Mac (Catalyst): the chat name moves into the native window titlebar and the settings menu becomes a titlebar toolbar item, so the in-app navigation bar is hidden on the Mac. iPhone/iPad keep their normal navigation bar. Co-Authored-By: Claude <noreply@anthropic.com> * ios: multiple chats, swipe-to-reply, edge-back, native composer Multiple chats: - A chat list is now the home screen, showing each chat's group name (fetched live) and an unread count, with a button to add another. Each chat is stored as just its invite URL + screen name (+ a lastSeenId for the unread count) in the Keychain, since the URL carries the group key. Old single-session installs migrate into the list automatically. - ChatView takes a SavedChat; a back chevron returns to the list (recording what was seen so the unread badge clears). "Leave chat" removes it from the device. Interaction: - Swipe a bubble left to reply (touch), with a revealed reply arrow + haptic; runs simultaneously with the scroll view so vertical scrolling is unaffected. - A left-screen-edge swipe goes back to the list (UIScreenEdgePanGestureRecognizer on the window). - Composer restyled to a rounded, growing field with circular attach/send buttons so it reads natively on iOS and the Mac. - Resizing the window now re-pins the feed to the bottom instead of drifting. Mac: the chat name shows in the native window titlebar; the back chevron and settings menu render in the app's nav bar. Catalyst pins the system title leading and groups NSToolbar items trailing (and custom-view toolbar items aren't available), so a chevron can't sit to the title's left in the titlebar itself. Co-Authored-By: Claude <noreply@anthropic.com> * ios: tag messages with a type, ignore kinds we don't render Carry a `type` inside the encrypted payload (mirrors web/static/message.js): every message this client sends is stamped "chat", and on receipt anything whose type this client doesn't render is skipped (a nil/absent type is a legacy chat and still shown). The type is an end-to-end signal between clients — the server only ever sees ciphertext, never this tag. Co-Authored-By: Claude <noreply@anthropic.com> ---------
Co-authored-by: Claude <noreply@anthropic.com>
23 files changed,
+2166
−0
ios/.gitignore+6 −0ios/LChat/Assets.xcassets/AccentColor.colorset/Contents.json+20 −0ios/LChat/Assets.xcassets/AppIcon.appiconset/Contents.json+14 −0ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.pngbinaryios/LChat/Assets.xcassets/Contents.json+6 −0ios/LChat/ChatSocket.swift+143 −0ios/LChat/ChatStore.swift+258 −0ios/LChat/Crypto.swift+94 −0ios/LChat/ImageProcessing.swift+35 −0ios/LChat/Info.plist+42 −0ios/LChat/Keychain.swift+61 −0ios/LChat/LChat.entitlements+16 −0ios/LChat/LChatAPI.swift+109 −0ios/LChat/LChatApp.swift+88 −0ios/LChat/Models.swift+142 −0ios/LChat/Views/ChatListView.swift+93 −0ios/LChat/Views/ChatView.swift+358 −0ios/LChat/Views/EdgeSwipeBack.swift+55 −0ios/LChat/Views/JoinView.swift+105 −0ios/LChat/Views/MacTitlebar.swift+29 −0ios/LChat/Views/MessageRow.swift+367 −0ios/README.md+80 −0ios/project.yml+45 −0
diff --git a/ios/.gitignore b/ios/.gitignore| new file mode 100644 |
| index 0000000..bf19373 |
| --- /dev/null |
| +++ b/ios/.gitignore |
| @@ -0,0 +1,6 @@ |
| +# The Xcode project is generated from project.yml by xcodegen. |
| +LChat.xcodeproj/ |
| +build/ |
| +DerivedData/ |
| +*.xcuserstate |
| +.DS_Store |
diff --git a/ios/LChat/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/LChat/Assets.xcassets/AccentColor.colorset/Contents.json| new file mode 100644 |
| index 0000000..70cc681 |
| --- /dev/null |
| +++ b/ios/LChat/Assets.xcassets/AccentColor.colorset/Contents.json |
| @@ -0,0 +1,20 @@ |
| +{ |
| + "colors" : [ |
| + { |
| + "color" : { |
| + "color-space" : "srgb", |
| + "components" : { |
| + "alpha" : "1.000", |
| + "blue" : "1.000", |
| + "green" : "0.424", |
| + "red" : "0.357" |
| + } |
| + }, |
| + "idiom" : "universal" |
| + } |
| + ], |
| + "info" : { |
| + "author" : "xcode", |
| + "version" : 1 |
| + } |
| +} |
diff --git a/ios/LChat/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/LChat/Assets.xcassets/AppIcon.appiconset/Contents.json| new file mode 100644 |
| index 0000000..27a4f38 |
| --- /dev/null |
| +++ b/ios/LChat/Assets.xcassets/AppIcon.appiconset/Contents.json |
| @@ -0,0 +1,14 @@ |
| +{ |
| + "images" : [ |
| + { |
| + "filename" : "icon-1024.png", |
| + "idiom" : "universal", |
| + "platform" : "ios", |
| + "size" : "1024x1024" |
| + } |
| + ], |
| + "info" : { |
| + "author" : "xcode", |
| + "version" : 1 |
| + } |
| +} |
diff --git a/ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png| new file mode 100644 |
| index 0000000..105803e |
| Binary files /dev/null and b/ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ |
diff --git a/ios/LChat/Assets.xcassets/Contents.json b/ios/LChat/Assets.xcassets/Contents.json| new file mode 100644 |
| index 0000000..73c0059 |
| --- /dev/null |
| +++ b/ios/LChat/Assets.xcassets/Contents.json |
| @@ -0,0 +1,6 @@ |
| +{ |
| + "info" : { |
| + "author" : "xcode", |
| + "version" : 1 |
| + } |
| +} |
diff --git a/ios/LChat/ChatSocket.swift b/ios/LChat/ChatSocket.swift| new file mode 100644 |
| index 0000000..5ac331c |
| --- /dev/null |
| +++ b/ios/LChat/ChatSocket.swift |
| @@ -0,0 +1,143 @@ |
| +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]) |
| +} |
| + |
| +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 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 ?? [])) |
| + } |
| + 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| new file mode 100644 |
| index 0000000..41dd4e9 |
| --- /dev/null |
| +++ b/ios/LChat/ChatStore.swift |
| @@ -0,0 +1,258 @@ |
| +import Foundation |
| +import UIKit |
| +import UserNotifications |
| + |
| +// 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 |
| + |
| + // 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] = [:] |
| + |
| + 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() { |
| + 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) |
| + } |
| + } |
| + |
| + 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: 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 } |
| + 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/Crypto.swift b/ios/LChat/Crypto.swift| new file mode 100644 |
| index 0000000..450ec49 |
| --- /dev/null |
| +++ b/ios/LChat/Crypto.swift |
| @@ -0,0 +1,94 @@ |
| +import Foundation |
| +import CryptoKit |
| + |
| +// All message/attachment encryption happens on-device with the group's AES-GCM |
| +// key, which lives only in the invite link's #fragment. The server sees nothing |
| +// but the opaque blobs produced here — the exact same contract as web/static/ |
| +// crypto.js, and the wire format is byte-identical: |
| +// |
| +// blob = iv(12) || ciphertext || tag(16) |
| +// |
| +// WebCrypto's AES-GCM output is ciphertext||tag and crypto.js prepends the IV, |
| +// which is precisely CryptoKit's `AES.GCM.SealedBox.combined` (nonce||ct||tag). |
| +// So blobs round-trip in both directions without any custom framing. |
| + |
| +enum CryptoError: Error { |
| + case badKey |
| + case badBlob |
| + case sealFailed |
| +} |
| + |
| +struct GroupKey { |
| + private let key: SymmetricKey |
| + |
| + /// Imports a 256-bit key from the base64url string carried in `#k=…`. |
| + init(base64url: String) throws { |
| + guard let raw = Data(base64urlEncoded: base64url), raw.count == 32 else { |
| + throw CryptoError.badKey |
| + } |
| + key = SymmetricKey(data: raw) |
| + } |
| + |
| + // MARK: JSON payloads (chat messages) |
| + |
| + func decryptJSON<T: Decodable>(_ type: T.Type, blob: String) throws -> T { |
| + let data = try decrypt(base64urlBlob: blob) |
| + return try JSONDecoder().decode(T.self, from: data) |
| + } |
| + |
| + func encryptJSON<T: Encodable>(_ value: T) throws -> String { |
| + let data = try JSONEncoder().encode(value) |
| + return try encrypt(data).base64urlEncodedString() |
| + } |
| + |
| + // MARK: Binary payloads (attachments) |
| + |
| + /// Decrypts raw `iv||ct||tag` bytes (attachment downloads are octet-stream, |
| + /// not base64). |
| + func decryptBytes(_ combined: Data) throws -> Data { |
| + do { |
| + let box = try AES.GCM.SealedBox(combined: combined) |
| + return try AES.GCM.open(box, using: key) |
| + } catch { |
| + throw CryptoError.badBlob |
| + } |
| + } |
| + |
| + /// Encrypts raw bytes to `iv||ct||tag` for upload. |
| + func encryptBytes(_ plaintext: Data) throws -> Data { |
| + try encrypt(plaintext) |
| + } |
| + |
| + // MARK: Internals |
| + |
| + private func decrypt(base64urlBlob: String) throws -> Data { |
| + guard let combined = Data(base64urlEncoded: base64urlBlob) else { |
| + throw CryptoError.badBlob |
| + } |
| + return try decryptBytes(combined) |
| + } |
| + |
| + private func encrypt(_ plaintext: Data) throws -> Data { |
| + // seal() picks a fresh random 12-byte nonce — exactly what the web does. |
| + let box = try AES.GCM.seal(plaintext, using: key) |
| + guard let combined = box.combined else { throw CryptoError.sealFailed } |
| + return combined |
| + } |
| +} |
| + |
| +extension Data { |
| + /// Decodes base64url (`-`/`_`, no padding), the encoding crypto.js uses. |
| + init?(base64urlEncoded s: String) { |
| + var str = s.replacingOccurrences(of: "-", with: "+") |
| + .replacingOccurrences(of: "_", with: "/") |
| + while str.count % 4 != 0 { str += "=" } |
| + self.init(base64Encoded: str) |
| + } |
| + |
| + func base64urlEncodedString() -> String { |
| + base64EncodedString() |
| + .replacingOccurrences(of: "+", with: "-") |
| + .replacingOccurrences(of: "/", with: "_") |
| + .replacingOccurrences(of: "=", with: "") |
| + } |
| +} |
diff --git a/ios/LChat/ImageProcessing.swift b/ios/LChat/ImageProcessing.swift| new file mode 100644 |
| index 0000000..868e75e |
| --- /dev/null |
| +++ b/ios/LChat/ImageProcessing.swift |
| @@ -0,0 +1,35 @@ |
| +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/Info.plist b/ios/LChat/Info.plist| new file mode 100644 |
| index 0000000..6eed0ba |
| --- /dev/null |
| +++ b/ios/LChat/Info.plist |
| @@ -0,0 +1,42 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>CFBundleDevelopmentRegion</key> |
| + <string>$(DEVELOPMENT_LANGUAGE)</string> |
| + <key>CFBundleDisplayName</key> |
| + <string>lchat</string> |
| + <key>CFBundleExecutable</key> |
| + <string>$(EXECUTABLE_NAME)</string> |
| + <key>CFBundleIdentifier</key> |
| + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> |
| + <key>CFBundleInfoDictionaryVersion</key> |
| + <string>6.0</string> |
| + <key>CFBundleName</key> |
| + <string>$(PRODUCT_NAME)</string> |
| + <key>CFBundlePackageType</key> |
| + <string>APPL</string> |
| + <key>CFBundleShortVersionString</key> |
| + <string>1.0</string> |
| + <key>CFBundleVersion</key> |
| + <string>1</string> |
| + <key>NSAppTransportSecurity</key> |
| + <dict> |
| + <key>NSAllowsLocalNetworking</key> |
| + <true/> |
| + </dict> |
| + <key>UILaunchScreen</key> |
| + <dict/> |
| + <key>UISupportedInterfaceOrientations</key> |
| + <array> |
| + <string>UIInterfaceOrientationPortrait</string> |
| + </array> |
| + <key>UISupportedInterfaceOrientations~ipad</key> |
| + <array> |
| + <string>UIInterfaceOrientationPortrait</string> |
| + <string>UIInterfaceOrientationPortraitUpsideDown</string> |
| + <string>UIInterfaceOrientationLandscapeLeft</string> |
| + <string>UIInterfaceOrientationLandscapeRight</string> |
| + </array> |
| +</dict> |
| +</plist> |
diff --git a/ios/LChat/Keychain.swift b/ios/LChat/Keychain.swift| new file mode 100644 |
| index 0000000..81407af |
| --- /dev/null |
| +++ b/ios/LChat/Keychain.swift |
| @@ -0,0 +1,61 @@ |
| +import Foundation |
| +import Security |
| + |
| +// Keychain-backed JSON storage. The saved chats live here (not UserDefaults) |
| +// because their invite URLs carry the group key — the "key is load-bearing" rule |
| +// from CLAUDE.md. |
| +enum Keychain { |
| + private static let service = "com.lchat.client" |
| + |
| + static func load<T: Decodable>(_ type: T.Type, account: String) -> T? { |
| + let query: [String: Any] = [ |
| + kSecClass as String: kSecClassGenericPassword, |
| + kSecAttrService as String: service, |
| + kSecAttrAccount as String: account, |
| + kSecReturnData as String: true, |
| + kSecMatchLimit as String: kSecMatchLimitOne, |
| + ] |
| + var item: CFTypeRef? |
| + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, |
| + let data = item as? Data, |
| + let value = try? JSONDecoder().decode(T.self, from: data) |
| + else { return nil } |
| + return value |
| + } |
| + |
| + static func save<T: Encodable>(_ value: T, account: String) { |
| + guard let data = try? JSONEncoder().encode(value) else { return } |
| + let base: [String: Any] = [ |
| + kSecClass as String: kSecClassGenericPassword, |
| + kSecAttrService as String: service, |
| + kSecAttrAccount as String: account, |
| + ] |
| + SecItemDelete(base as CFDictionary) |
| + var add = base |
| + add[kSecValueData as String] = data |
| + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly |
| + SecItemAdd(add as CFDictionary, nil) |
| + } |
| + |
| + static func clear(account: String) { |
| + SecItemDelete([ |
| + kSecClass as String: kSecClassGenericPassword, |
| + kSecAttrService as String: service, |
| + kSecAttrAccount as String: account, |
| + ] as CFDictionary) |
| + } |
| +} |
| + |
| +// The persisted list of chats. |
| +enum ChatsStore { |
| + private static let account = "chats" |
| + static func load() -> [SavedChat] { Keychain.load([SavedChat].self, account: account) ?? [] } |
| + static func save(_ chats: [SavedChat]) { Keychain.save(chats, account: account) } |
| +} |
| + |
| +// Legacy single-session slot, kept only to migrate old installs into the list. |
| +enum SessionStore { |
| + private static let account = "session" |
| + static func load() -> Session? { Keychain.load(Session.self, account: account) } |
| + static func clear() { Keychain.clear(account: account) } |
| +} |
diff --git a/ios/LChat/LChat.entitlements b/ios/LChat/LChat.entitlements| new file mode 100644 |
| index 0000000..d218dc7 |
| --- /dev/null |
| +++ b/ios/LChat/LChat.entitlements |
| @@ -0,0 +1,16 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <!-- Mac Catalyst runs sandboxed; without network.client the app can't open |
| + any connection (REST or WebSocket). The sandbox keys are macOS-only and |
| + are ignored on iOS. --> |
| + <key>com.apple.security.app-sandbox</key> |
| + <true/> |
| + <key>com.apple.security.network.client</key> |
| + <true/> |
| + <!-- Lets the sandboxed Mac build read files chosen via the file picker. --> |
| + <key>com.apple.security.files.user-selected.read-only</key> |
| + <true/> |
| +</dict> |
| +</plist> |
diff --git a/ios/LChat/LChatAPI.swift b/ios/LChat/LChatAPI.swift| new file mode 100644 |
| index 0000000..004e373 |
| --- /dev/null |
| +++ b/ios/LChat/LChatAPI.swift |
| @@ -0,0 +1,109 @@ |
| +import Foundation |
| + |
| +// Thin REST client for the lchat HTTP API. Every data endpoint is token-gated; |
| +// the token rides in the query string exactly as the web client sends it. The |
| +// server only ever sees ciphertext and opaque blob ids. |
| +struct LChatAPI { |
| + let baseURL: URL |
| + let token: String |
| + private let session = URLSession(configuration: .default) |
| + |
| + // MARK: Invite link parsing (mirrors chat.js `parse`) |
| + |
| + struct ParsedInvite { |
| + let baseURL: URL |
| + let token: String |
| + let keyB64: String |
| + } |
| + |
| + /// Parses `https://host/join/<token>#k=<key>` into its parts. |
| + static func parseInvite(_ raw: String) -> ParsedInvite? { |
| + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard var comps = URLComponents(string: trimmed), |
| + let scheme = comps.scheme, let host = comps.host, |
| + scheme == "http" || scheme == "https" |
| + else { return nil } |
| + |
| + let token = comps.path.split(separator: "/").last.map(String.init) |
| + // The key lives in the fragment as `k=…` (URLComponents leaves it raw). |
| + var keyB64: String? |
| + if let frag = comps.fragment { |
| + for pair in frag.split(separator: "&") { |
| + let kv = pair.split(separator: "=", maxSplits: 1) |
| + if kv.first == "k", kv.count == 2 { keyB64 = String(kv[1]) } |
| + } |
| + } |
| + guard let token, !token.isEmpty, let keyB64, !keyB64.isEmpty else { return nil } |
| + |
| + // Reduce to a clean origin URL (scheme://host[:port]). |
| + comps.path = "" |
| + comps.query = nil |
| + comps.fragment = nil |
| + guard let base = comps.url else { return nil } |
| + return ParsedInvite(baseURL: base, token: token, keyB64: keyB64) |
| + } |
| + |
| + // MARK: Endpoints |
| + |
| + static func inviteInfo(baseURL: URL, token: String) async throws -> InviteInfo { |
| + let url = baseURL.appending(path: "/api/invite/\(token.urlPathEscaped)") |
| + let (data, _) = try await URLSession.shared.data(from: url) |
| + return try JSONDecoder().decode(InviteInfo.self, from: data) |
| + } |
| + |
| + static func claim(baseURL: URL, token: String) async { |
| + let url = baseURL.appending(path: "/api/invite/\(token.urlPathEscaped)/claim") |
| + var req = URLRequest(url: url) |
| + req.httpMethod = "POST" |
| + _ = try? await URLSession.shared.data(for: req) |
| + } |
| + |
| + func groupName() async -> String { |
| + let url = baseURL.appending(path: "/api/group") |
| + guard let (data, _) = try? await session.data(from: url), |
| + let obj = try? JSONDecoder().decode([String: String].self, from: data), |
| + let name = obj["name"] |
| + else { return "lchat" } |
| + return name |
| + } |
| + |
| + func messages(after: Int64) async throws -> [ServerMessage] { |
| + var comps = URLComponents(url: baseURL.appending(path: "/api/messages"), |
| + resolvingAgainstBaseURL: false)! |
| + comps.queryItems = [ |
| + URLQueryItem(name: "token", value: token), |
| + URLQueryItem(name: "after", value: String(after)), |
| + ] |
| + let (data, _) = try await session.data(from: comps.url!) |
| + struct Resp: Codable { let messages: [ServerMessage]? } |
| + return try JSONDecoder().decode(Resp.self, from: data).messages ?? [] |
| + } |
| + |
| + /// Uploads encrypted bytes, returns the new blob id. |
| + func upload(_ data: Data) async throws -> String { |
| + var comps = URLComponents(url: baseURL.appending(path: "/api/upload"), |
| + resolvingAgainstBaseURL: false)! |
| + comps.queryItems = [URLQueryItem(name: "token", value: token)] |
| + var req = URLRequest(url: comps.url!) |
| + req.httpMethod = "POST" |
| + req.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") |
| + let (respData, _) = try await session.upload(for: req, from: data) |
| + struct Resp: Codable { let id: String } |
| + return try JSONDecoder().decode(Resp.self, from: respData).id |
| + } |
| + |
| + /// Downloads encrypted attachment bytes by id. |
| + func blob(id: String) async throws -> Data { |
| + var comps = URLComponents(url: baseURL.appending(path: "/api/blob/\(id.urlPathEscaped)"), |
| + resolvingAgainstBaseURL: false)! |
| + comps.queryItems = [URLQueryItem(name: "token", value: token)] |
| + let (data, _) = try await session.data(from: comps.url!) |
| + return data |
| + } |
| +} |
| + |
| +private extension String { |
| + var urlPathEscaped: String { |
| + addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? self |
| + } |
| +} |
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| new file mode 100644 |
| index 0000000..44dc7ae |
| --- /dev/null |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -0,0 +1,88 @@ |
| +import SwiftUI |
| + |
| +@main |
| +struct LChatApp: App { |
| + @StateObject private var app = AppState() |
| + |
| + var body: some Scene { |
| + WindowGroup { |
| + RootView() |
| + .environmentObject(app) |
| + } |
| + } |
| +} |
| + |
| +// Owns the persisted list of chats and which one (if any) is open. |
| +final class AppState: ObservableObject { |
| + @Published var chats: [SavedChat] |
| + @Published var current: SavedChat? // nil = showing the chat list |
| + |
| + init() { |
| + var saved = ChatsStore.load() |
| + // Migrate an old single-session install into the list, once. |
| + if saved.isEmpty, let old = SessionStore.load() { |
| + let url = "\(old.origin)/join/\(old.token)#k=\(old.keyB64)" |
| + saved = [SavedChat(url: url, name: old.name)] |
| + ChatsStore.save(saved) |
| + SessionStore.clear() |
| + } |
| + chats = saved |
| + } |
| + |
| + func open(_ chat: SavedChat) { current = chat } |
| + |
| + /// Back to the list. The chat's lastSeenId (so far) clears its unread badge. |
| + func close(lastSeenId: Int64) { |
| + if let cur = current, let i = chats.firstIndex(where: { $0.id == cur.id }) { |
| + chats[i].lastSeenId = max(chats[i].lastSeenId, lastSeenId) |
| + ChatsStore.save(chats) |
| + } |
| + current = nil |
| + } |
| + |
| + /// Add a chat (or update the screen name if it's already saved) and open it. |
| + func add(url: String, name: String) { |
| + let chat = SavedChat(url: url, name: name) |
| + if let i = chats.firstIndex(where: { $0.token != nil && $0.token == chat.token }) { |
| + chats[i].name = name |
| + ChatsStore.save(chats) |
| + current = chats[i] |
| + } else { |
| + chats.append(chat) |
| + ChatsStore.save(chats) |
| + current = chat |
| + } |
| + } |
| + |
| + func remove(_ chat: SavedChat) { |
| + chats.removeAll { $0.id == chat.id } |
| + ChatsStore.save(chats) |
| + } |
| + |
| + func setName(_ name: String, for chat: SavedChat) { |
| + guard let i = chats.firstIndex(where: { $0.id == chat.id }) else { return } |
| + chats[i].name = name |
| + ChatsStore.save(chats) |
| + } |
| + |
| + func markSeen(_ id: Int64, in chat: SavedChat) { |
| + guard let i = chats.firstIndex(where: { $0.id == chat.id }) else { return } |
| + if id > chats[i].lastSeenId { |
| + chats[i].lastSeenId = id |
| + ChatsStore.save(chats) |
| + } |
| + } |
| +} |
| + |
| +struct RootView: View { |
| + @EnvironmentObject var app: AppState |
| + |
| + var body: some View { |
| + if let current = app.current { |
| + ChatView(chat: current) |
| + .id(current.id) // rebuild the store when switching chats |
| + } else { |
| + ChatListView() |
| + } |
| + } |
| +} |
diff --git a/ios/LChat/Models.swift b/ios/LChat/Models.swift| new file mode 100644 |
| index 0000000..33ec816 |
| --- /dev/null |
| +++ b/ios/LChat/Models.swift |
| @@ -0,0 +1,142 @@ |
| +import Foundation |
| + |
| +// The persisted identity for one chat on this device. The key never leaves the |
| +// device and is stored in the Keychain (see Keychain.swift), mirroring the |
| +// "the key is load-bearing" rule from CLAUDE.md. |
| +struct Session: Codable, Equatable { |
| + var baseURL: URL // e.g. https://chat.example.com — derived from the invite link |
| + var token: String // invite token (transport auth only; NOT a decryption key) |
| + var keyB64: String // base64url AES-GCM key from the link's #fragment |
| + var name: String // screen name |
| + |
| + /// wss/ws URL for the live feed, matching baseURL's scheme. |
| + var socketURL: URL { |
| + var c = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! |
| + c.scheme = baseURL.scheme == "https" ? "wss" : "ws" |
| + c.path = "/ws" |
| + c.queryItems = [URLQueryItem(name: "token", value: token)] |
| + return c.url! |
| + } |
| + |
| + /// The Origin header value to present on the WebSocket upgrade, so the |
| + /// server's same-origin check is satisfied. |
| + var origin: String { |
| + var s = baseURL.scheme.map { "\($0)://" } ?? "" |
| + s += baseURL.host ?? "" |
| + if let port = baseURL.port { s += ":\(port)" } |
| + return s |
| + } |
| +} |
| + |
| +// A saved chat: just the invite URL (which carries host, token and key in its |
| +// #fragment) plus the screen name — persisted between runs. lastSeenId drives the |
| +// unread count shown in the chat list. |
| +struct SavedChat: Codable, Identifiable, Equatable { |
| + var url: String |
| + var name: String |
| + var lastSeenId: Int64 = 0 |
| + |
| + var id: String { url } |
| + |
| + /// Parse the stored invite URL into a usable Session (nil if malformed). |
| + var session: Session? { |
| + guard let p = LChatAPI.parseInvite(url) else { return nil } |
| + return Session(baseURL: p.baseURL, token: p.token, keyB64: p.keyB64, name: name) |
| + } |
| + |
| + /// The invite token, used to de-duplicate chats. |
| + var token: String? { LChatAPI.parseInvite(url)?.token } |
| +} |
| + |
| +// What /api/messages and the "msg" WebSocket frame return. Opaque to the server. |
| +struct ServerMessage: Codable, Identifiable { |
| + let id: Int64 |
| + let created_at: Int64 |
| + let blob: String |
| + var reactions: [ReactionView]? |
| +} |
| + |
| +struct ReactionView: Codable, Hashable { |
| + let emoji: String |
| + let count: Int |
| + var mine: Bool? |
| +} |
| + |
| +// The decrypted message body — same shape chat.js builds before encrypt(). |
| +struct MessagePayload: Codable { |
| + var name: String |
| + var text: String? |
| + var ts: Double? // ms since epoch (Date.now() on the web) |
| + var attachments: [Attachment]? |
| + var replyTo: ReplyTo? |
| + var type: String? // message kind; see MessageType (nil == legacy chat) |
| +} |
| + |
| +// Message-content typing — mirrors web/static/message.js. The `type` rides |
| +// inside the encrypted payload (the server only ever sees ciphertext), so it's |
| +// an end-to-end signal between clients, never server metadata. Everything is a |
| +// "chat" message today; tagging them now lets future clients add other payload |
| +// kinds and lets each client decide which kinds it will render. |
| +enum MessageType { |
| + // The type this client stamps on every message it sends. |
| + static let chat = "chat" |
| + |
| + // The types this client will render. A nil/absent type is a legacy message |
| + // from before payloads were tagged, so it's treated as a chat. Narrowing |
| + // this set — or dropping the nil allowance — is how we get "more limited". |
| + private static let accepted: Set<String> = [chat] |
| + |
| + // Whether a decrypted payload of the given type should be rendered. |
| + static func accepts(_ type: String?) -> Bool { |
| + guard let type else { return true } |
| + return accepted.contains(type) |
| + } |
| +} |
| + |
| +struct Attachment: Codable, Hashable { |
| + var id: String |
| + var name: String? |
| + var type: String? |
| + var size: Int? |
| + var w: Int? |
| + var h: Int? |
| + |
| + var isImage: Bool { type?.hasPrefix("image/") ?? false } |
| +} |
| + |
| +struct ReplyTo: Codable, Hashable { |
| + var id: Int64 |
| + var name: String? |
| + var snippet: String? |
| +} |
| + |
| +// Invite lookup result (/api/invite/{token}). |
| +struct InviteInfo: Codable { |
| + let ok: Bool |
| + var revoked: Bool? |
| + var label: String? |
| +} |
| + |
| +// The fixed reaction set. Copied verbatim from hub.go's allowedReactions — |
| +// `❤️` carries a U+FE0F variation selector; a server reaction that doesn't match |
| +// exactly is silently dropped. |
| +let kReactions = ["👍", "❤️", "😂", "😮", "😢", "🎉"] |
| + |
| +// MARK: - Reply snippet (mirrors format.js replySnippet) |
| + |
| +func replySnippet(_ payload: MessagePayload, max: Int = 140) -> String { |
| + func clamp(_ s: String) -> String { |
| + guard s.count > max else { return s } |
| + let cut = s.prefix(max - 1).trimmingCharacters(in: .whitespaces) |
| + return cut + "…" |
| + } |
| + let text = (payload.text ?? "") |
| + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) |
| + .trimmingCharacters(in: .whitespacesAndNewlines) |
| + if !text.isEmpty { return clamp(text) } |
| + if let a = payload.attachments?.first { |
| + if a.isImage { return "📷 Photo" } |
| + return clamp("📎 " + (a.name ?? "Attachment")) |
| + } |
| + return "" |
| +} |
diff --git a/ios/LChat/Views/ChatListView.swift b/ios/LChat/Views/ChatListView.swift| new file mode 100644 |
| index 0000000..7c9893c |
| --- /dev/null |
| +++ b/ios/LChat/Views/ChatListView.swift |
| @@ -0,0 +1,93 @@ |
| +import SwiftUI |
| + |
| +// The home screen: every saved chat with its group name and unread count, plus a |
| +// button to add another. Tapping one opens it. |
| +struct ChatListView: View { |
| + @EnvironmentObject var app: AppState |
| + @State private var showAdd = false |
| + @State private var groupNames: [String: String] = [:] |
| + @State private var unread: [String: Int] = [:] |
| + |
| + var body: some View { |
| + NavigationStack { |
| + Group { |
| + if app.chats.isEmpty { |
| + emptyState |
| + } else { |
| + list |
| + } |
| + } |
| + .navigationTitle("Chats") |
| + .toolbar { |
| + ToolbarItem(placement: .topBarTrailing) { |
| + Button { showAdd = true } label: { Image(systemName: "plus") } |
| + } |
| + } |
| + .sheet(isPresented: $showAdd) { AddChatView() } |
| + .task(id: app.chats) { await refresh() } |
| + } |
| + } |
| + |
| + private var list: some View { |
| + List { |
| + ForEach(app.chats) { chat in |
| + Button { app.open(chat) } label: { row(chat) } |
| + .buttonStyle(.plain) |
| + } |
| + .onDelete { offsets in |
| + offsets.map { app.chats[$0] }.forEach(app.remove) |
| + } |
| + } |
| + .refreshable { await refresh() } |
| + } |
| + |
| + private func row(_ chat: SavedChat) -> some View { |
| + HStack(spacing: 12) { |
| + VStack(alignment: .leading, spacing: 2) { |
| + Text(groupNames[chat.id] ?? host(chat)).font(.headline) |
| + Text("as \(chat.name)").font(.caption).foregroundStyle(.secondary) |
| + } |
| + Spacer() |
| + if let n = unread[chat.id], n > 0 { |
| + Text("\(n)") |
| + .font(.caption.bold()).foregroundStyle(.white) |
| + .padding(.horizontal, 8).padding(.vertical, 3) |
| + .background(Color.accentColor, in: Capsule()) |
| + } |
| + Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) |
| + } |
| + .contentShape(Rectangle()) |
| + .padding(.vertical, 4) |
| + } |
| + |
| + private var emptyState: some View { |
| + VStack(spacing: 16) { |
| + Image(systemName: "bubble.left.and.bubble.right") |
| + .font(.system(size: 48)).foregroundStyle(.secondary) |
| + Text("No chats yet").font(.title3.weight(.semibold)) |
| + Text("Add a chat with an invite link you were sent.") |
| + .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) |
| + Button { showAdd = true } label: { Text("Add chat").frame(maxWidth: 220) } |
| + .buttonStyle(.borderedProminent).controlSize(.large) |
| + } |
| + .padding() |
| + } |
| + |
| + private func host(_ chat: SavedChat) -> String { |
| + chat.session?.baseURL.host ?? "Chat" |
| + } |
| + |
| + // Fetch each chat's group name and unread count. The count is simply the |
| + // number of messages after lastSeenId — no decryption needed (or possible) |
| + // here, just the server's opaque count. |
| + private func refresh() async { |
| + for chat in app.chats { |
| + guard let s = chat.session else { continue } |
| + let api = LChatAPI(baseURL: s.baseURL, token: s.token) |
| + let name = await api.groupName() |
| + let count = (try? await api.messages(after: chat.lastSeenId))?.count ?? 0 |
| + groupNames[chat.id] = name |
| + unread[chat.id] = count |
| + } |
| + } |
| +} |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| new file mode 100644 |
| index 0000000..4571b5b |
| --- /dev/null |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -0,0 +1,358 @@ |
| +import SwiftUI |
| +import PhotosUI |
| +import UniformTypeIdentifiers |
| + |
| +struct ChatView: View { |
| + @EnvironmentObject var app: AppState |
| + @Environment(\.scenePhase) private var scenePhase |
| + @StateObject private var store: ChatStore |
| + |
| + @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 showLeave = false |
| + @State private var flashID: Int64? |
| + @State private var dropTargeted = false |
| + |
| + 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)) |
| + } |
| + |
| + 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 |
| + composer |
| + } |
| + // 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) |
| + .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) } |
| + .background(EdgeSwipeBack { goBack() }) |
| + #if targetEnvironment(macCatalyst) |
| + .background(MacWindowTitle(title: store.groupName)) |
| + #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) {} |
| + } |
| + .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) |
| + } |
| + } |
| + } |
| + } |
| + |
| + 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 } |
| + } |
| + } |
| + |
| + // MARK: Composer |
| + |
| + private var composer: some View { |
| + VStack(spacing: 6) { |
| + if let replyingTo { |
| + replyBanner(replyingTo) |
| + } |
| + if pendingAttachment != nil || uploadingAttachment { |
| + attachmentPreview |
| + } |
| + 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) |
| + .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) |
| + // Hardware keyboard (Mac/iPad): Return sends. Shift+Return isn't |
| + // matched, so the multiline field inserts a newline instead. |
| + .keyboardShortcut(.return, modifiers: []) |
| + } |
| + } |
| + .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) } |
| + } |
| + .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 |
| + private var toolbarMenu: some ToolbarContent { |
| + ToolbarItem(placement: .topBarLeading) { |
| + Button { goBack() } label: { |
| + Image(systemName: "chevron.backward") |
| + } |
| + .accessibilityLabel("All chats") |
| + } |
| + 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") |
| + } |
| + } label: { |
| + Image(systemName: "ellipsis.circle") |
| + } |
| + } |
| + } |
| + |
| + // 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)) |
| + } |
| + store.send(text: text, attachment: pendingAttachment, replyTo: replyMeta) |
| + text = "" |
| + pendingAttachment = nil |
| + pendingPreview = nil |
| + pendingName = nil |
| + replyingTo = nil |
| + } |
| + |
| + // 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 |
| + } |
| + } |
| + } |
| + |
| + 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)) |
| + } |
| + } |
| + |
| + 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) |
| + } |
| + |
| + // 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 |
| + } |
| + 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)) } |
| + } |
| + return true |
| + } |
| + return false |
| + } |
| +} |
diff --git a/ios/LChat/Views/EdgeSwipeBack.swift b/ios/LChat/Views/EdgeSwipeBack.swift| new file mode 100644 |
| index 0000000..5e58d11 |
| --- /dev/null |
| +++ b/ios/LChat/Views/EdgeSwipeBack.swift |
| @@ -0,0 +1,55 @@ |
| +import SwiftUI |
| +import UIKit |
| + |
| +// A left-screen-edge swipe that runs `action` (used to go back to the chat list). |
| +// Installed as a UIScreenEdgePanGestureRecognizer on the window so it coexists |
| +// with the scroll view and the message swipe-to-reply instead of fighting them. |
| +struct EdgeSwipeBack: UIViewRepresentable { |
| + var action: () -> Void |
| + |
| + func makeCoordinator() -> Coordinator { Coordinator(action) } |
| + func makeUIView(context: Context) -> UIView { ProbeView(context.coordinator) } |
| + func updateUIView(_ uiView: UIView, context: Context) { context.coordinator.action = action } |
| + |
| + final class Coordinator: NSObject, UIGestureRecognizerDelegate { |
| + var action: () -> Void |
| + init(_ action: @escaping () -> Void) { self.action = action } |
| + |
| + @objc func handle(_ g: UIScreenEdgePanGestureRecognizer) { |
| + if g.state == .ended || g.state == .recognized { action() } |
| + } |
| + |
| + func gestureRecognizer(_ g: UIGestureRecognizer, |
| + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { |
| + true |
| + } |
| + } |
| + |
| + // Adds the recognizer when it joins a window and removes it when it leaves, so |
| + // it doesn't linger after the chat view goes away. |
| + final class ProbeView: UIView { |
| + private let coordinator: Coordinator |
| + private weak var recognizer: UIScreenEdgePanGestureRecognizer? |
| + |
| + init(_ coordinator: Coordinator) { |
| + self.coordinator = coordinator |
| + super.init(frame: .zero) |
| + isUserInteractionEnabled = false |
| + } |
| + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } |
| + |
| + override func didMoveToWindow() { |
| + super.didMoveToWindow() |
| + if let window, recognizer == nil { |
| + let g = UIScreenEdgePanGestureRecognizer(target: coordinator, action: #selector(Coordinator.handle)) |
| + g.edges = .left |
| + g.delegate = coordinator |
| + window.addGestureRecognizer(g) |
| + recognizer = g |
| + } else if window == nil, let g = recognizer { |
| + g.view?.removeGestureRecognizer(g) |
| + recognizer = nil |
| + } |
| + } |
| + } |
| +} |
diff --git a/ios/LChat/Views/JoinView.swift b/ios/LChat/Views/JoinView.swift| new file mode 100644 |
| index 0000000..725b0ac |
| --- /dev/null |
| +++ b/ios/LChat/Views/JoinView.swift |
| @@ -0,0 +1,105 @@ |
| +import SwiftUI |
| + |
| +// Add a chat: paste an invite link + pick a screen name. The link carries the |
| +// secret key in its #fragment; we validate it on-device and never send it |
| +// anywhere. Presented as a sheet from the chat list. |
| +struct AddChatView: View { |
| + @EnvironmentObject var app: AppState |
| + @Environment(\.dismiss) private var dismiss |
| + @State private var link = "" |
| + @State private var name = "" |
| + @State private var error: String? |
| + @State private var busy = false |
| + |
| + var body: some View { |
| + NavigationStack { |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 20) { |
| + Text("Paste the invite link you were sent. It contains a secret key that stays on this device.") |
| + .font(.callout) |
| + .foregroundStyle(.secondary) |
| + |
| + VStack(alignment: .leading, spacing: 6) { |
| + Text("Invite link").font(.subheadline.weight(.semibold)) |
| + TextField("https://…/join/…#k=…", text: $link, axis: .vertical) |
| + .textFieldStyle(.roundedBorder) |
| + .textInputAutocapitalization(.never) |
| + .autocorrectionDisabled() |
| + .keyboardType(.URL) |
| + .onChange(of: link) { _, _ in prefillName() } |
| + } |
| + |
| + VStack(alignment: .leading, spacing: 6) { |
| + Text("Your screen name").font(.subheadline.weight(.semibold)) |
| + TextField("e.g. Sam", text: $name) |
| + .textFieldStyle(.roundedBorder) |
| + .autocorrectionDisabled() |
| + } |
| + |
| + if let error { |
| + Text(error).font(.callout).foregroundStyle(.red) |
| + } |
| + |
| + Button(action: add) { |
| + if busy { ProgressView() } else { Text("Add chat").frame(maxWidth: .infinity) } |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .controlSize(.large) |
| + .disabled(busy) |
| + } |
| + .padding() |
| + } |
| + .navigationTitle("Add chat") |
| + .navigationBarTitleDisplayMode(.inline) |
| + .toolbar { |
| + ToolbarItem(placement: .topBarLeading) { |
| + Button("Cancel") { dismiss() } |
| + } |
| + } |
| + } |
| + } |
| + |
| + // Fill the name field from the invite's label, once, if it's still empty. |
| + private func prefillName() { |
| + guard name.isEmpty, let p = LChatAPI.parseInvite(link) else { return } |
| + Task { |
| + if let info = try? await LChatAPI.inviteInfo(baseURL: p.baseURL, token: p.token), |
| + let label = info.label, !label.isEmpty, name.isEmpty { |
| + name = label |
| + } |
| + } |
| + } |
| + |
| + private func add() { |
| + error = nil |
| + guard let p = LChatAPI.parseInvite(link) else { |
| + error = "That doesn't look like a valid invite link." |
| + return |
| + } |
| + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard !trimmedName.isEmpty else { |
| + error = "Please enter a screen name." |
| + return |
| + } |
| + guard (try? GroupKey(base64url: p.keyB64)) != nil else { |
| + error = "This link's secret key is malformed." |
| + return |
| + } |
| + busy = true |
| + Task { |
| + defer { busy = false } |
| + guard let info = try? await LChatAPI.inviteInfo(baseURL: p.baseURL, token: p.token) else { |
| + error = "Couldn't reach the server. Try again." |
| + return |
| + } |
| + guard info.ok else { |
| + error = (info.revoked == true) ? "This invite was turned off." : "This invite link is invalid." |
| + return |
| + } |
| + await LChatAPI.claim(baseURL: p.baseURL, token: p.token) |
| + app.add(url: link.trimmingCharacters(in: .whitespacesAndNewlines), |
| + name: String(trimmedName.prefix(32))) |
| + dismiss() |
| + } |
| + } |
| +} |
diff --git a/ios/LChat/Views/MacTitlebar.swift b/ios/LChat/Views/MacTitlebar.swift| new file mode 100644 |
| index 0000000..3a86c47 |
| --- /dev/null |
| +++ b/ios/LChat/Views/MacTitlebar.swift |
| @@ -0,0 +1,29 @@ |
| +#if targetEnvironment(macCatalyst) |
| +import SwiftUI |
| +import UIKit |
| + |
| +// Mac only: put the chat name in the native window titlebar so it isn't the |
| +// generic app name. The back button + settings menu render in the app's own |
| +// navigation bar — Catalyst's titlebar pins the system title to the leading edge |
| +// and groups NSToolbar items on the trailing side, and custom-view toolbar items |
| +// (NSToolbarItem.view) aren't available under Catalyst, so a chevron can't be |
| +// placed cleanly to the left of the title there. |
| +struct MacWindowTitle: UIViewControllerRepresentable { |
| + let title: String |
| + |
| + func makeUIViewController(context: Context) -> UIViewController { |
| + let vc = UIViewController() |
| + vc.view.isUserInteractionEnabled = false |
| + return vc |
| + } |
| + |
| + func updateUIViewController(_ vc: UIViewController, context: Context) { |
| + let title = self.title |
| + DispatchQueue.main.async { |
| + guard let scene = vc.view.window?.windowScene else { return } |
| + scene.title = title |
| + scene.titlebar?.titleVisibility = .visible |
| + } |
| + } |
| +} |
| +#endif |
diff --git a/ios/LChat/Views/MessageRow.swift b/ios/LChat/Views/MessageRow.swift| new file mode 100644 |
| index 0000000..0b9cce4 |
| --- /dev/null |
| +++ b/ios/LChat/Views/MessageRow.swift |
| @@ -0,0 +1,367 @@ |
| +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 { |
| + 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 } |
| + } |
| + |
| + 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/ios/README.md b/ios/README.md| new file mode 100644 |
| index 0000000..c1be0bc |
| --- /dev/null |
| +++ b/ios/README.md |
| @@ -0,0 +1,80 @@ |
| +# lchat — iOS & macOS client |
| + |
| +A native SwiftUI client for [lchat](../README.md), running on **iPhone/iPad** and |
| +on **macOS via Mac Catalyst** (same target, same code). Same end-to-end |
| +encryption contract as the web app: the AES-GCM group key lives only in the |
| +invite link's `#fragment`, stays on-device (Keychain), and the server only ever |
| +sees opaque ciphertext and blob ids. |
| + |
| +## What it does |
| + |
| +- **Join** by pasting an invite link (`https://host/join/<token>#k=…`). The host |
| + is taken from the link — nothing is hardcoded. |
| +- **Read** history + live messages (text, inline images, reply quotes) over the |
| + `/ws` WebSocket, with auto-reconnect and gap-fill. |
| +- **Send** text and **photos** (downscaled + re-encoded on-device, which also |
| + strips EXIF/GPS — there is no server-side image proxy). |
| +- **React** (long-press a message → pick from the same six-emoji set the server |
| + allows) and **reply** (quoted, iMessage-style; the thread link rides inside the |
| + encrypted payload). |
| + |
| +Out of scope for this first cut: push notifications (needs APNs + a paid Apple |
| +developer account + server work) and link-preview cards. |
| + |
| +## Build & run |
| + |
| +Requires Xcode 16+ and [`xcodegen`](https://github.com/yonsm/XcodeGen) |
| +(`brew install xcodegen`). The `.xcodeproj` is generated, not committed. |
| + |
| +```sh |
| +cd ios |
| +xcodegen generate # writes LChat.xcodeproj from project.yml |
| +open LChat.xcodeproj # then pick a destination and Run |
| +``` |
| + |
| +Pick the destination in Xcode's run-destination menu: |
| + |
| +- **iPhone/iPad** — a Simulator or a connected device. |
| +- **Mac** — choose "My Mac (Mac Catalyst)". |
| + |
| +To run on a physical iOS device or on the Mac, set your signing team in Xcode |
| +(Signing & Capabilities) — the bundle id is `com.lchat.client`. |
| + |
| +### macOS (Mac Catalyst) notes |
| + |
| +The Mac app is the same target with `SUPPORTS_MACCATALYST = YES`. Because |
| +Catalyst apps are sandboxed, `LChat/LChat.entitlements` grants |
| +`com.apple.security.network.client` — **without it the Mac app cannot connect to |
| +the server at all** (REST or WebSocket). The Keychain, photo picker, and share |
| +sheet all work under the sandbox as-is. |
| + |
| +Headless build check for the Mac variant (doesn't need the iOS Simulator): |
| + |
| +```sh |
| +xcodebuild -project LChat.xcodeproj -scheme LChat \ |
| + -destination 'platform=macOS,variant=Mac Catalyst' build |
| +``` |
| + |
| +One UX difference to be aware of: the composer sends via the send button (there's |
| +no Enter-to-send shortcut yet on Mac); Return inserts a newline. |
| + |
| +### Testing against a local server |
| + |
| +`NSAllowsLocalNetworking` is enabled so you can point the app at a `go run .` |
| +instance over plain HTTP on the LAN/loopback. In production, instances are HTTPS |
| +and no ATS exception is needed. Mint an invite from the admin console, then paste |
| +that `…/join/…#k=…` link into the app's join screen. |
| + |
| +## How it maps to the server |
| + |
| +| iOS file | Responsibility | Web counterpart | |
| +|---|---|---| |
| +| `Crypto.swift` | AES-GCM via CryptoKit; `iv(12)‖ct‖tag` base64url | `crypto.js` | |
| +| `LChatAPI.swift` | REST: invite, messages, upload, blob, group | `fetch` calls in `chat.js` | |
| +| `ChatSocket.swift` | `/ws` send/receive, ping keepalive, reconnect | WebSocket block in `chat.js` | |
| +| `ChatStore.swift` | decrypt, dedupe, reactions, reply counts | render/state in `chat.js` | |
| +| `Models.swift` | payload shape + `replySnippet` + reaction set | `format.js`, `hub.go` | |
| + |
| +The wire format is byte-identical to the web client in both directions |
| +(CryptoKit's `SealedBox.combined` is exactly `iv‖ciphertext‖tag`), so iOS and |
| +browser participants interoperate in the same chat. |
diff --git a/ios/project.yml b/ios/project.yml| new file mode 100644 |
| index 0000000..cb8945c |
| --- /dev/null |
| +++ b/ios/project.yml |
| @@ -0,0 +1,45 @@ |
| +name: LChat |
| +options: |
| + bundleIdPrefix: com.lchat |
| + deploymentTarget: |
| + iOS: "17.0" |
| + createIntermediateGroups: true |
| +settings: |
| + base: |
| + SWIFT_VERSION: "5.0" |
| + MARKETING_VERSION: "1.0" |
| + CURRENT_PROJECT_VERSION: "1" |
| + TARGETED_DEVICE_FAMILY: "1,2" |
| +targets: |
| + LChat: |
| + type: application |
| + platform: iOS |
| + sources: |
| + - path: LChat |
| + settings: |
| + base: |
| + PRODUCT_BUNDLE_IDENTIFIER: com.lchat.client |
| + CODE_SIGN_STYLE: Automatic |
| + # Also build a real Mac app from the same code via Mac Catalyst. |
| + SUPPORTS_MACCATALYST: YES |
| + DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER: NO |
| + CODE_SIGN_ENTITLEMENTS: LChat/LChat.entitlements |
| + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon |
| + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor |
| + info: |
| + path: LChat/Info.plist |
| + properties: |
| + CFBundleDisplayName: lchat |
| + UILaunchScreen: {} |
| + UISupportedInterfaceOrientations: |
| + - UIInterfaceOrientationPortrait |
| + UISupportedInterfaceOrientations~ipad: |
| + - UIInterfaceOrientationPortrait |
| + - UIInterfaceOrientationPortraitUpsideDown |
| + - UIInterfaceOrientationLandscapeLeft |
| + - UIInterfaceOrientationLandscapeRight |
| + # E2E instances are served over HTTPS in production. This single |
| + # exception only loosens ATS for loopback/.local addresses so you can |
| + # test against a `go run .` instance during development. |
| + NSAppTransportSecurity: |
| + NSAllowsLocalNetworking: true |