ios+mac: never decode or parse on the main thread — feed images decode off-main, downsampled to their display class (#571)
The 1-2s can't-scroll freeze opening an image-heavy chat was the deferred UIImage bitmap decode: blobs were fetched + decrypted off-main, but UIImage(data:) postpones the actual JPEG->bitmap work to first draw, on the main thread, at the full 2048px upload size — for boxes that render at 260pt. - decodeImageForDisplay (ImageProcessing): ImageIO thumbnail decode with kCGImageSourceShouldCacheImmediately, downsampled to the display class (feed 1024px / avatar 256px), forced on the loader's detached task. - BlobImageLoader: size-classed cache keys, pixel-correct cost accounting (points under-counted 9x on 3x screens), purge on memory pressure. - Lightbox opens with the feed thumbnail instantly and swaps in a full-fidelity decode when it lands (iOS viewer + Mac lightbox). - iOS AvatarLoader folded into BlobImageLoader at the avatar class. - SchemaAppStore.load: snapshot open + per-row decrypt/parse/fold moved off the main actor (the same fix chat's foldPageDetached already had). - Per-frame main-thread costs cached or deferred: firstLink + hoverTime memoised, youtubeId's per-call regex compiled once, times-peek overlay built only mid-drag, shared absTime DateFormatters, thread-list separator handler no longer copies the snapshot per row (O(n^2)). - Stage/share paths read files + build previews detached, not on main.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
16 files changed,
+349
−153
ios/LChat/AvatarLoader.swift+0 −48ios/LChat/LChatApp.swift+1 −1ios/LChat/Views/Avatar.swift+11 −6ios/LChat/Views/NativeChatScreen.swift+68 −26ios/LChat/Views/ThreadListCollection.swift+8 −3ios/LChatCore/Sources/LChatCore/AppChatProjection.swift+9 −3ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift+91 −36ios/LChatCore/Sources/LChatCore/ImageProcessing.swift+52 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+33 −5ios/LChatCore/Sources/LChatCore/RichText.swift+5 −1ios/LChatCore/Sources/LChatCore/SchemaAppStore.swift+37 −16ios/LChatCore/Sources/LChatCore/SchemaEngine.swift+10 −3ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift+3 −2ios/LChatMac/NativeChatView.swift+3 −2ios/LChatMac/NativeFeedView.swift+17 −0ios/LChatShare/ShareViewController.swift+1 −1
diff --git a/ios/LChat/AvatarLoader.swift b/ios/LChat/AvatarLoader.swift| deleted file mode 100644 |
| index 7dcca0a..0000000 |
| --- a/ios/LChat/AvatarLoader.swift |
| +++ /dev/null |
| @@ -1,48 +0,0 @@ |
| -import LChatCore |
| -import UIKit |
| - |
| -// Fetches + decrypts a profile/group photo blob into a UIImage for the native |
| -// avatar circles, caching by blob id so scrolling and re-renders don't refetch. |
| -// The native mirror of avatarFor / threadAvatarFor in web/static/chat.js: GET |
| -// /api/blob/<id> (opaque ciphertext), then AES-GCM open with the instance key (a |
| -// member's photo) or the thread key (a group photo). The decrypt happens |
| -// on-device — the server never sees a key or the plaintext image. |
| -@MainActor |
| -final class AvatarLoader { |
| - static let shared = AvatarLoader() |
| - |
| - private var cache: [String: UIImage] = [:] // blobID -> decoded image |
| - private var inFlight: [String: Task<UIImage?, Never>] = [:] |
| - private let session = URLSession(configuration: .default) |
| - |
| - /// A photo already decoded in memory, if any — lets a view show it with no |
| - /// flash back to initials on re-render. |
| - func cached(_ blobID: String) -> UIImage? { cache[blobID] } |
| - |
| - /// The decrypted photo for a source, fetched + decoded once and cached. |
| - /// Concurrent callers for the same blob share a single fetch. Returns nil on |
| - /// any failure (missing blob, wrong/absent key, undecodable image); the caller |
| - /// then just keeps the initials. |
| - func image(_ source: AvatarSource) async -> UIImage? { |
| - if let img = cache[source.blobID] { return img } |
| - if let task = inFlight[source.blobID] { return await task.value } |
| - |
| - let session = self.session |
| - // Detached so the fetch + AES-GCM open + image decode stay off the main |
| - // thread; only the cache bookkeeping below runs on the main actor. |
| - let task = Task.detached { () -> UIImage? in |
| - guard let url = source.url, |
| - let (data, resp) = try? await session.data(from: url), |
| - let http = resp as? HTTPURLResponse, http.statusCode == 200, |
| - let key = try? GroupKey(base64url: source.keyB64), |
| - let plain = try? key.decryptBytes(data), |
| - let img = UIImage(data: plain) else { return nil } |
| - return img |
| - } |
| - inFlight[source.blobID] = task |
| - let img = await task.value |
| - inFlight.removeValue(forKey: source.blobID) |
| - if let img { cache[source.blobID] = img } |
| - return img |
| - } |
| -} |
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 8b4d2f2..c06234d 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -30,7 +30,7 @@ final class AppState: ObservableObject { |
| @Published var instances: [SavedInstance] |
| // Per-instance server photos (id -> image) shown in the server switcher. Public |
| // branding served in the clear at /api/server-avatar (not an E2E blob), so it's |
| - // fetched directly here rather than through AvatarLoader. |
| + // fetched directly here rather than through BlobImageLoader. |
| @Published var serverAvatars: [String: UIImage] = [:] |
| init() { instances = InstancesStore.load() } |
diff --git a/ios/LChat/Views/Avatar.swift b/ios/LChat/Views/Avatar.swift| index 0b03b1c..66a3586 100644 |
| --- a/ios/LChat/Views/Avatar.swift |
| +++ b/ios/LChat/Views/Avatar.swift |
| @@ -3,16 +3,17 @@ import SwiftUI |
| // A circle for a chat or person. Shows initials over a name-hashed colour by |
| // default; when handed an AvatarSource it fetches + decrypts that profile/group |
| -// photo (via AvatarLoader) and swaps it in once it loads — mirroring paintAvatar |
| -// in web/static/chat.js (initials first, the real photo when it resolves). The |
| -// encrypted-blob fetch keeps E2E intact: the server only ever serves ciphertext. |
| +// photo (via BlobImageLoader, decoded off-main at avatar size) and swaps it in |
| +// once it loads — mirroring paintAvatar in web/static/chat.js (initials first, |
| +// the real photo when it resolves). The encrypted-blob fetch keeps E2E intact: |
| +// the server only ever serves ciphertext. |
| struct Avatar: View { |
| let name: String |
| var size: CGFloat = 40 |
| var source: AvatarSource? = nil |
| // A ready, already-decoded image to show directly — e.g. an instance's plaintext |
| // server photo (/api/server-avatar), which isn't an E2E blob so it doesn't go |
| - // through AvatarSource/AvatarLoader. Wins over the fetched `source`/initials. |
| + // through AvatarSource/BlobImageLoader. Wins over the fetched `source`/initials. |
| var photo: UIImage? = nil |
| // An app-thread's emoji glyph (⏰, ✓, …) — replaces the initial. |
| var glyph: String? = nil |
| @@ -60,9 +61,13 @@ struct Avatar: View { |
| private func load() async { |
| guard let source else { image = nil; return } |
| // A decoded photo shows instantly; otherwise fall to initials and fetch. |
| - if let cached = AvatarLoader.shared.cached(source.blobID) { image = cached; return } |
| + if let cached = BlobImageLoader.shared.cached(source.blobID, |
| + maxPixel: avatarImageMaxPixel) { |
| + image = cached |
| + return |
| + } |
| image = nil |
| - let img = await AvatarLoader.shared.image(source) |
| + let img = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel) |
| // The row may have been recycled onto another chat while we awaited; the |
| // id-keyed task is cancelled in that case, so don't stomp the new photo. |
| if !Task.isCancelled { image = img } |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 49a9c5a..eda409b 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -351,19 +351,25 @@ struct NativeChatScreen: View { |
| title: thread.title) |
| // Share-sheet handoff: stage each shared file as an upload |
| // (encrypt + upload run exactly like a picked photo), then |
| - // burn the inbox copies. |
| + // burn the inbox copies. The reads run detached — a shared |
| + // video is whole-file IO, and this fires during the chat-open |
| + // layout crunch. |
| if let share = Self.pendingShare, share.thread == thread.id { |
| Self.pendingShare = nil |
| - for url in share.files { |
| - guard let data = try? Data(contentsOf: url) else { continue } |
| - let ext = url.pathExtension.lowercased() |
| - if ["mp4", "mov", "m4v"].contains(ext) { |
| - stageVideoData(data, name: url.lastPathComponent) |
| - } else { |
| - stageImageData(data, name: url.lastPathComponent) |
| + Task { |
| + for url in share.files { |
| + guard let data = await Task.detached(operation: { |
| + try? Data(contentsOf: url) |
| + }).value else { continue } |
| + let ext = url.pathExtension.lowercased() |
| + if ["mp4", "mov", "m4v"].contains(ext) { |
| + stageVideoData(data, name: url.lastPathComponent) |
| + } else { |
| + stageImageData(data, name: url.lastPathComponent) |
| + } |
| } |
| + ShareInbox.clear(share.files) |
| } |
| - ShareInbox.clear(share.files) |
| } |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { |
| withAnimation(.spring(response: 0.42, dampingFraction: 0.45)) { avatarPop = true } |
| @@ -392,6 +398,19 @@ struct NativeChatScreen: View { |
| withTransaction(t, clear) |
| } |
| + // The feed hands the lightbox its downsampled thumbnail so the zoom opens |
| + // instantly; the full-fidelity decode (off-main, cached under its own size |
| + // class) swaps in underneath once it lands — unless the viewer has already |
| + // moved on to another photo or closed. |
| + private func upgradeViewerImage(_ att: Attachment) { |
| + guard let source = model.blobSource(att) else { return } |
| + Task { |
| + guard let full = await BlobImageLoader.shared.image(source), |
| + viewerImage?.id == att.id else { return } |
| + viewerImage = IdentifiedImage(id: att.id, image: full) |
| + } |
| + } |
| + |
| // ---- feed -------------------------------------------------------------- |
| // Once the feed has landed, arriving messages spring up from beneath the |
| @@ -719,6 +738,7 @@ struct NativeChatScreen: View { |
| blobSource: { model.blobSource($0) }, |
| onImageTap: { att, image, _ in |
| viewerImage = IdentifiedImage(id: att.id, image: image) |
| + upgradeViewerImage(att) |
| }, |
| onVideoTap: { att, _ in playVideo(att) }, |
| downloading: videoDownload, |
| @@ -775,16 +795,23 @@ struct NativeChatScreen: View { |
| } |
| // The times peek (Messages): dragging left slides a time in at |
| // the trailing edge — landing INSIDE the viewport with a |
| - // margin — springing back on release. |
| + // margin — springing back on release. Built only while a drag |
| + // is revealing it: chatTime's ICU formatting for every row on |
| + // every body eval is pure waste the other 99% of the time. |
| .overlay(alignment: .trailing) { |
| - Text(chatTime(m.at)) |
| - .font(.system(size: 11)) |
| - .foregroundStyle(.secondary) |
| - .fixedSize() |
| - .padding(.trailing, 10) |
| - .offset(x: max(0, Self.timesGutter - timesReveal)) |
| - .opacity(timesReveal / Self.timesGutter) |
| - .allowsHitTesting(false) |
| + if timesReveal > 0 { |
| + Text(chatTime(m.at)) |
| + .font(.system(size: 11)) |
| + .foregroundStyle(.secondary) |
| + .fixedSize() |
| + .padding(.trailing, 10) |
| + .offset(x: max(0, Self.timesGutter - timesReveal)) |
| + .opacity(timesReveal / Self.timesGutter) |
| + .allowsHitTesting(false) |
| + // Removal rides the release spring, so letting go |
| + // still slides the times away instead of blinking. |
| + .transition(.move(edge: .trailing).combined(with: .opacity)) |
| + } |
| } |
| .simultaneousGesture(rowSwipe(m)) |
| .clipped() |
| @@ -820,6 +847,7 @@ struct NativeChatScreen: View { |
| blobSource: { model.blobSource($0) }, |
| onImageTap: { att, image, _ in |
| viewerImage = IdentifiedImage(id: att.id, image: image) |
| + upgradeViewerImage(att) |
| }, |
| onVideoTap: { att, _ in playVideo(att) }, |
| downloading: videoDownload, |
| @@ -1387,7 +1415,10 @@ struct NativeChatScreen: View { |
| staged.removeAll { $0.id == entry.id } |
| return |
| } |
| - if !isVideo, let thumb = processImage(data, maxDim: 288), |
| + // The composer-chip preview downscale is a full decode — detached, |
| + // like the upload-side processImage in ChatFeedModel. |
| + if !isVideo, |
| + let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value, |
| let i = staged.firstIndex(where: { $0.id == entry.id }) { |
| staged[i].preview = UIImage(data: thumb.bytes) |
| } |
| @@ -1404,15 +1435,18 @@ struct NativeChatScreen: View { |
| // Stage raw image bytes (paste / share-sheet handoff) as an upload. |
| private func stageImageData(_ data: Data, name: String = "pasted.jpg") { |
| - var entry = StagedUpload(name: name) |
| - if let thumb = processImage(data, maxDim: 288) { |
| - entry.preview = UIImage(data: thumb.bytes) |
| - } |
| + let entry = StagedUpload(name: name) |
| staged.append(entry) |
| let onProgress: @MainActor (Double) -> Void = { p in |
| if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p } |
| } |
| Task { |
| + // The chip shows name-only for a beat while the preview downscale |
| + // (a full decode of the pasted image) runs detached. |
| + if let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value, |
| + let i = staged.firstIndex(where: { $0.id == entry.id }) { |
| + staged[i].preview = UIImage(data: thumb.bytes) |
| + } |
| let att = await model.uploadImage(data, name: name, onProgress: onProgress) |
| if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) { |
| staged[i].attachment = att |
| @@ -1440,9 +1474,6 @@ struct NativeChatScreen: View { |
| } |
| private func stageFile(_ url: URL) { |
| - guard url.startAccessingSecurityScopedResource() else { return } |
| - defer { url.stopAccessingSecurityScopedResource() } |
| - guard let data = try? Data(contentsOf: url) else { return } |
| let name = url.lastPathComponent |
| let entry = StagedUpload(name: name) |
| staged.append(entry) |
| @@ -1451,6 +1482,17 @@ struct NativeChatScreen: View { |
| if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p } |
| } |
| Task { |
| + // Whole-file read detached — a picked file can be tens of MB, and |
| + // the security scope only needs to bracket the read itself. |
| + let data = await Task.detached { () -> Data? in |
| + guard url.startAccessingSecurityScopedResource() else { return nil } |
| + defer { url.stopAccessingSecurityScopedResource() } |
| + return try? Data(contentsOf: url) |
| + }.value |
| + guard let data else { |
| + staged.removeAll { $0.id == entry.id } |
| + return |
| + } |
| let att = await model.uploadFile(data, name: name, mimeType: type, onProgress: onProgress) |
| if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) { |
| staged[i].attachment = att |
diff --git a/ios/LChat/Views/ThreadListCollection.swift b/ios/LChat/Views/ThreadListCollection.swift| index 08ef48d..530c4be 100644 |
| --- a/ios/LChat/Views/ThreadListCollection.swift |
| +++ b/ios/LChat/Views/ThreadListCollection.swift |
| @@ -43,6 +43,8 @@ final class ThreadListController: UICollectionViewController { |
| private var latestThreads: [ChatThread] = [] |
| private var dataSource: UICollectionViewDiffableDataSource<Section, String>! |
| private var populated = false |
| + // Mirrors the applied snapshot's row count for the separator handler. |
| + private var itemCount = 0 |
| init() { |
| // A placeholder list layout; viewDidLoad swaps in the real one, which needs |
| @@ -87,12 +89,14 @@ final class ThreadListController: UICollectionViewController { |
| return UISwipeActionsConfiguration(actions: [toggle]) |
| } |
| // Separators run only *between* rows: no line above the first, none below the |
| - // last (a plain list would otherwise cap both ends). |
| + // last (a plain list would otherwise cap both ends). The count comes from |
| + // the var apply() maintains — dataSource.snapshot() COPIES the whole |
| + // snapshot, and this handler runs per row, so reading it here was O(n²) |
| + // across a layout pass. |
| config.itemSeparatorHandler = { [weak self] indexPath, proposed in |
| var sep = proposed |
| if indexPath.item == 0 { sep.topSeparatorVisibility = .hidden } |
| - if let n = self?.dataSource.snapshot().numberOfItems(inSection: .main), |
| - indexPath.item == n - 1 { |
| + if indexPath.item == (self?.itemCount ?? 0) - 1 { |
| sep.bottomSeparatorVisibility = .hidden |
| } |
| return sep |
| @@ -149,6 +153,7 @@ final class ThreadListController: UICollectionViewController { |
| let existing = Set(threadsByID.keys) |
| threadsByID = Dictionary(threads.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) |
| let ids = threads.map(\.id) |
| + itemCount = ids.count |
| var snap = NSDiffableDataSourceSnapshot<Section, String>() |
| snap.appendSections([.main]) |
| snap.appendItems(ids, toSection: .main) |
diff --git a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift| index 38998ef..930f3e0 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift |
| @@ -65,10 +65,16 @@ public enum AppChatProjection { |
| return GroupApps.title(app) |
| } |
| - // remind.js absTime: "Sat 4 Jul at 23:16". |
| - static func absTime(_ ms: Double) -> String { |
| + // remind.js absTime: "Sat 4 Jul at 23:16". One shared formatter — |
| + // DateFormatter allocation is the expensive part, and formatting through |
| + // an existing one is thread-safe on every OS we ship. |
| + private static let absTimeFormatter: DateFormatter = { |
| let f = DateFormatter() |
| f.dateFormat = "EEE d MMM 'at' HH:mm" |
| - return f.string(from: Date(timeIntervalSince1970: ms / 1000)) |
| + return f |
| + }() |
| + |
| + static func absTime(_ ms: Double) -> String { |
| + absTimeFormatter.string(from: Date(timeIntervalSince1970: ms / 1000)) |
| } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift b/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift| index e7557ab..2826d7f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift |
| @@ -3,42 +3,87 @@ import SwiftUI |
| // Fetches + decrypts an encrypted image blob (an attachment photo, a profile or |
| // group avatar) into a PlatformImage, caching by blob id so scrolling and |
| -// re-renders don't refetch. The generalisation of the iOS AvatarLoader (which |
| -// folds into this when iOS adopts the native feed): GET /api/blob/<id> returns |
| -// opaque `iv||ct||tag` bytes, opened on-device with the AES key carried in the |
| -// AvatarSource — the instance key for a member photo, the thread key for a |
| -// group photo or a message attachment. The server never sees a key or a pixel. |
| +// re-renders don't refetch. GET /api/blob/<id> returns opaque `iv||ct||tag` |
| +// bytes, opened on-device with the AES key carried in the AvatarSource — the |
| +// instance key for a member photo, the thread key for a group photo or a |
| +// message attachment. The server never sees a key or a pixel. |
| +// |
| +// Everything expensive — the fetch, the AES-GCM open, and crucially the |
| +// bitmap DECODE (decodeImageForDisplay) — runs detached, so a photo is |
| +// render-ready before the main thread ever touches it. `maxPixel` picks the |
| +// decode class (feedImageMaxPixel / avatarImageMaxPixel / nil for full) and |
| +// keys the cache, so a feed thumbnail and the lightbox's full-fidelity pass |
| +// coexist without evicting each other. |
| @MainActor |
| public final class BlobImageLoader { |
| public static let shared = BlobImageLoader() |
| - private var cache: [String: PlatformImage] = [:] // blobID -> decoded image |
| + private var cache: [String: PlatformImage] = [:] // key -> decoded image |
| private var order: [String] = [] // LRU, oldest first |
| - private var totalCost = 0 // decoded bytes (w*h*4) |
| + private var totalCost = 0 // decoded bytes (px*4) |
| private let maxCost = 96 << 20 // ~96 MB of pixels |
| private var inFlight: [String: Task<PlatformImage?, Never>] = [:] |
| private let session = URLSession(configuration: .default) |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + private var pressureSource: DispatchSourceMemoryPressure? |
| + #endif |
| - /// A blob already decoded in memory, if any — lets a view render it with no |
| - /// placeholder flash on re-render. |
| - public func cached(_ blobID: String) -> PlatformImage? { |
| - if let img = cache[blobID] { touch(blobID); return img } |
| + private init() { |
| + // Under memory pressure the cache is the first thing to give back — |
| + // visible rows keep their own references, everything else re-decodes |
| + // on demand. (In an app extension the UIKit notification never fires; |
| + // the extension's own tight memory cap is the real limiter there.) |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + let source = DispatchSource.makeMemoryPressureSource(eventMask: [.warning, .critical], |
| + queue: .main) |
| + source.setEventHandler { [weak self] in |
| + MainActor.assumeIsolated { self?.purge() } |
| + } |
| + source.resume() |
| + pressureSource = source |
| + #else |
| + // The raw name rather than UIApplication.didReceiveMemoryWarningNotification |
| + // keeps LChatCore free of app-only UIKit API (the NSE and share |
| + // extension link this package). |
| + NotificationCenter.default.addObserver( |
| + forName: Notification.Name("UIApplicationDidReceiveMemoryWarningNotification"), |
| + object: nil, queue: .main |
| + ) { [weak self] _ in |
| + MainActor.assumeIsolated { self?.purge() } |
| + } |
| + #endif |
| + } |
| + |
| + private func purge() { |
| + cache.removeAll() |
| + order.removeAll() |
| + totalCost = 0 |
| + } |
| + |
| + private static func key(_ blobID: String, _ maxPixel: Int?) -> String { |
| + maxPixel.map { "\(blobID)@\($0)" } ?? blobID |
| + } |
| + |
| + /// A blob already decoded in memory at this size class, if any — lets a |
| + /// view render it with no placeholder flash on re-render. |
| + public func cached(_ blobID: String, maxPixel: Int? = nil) -> PlatformImage? { |
| + let key = Self.key(blobID, maxPixel) |
| + if let img = cache[key] { touch(key); return img } |
| return nil |
| } |
| - // Cost-capped LRU, like BlobDataCache next door: full-size photos decode to |
| - // ~16 MB each, so an uncapped dictionary is a slow-motion jetsam kill on a |
| - // photo-heavy scroll-back. |
| - private func touch(_ blobID: String) { |
| - order.removeAll { $0 == blobID } |
| - order.append(blobID) |
| + // Cost-capped LRU, like BlobDataCache next door: an uncapped dictionary is |
| + // a slow-motion jetsam kill on a photo-heavy scroll-back. |
| + private func touch(_ key: String) { |
| + order.removeAll { $0 == key } |
| + order.append(key) |
| } |
| - private func store(_ img: PlatformImage, for blobID: String) { |
| - cache[blobID] = img |
| - touch(blobID) |
| + private func store(_ img: PlatformImage, for key: String) { |
| + cache[key] = img |
| + touch(key) |
| totalCost += Self.cost(img) |
| - while totalCost > maxCost, let oldest = order.first, oldest != blobID { |
| + while totalCost > maxCost, let oldest = order.first, oldest != key { |
| order.removeFirst() |
| if let evicted = cache.removeValue(forKey: oldest) { |
| totalCost -= Self.cost(evicted) |
| @@ -46,34 +91,44 @@ public final class BlobImageLoader { |
| } |
| } |
| + // Decoded bitmap cost in PIXELS — size is in points, and a 3x image |
| + // counted in points under-counts 9-fold, which silently turned the 96 MB |
| + // cap into ~860 MB of real pixels. |
| private static func cost(_ img: PlatformImage) -> Int { |
| - Int(img.size.width * img.size.height) * 4 |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + let px = img.representations.first.map { $0.pixelsWide * $0.pixelsHigh } |
| + ?? Int(img.size.width * img.size.height) |
| + return px * 4 |
| + #else |
| + return Int(img.size.width * img.scale * img.size.height * img.scale) * 4 |
| + #endif |
| } |
| /// The decrypted image for a source, fetched + decoded once and cached. |
| - /// Concurrent callers for the same blob share a single fetch. Returns nil on |
| - /// any failure (missing blob, wrong/absent key, undecodable image); the |
| - /// caller keeps its placeholder. |
| - public func image(_ source: AvatarSource) async -> PlatformImage? { |
| - if let img = cache[source.blobID] { touch(source.blobID); return img } |
| - if let task = inFlight[source.blobID] { return await task.value } |
| + /// Concurrent callers for the same blob+class share a single fetch. |
| + /// Returns nil on any failure (missing blob, wrong/absent key, undecodable |
| + /// image); the caller keeps its placeholder. |
| + public func image(_ source: AvatarSource, maxPixel: Int? = nil) async -> PlatformImage? { |
| + let key = Self.key(source.blobID, maxPixel) |
| + if let img = cache[key] { touch(key); return img } |
| + if let task = inFlight[key] { return await task.value } |
| let session = self.session |
| - // Detached so the fetch + AES-GCM open + image decode stay off the main |
| - // thread; only the cache bookkeeping below runs on the main actor. |
| + // Detached so the fetch + AES-GCM open + bitmap decode stay off the |
| + // main thread; only the cache bookkeeping below runs on the main actor. |
| let task = Task.detached { () -> PlatformImage? in |
| guard let url = source.url, |
| let (data, resp) = try? await session.data(from: url), |
| let http = resp as? HTTPURLResponse, http.statusCode == 200, |
| - let key = try? GroupKey(base64url: source.keyB64), |
| - let plain = try? key.decryptBytes(data), |
| - let img = PlatformImage(data: plain) else { return nil } |
| + let groupKey = try? GroupKey(base64url: source.keyB64), |
| + let plain = try? groupKey.decryptBytes(data), |
| + let img = decodeImageForDisplay(plain, maxPixel: maxPixel) else { return nil } |
| return img |
| } |
| - inFlight[source.blobID] = task |
| + inFlight[key] = task |
| let img = await task.value |
| - inFlight.removeValue(forKey: source.blobID) |
| - if let img { store(img, for: source.blobID) } |
| + inFlight.removeValue(forKey: key) |
| + if let img { store(img, for: key) } |
| return img |
| } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/ImageProcessing.swift b/ios/LChatCore/Sources/LChatCore/ImageProcessing.swift| index 227a85c..47bb04e 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ImageProcessing.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ImageProcessing.swift |
| @@ -1,6 +1,11 @@ |
| import Foundation |
| import ImageIO |
| import UniformTypeIdentifiers |
| +#if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| +import AppKit |
| +#else |
| +import UIKit |
| +#endif |
| // The native twin of shell.js processImage: cap the long edge at 2048, re-encode |
| // as JPEG 0.85. The re-encode is the privacy step — a fresh CGImageDestination |
| @@ -37,3 +42,50 @@ public func jpegData(from image: CGImage, quality: Double = 0.85) -> Data? { |
| guard CGImageDestinationFinalize(dest) else { return nil } |
| return out as Data |
| } |
| + |
| +// ---- display-side decode ---------------------------------------------------- |
| + |
| +/// Long-edge pixel caps for the two display classes a decoded blob can land |
| +/// in: a feed photo/poster box tops out at 320pt (~960px @3x), an avatar |
| +/// circle at 54pt. Decoding to the class instead of the full 2048px upload |
| +/// cuts a photo's bitmap from ~13 MB to ~3 MB — and cuts the decode time the |
| +/// same way. |
| +public let feedImageMaxPixel = 1024 |
| +public let avatarImageMaxPixel = 256 |
| + |
| +/// Decode image bytes into a bitmap that is ready to draw. UIImage(data:) / |
| +/// NSImage(data:) don't decode — they defer the JPEG→bitmap work to the first |
| +/// draw, which lands on the MAIN thread as the row scrolls in (the 1-2s |
| +/// can't-scroll freeze on image-heavy chats). Call this off-main instead: |
| +/// kCGImageSourceShouldCacheImmediately forces the decode here and now, on the |
| +/// caller's thread, downsampled to `maxPixel` on the long edge. |
| +/// |
| +/// Pass nil for a full-fidelity decode (the lightbox's upgrade pass, Mac |
| +/// animated GIFs that need NSImage's multi-frame rep) — still forced eagerly |
| +/// on iOS via preparingForDisplay so no path defers to first draw. |
| +public func decodeImageForDisplay(_ data: Data, maxPixel: Int?) -> PlatformImage? { |
| + guard let maxPixel else { |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + return NSImage(data: data) |
| + #else |
| + guard let img = UIImage(data: data) else { return nil } |
| + return img.preparingForDisplay() ?? img |
| + #endif |
| + } |
| + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } |
| + let options: [CFString: Any] = [ |
| + kCGImageSourceCreateThumbnailFromImageAlways: true, |
| + kCGImageSourceCreateThumbnailWithTransform: true, // bake in EXIF orientation |
| + kCGImageSourceShouldCacheImmediately: true, // decode NOW, on this thread |
| + kCGImageSourceThumbnailMaxPixelSize: maxPixel, |
| + ] |
| + guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { |
| + // ImageIO couldn't thumbnail it (rare) — the platform decoder gets a try. |
| + return PlatformImage(data: data) |
| + } |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height)) |
| + #else |
| + return UIImage(cgImage: cg) |
| + #endif |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index ee9484a..9e9bfdf 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -196,7 +196,7 @@ public struct MessageBubble: View { |
| } |
| // The web's precedence (chat.js): a YouTube link gets the |
| // click-to-play card; any other link gets the unfurl preview. |
| - if let link = firstLink(message.text) { |
| + if let link = Self.cachedFirstLink(message.text) { |
| if let yt = youtubeId(link) { |
| YouTubeCard(id: yt.id, start: yt.start, onPlay: onYouTube) |
| } else if let unfurl { |
| @@ -479,9 +479,16 @@ public struct MessageBubble: View { |
| } |
| private var hoverTime: String { |
| - Date(timeIntervalSince1970: TimeInterval(message.at)) |
| + if let hit = Self.hoverTimeMemo[message.at] { return hit } |
| + if Self.hoverTimeMemo.count > 600 { Self.hoverTimeMemo.removeAll() } |
| + let built = Date(timeIntervalSince1970: TimeInterval(message.at)) |
| .formatted(date: .abbreviated, time: .shortened) |
| + Self.hoverTimeMemo[message.at] = built |
| + return built |
| } |
| + // .formatted goes through ICU — cheap once, real money when every visible |
| + // bubble re-evaluates its body on each model publish. |
| + @MainActor private static var hoverTimeMemo: [Int64: String] = [:] |
| // A block's text with the web's inline formatting applied — richTokens |
| // (RichText.swift) mirrors format.js exactly, so **bold**, _italics_, |
| @@ -501,6 +508,19 @@ public struct MessageBubble: View { |
| return parsed |
| } |
| + // firstLink re-runs the block + URL regex scan from scratch — memoised for |
| + // the same reason as the parses above: it's called from every non-card |
| + // bubble's body, on every publish and every row materialisation. |
| + @MainActor private static var linkMemo: [String: URL?] = [:] |
| + |
| + @MainActor static func cachedFirstLink(_ text: String) -> URL? { |
| + if let hit = linkMemo[text] { return hit } |
| + if linkMemo.count > 600 { linkMemo.removeAll() } |
| + let link = firstLink(text) |
| + linkMemo[text] = link |
| + return link |
| + } |
| + |
| private func richText(_ text: String) -> AttributedString { |
| // The built runs bake in \.chatFontSize, so it's part of the key — a |
| // ⌘+ / Text Size change must rebuild, not replay the old size. |
| @@ -748,7 +768,8 @@ public struct MessageBubble: View { |
| .onTapGesture { onTap?(attachment, globalFrame) } |
| .task(id: attachment.id) { |
| guard poster == nil, let posterSource else { return } |
| - poster = await BlobImageLoader.shared.image(posterSource) |
| + poster = await BlobImageLoader.shared.image(posterSource, |
| + maxPixel: feedImageMaxPixel) |
| } |
| .help(attachment.name ?? "video") |
| } |
| @@ -823,7 +844,14 @@ public struct MessageBubble: View { |
| } |
| .task(id: attachment.id) { |
| guard image == nil, let source else { return } |
| - if let hit = BlobImageLoader.shared.cached(source.blobID) { |
| + // The feed decodes downsampled to its box class; a Mac |
| + // animated GIF keeps the full multi-frame NSImage so it plays. |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + let px: Int? = attachment.isGIF ? nil : feedImageMaxPixel |
| + #else |
| + let px: Int? = feedImageMaxPixel |
| + #endif |
| + if let hit = BlobImageLoader.shared.cached(source.blobID, maxPixel: px) { |
| image = hit // instant — no fade for cached rematerialisation |
| return |
| } |
| @@ -837,7 +865,7 @@ public struct MessageBubble: View { |
| blur = PlatformImage(cgImage: cg) |
| #endif |
| } |
| - guard let loaded = await BlobImageLoader.shared.image(source) else { return } |
| + guard let loaded = await BlobImageLoader.shared.image(source, maxPixel: px) else { return } |
| withAnimation(.easeOut(duration: 0.18)) { image = loaded } |
| } |
| .help(attachment.name ?? "photo") |
diff --git a/ios/LChatCore/Sources/LChatCore/RichText.swift b/ios/LChatCore/Sources/LChatCore/RichText.swift| index 319d4ac..30b981a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/RichText.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/RichText.swift |
| @@ -219,6 +219,10 @@ public func firstLink(_ text: String) -> URL? { |
| return nil |
| } |
| +// Compiled once — youtubeId runs from every link-bearing bubble's body, and |
| +// NSRegularExpression construction is the expensive half. |
| +private let ytStartRE = try! NSRegularExpression(pattern: "^(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+)s)?$") |
| + |
| /// format.js youtubeId: parse a YouTube URL into (id, start seconds), or nil |
| /// for anything else. Covers the shapes people paste: watch?v=, youtu.be/, |
| /// /shorts/, /live/, /embed/, /v/, with t= / start= offsets (90, 90s, 1h2m3s). |
| @@ -247,7 +251,7 @@ public func youtubeId(_ url: URL) -> (id: String, start: Int)? { |
| var start = 0 |
| if t.range(of: "^\\d+s?$", options: .regularExpression) != nil { |
| start = Int(t.hasSuffix("s") ? String(t.dropLast()) : t) ?? 0 |
| - } else if let m = try? NSRegularExpression(pattern: "^(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+)s)?$") |
| + } else if let m = ytStartRE |
| .firstMatch(in: t, range: NSRange(location: 0, length: (t as NSString).length)) { |
| let ns = t as NSString |
| func group(_ i: Int) -> Int { |
diff --git a/ios/LChatCore/Sources/LChatCore/SchemaAppStore.swift b/ios/LChatCore/Sources/LChatCore/SchemaAppStore.swift| index eb856c0..a382bc1 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SchemaAppStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SchemaAppStore.swift |
| @@ -90,26 +90,42 @@ public final class SchemaAppStore: ObservableObject { |
| // while this pass computes a fresh state. |
| loading = true |
| seededUpTo = 0 |
| - var next = SchemaEngine.initialState(schema) |
| // Seed from the encrypted snapshot so the list is complete even when |
| // old events have fallen out of the message window (1,000 todos work |
| // as well as 10 — Memory, apps/README.md). |
| - if let snap = await api.threadState(thread: thread.id, slot: "apps"), !snap.blob.isEmpty, |
| - let data = try? key.decryptData(blob: snap.blob), |
| - let json = AppJSON.parse(data) { |
| - let seeded = json["states"][appId] |
| - if case .object = seeded { |
| - next = seeded |
| - seededUpTo = Int64(SchemaEngine.num(json["upTo"])) |
| - } |
| - } |
| + let snap = await api.threadState(thread: thread.id, slot: "apps") |
| let rows = await api.messages(thread: thread.id, after: 0) |
| - for row in rows where row.id > seededUpTo { |
| - if let event = decryptEvent(row.blob) { |
| - next = SchemaEngine.fold(schema, state: next, event: event) |
| - maxFolded = max(maxFolded, row.id) |
| + // The snapshot open + a decrypt/parse/fold per history row is the same |
| + // work chat's foldPageDetached moved off the main actor — on it, an |
| + // app-thread with a full window froze the UI on open exactly the way |
| + // chats used to. Only the publish tail below touches main. |
| + let schema = self.schema |
| + let appId = self.appId |
| + let folded = await Task.detached(priority: .userInitiated) { |
| + () -> (state: AppJSON, seededUpTo: Int64, maxFolded: Int64) in |
| + var next = SchemaEngine.initialState(schema) |
| + var seededUpTo: Int64 = 0 |
| + if let snap, !snap.blob.isEmpty, |
| + let data = try? key.decryptData(blob: snap.blob), |
| + let json = AppJSON.parse(data) { |
| + let seeded = json["states"][appId] |
| + if case .object = seeded { |
| + next = seeded |
| + seededUpTo = Int64(SchemaEngine.num(json["upTo"])) |
| + } |
| } |
| - } |
| + var maxFolded: Int64 = 0 |
| + for row in rows where row.id > seededUpTo { |
| + if let event = Self.decryptEvent(key: key, blob: row.blob) { |
| + next = SchemaEngine.fold(schema, state: next, event: event) |
| + maxFolded = max(maxFolded, row.id) |
| + } |
| + } |
| + return (next, seededUpTo, maxFolded) |
| + }.value |
| + seededUpTo = folded.seededUpTo |
| + maxFolded = max(maxFolded, folded.maxFolded) |
| + var next = folded.state |
| // Anything that arrived live while we were loading folds on top (the |
| // fetch may already contain it — reducers are idempotent by id). |
| for event in pendingLive { |
| @@ -121,7 +137,12 @@ public final class SchemaAppStore: ObservableObject { |
| } |
| private func decryptEvent(_ blob: String) -> AppJSON? { |
| - guard let key, let data = try? key.decryptData(blob: blob), |
| + guard let key else { return nil } |
| + return Self.decryptEvent(key: key, blob: blob) |
| + } |
| + |
| + private nonisolated static func decryptEvent(key: GroupKey, blob: String) -> AppJSON? { |
| + guard let data = try? key.decryptData(blob: blob), |
| let json = AppJSON.parse(data), case .object = json else { return nil } |
| // Chat payloads (and anything without a type this schema owns) are not |
| // app events; fold() itself ignores unknown types, this just skips the |
diff --git a/ios/LChatCore/Sources/LChatCore/SchemaEngine.swift b/ios/LChatCore/Sources/LChatCore/SchemaEngine.swift| index 100fc34..7b542f5 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SchemaEngine.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SchemaEngine.swift |
| @@ -205,11 +205,18 @@ public enum SchemaEngine { |
| } |
| /// The absolute form ("Sat 4 Jul at 23:16") — the same shape the native |
| - /// chat projection already uses (AppChatProjection.absTime). |
| - public static func absTime(_ ms: Double) -> String { |
| + /// chat projection already uses (AppChatProjection.absTime). One shared |
| + /// formatter: allocation is the expensive part, and formatting through an |
| + /// existing DateFormatter is thread-safe on every OS we ship (folds run |
| + /// detached). |
| + private static let absTimeFormatter: DateFormatter = { |
| let f = DateFormatter() |
| f.dateFormat = "EEE d MMM 'at' HH:mm" |
| - return f.string(from: Date(timeIntervalSince1970: ms / 1000)) |
| + return f |
| + }() |
| + |
| + public static func absTime(_ ms: Double) -> String { |
| + absTimeFormatter.string(from: Date(timeIntervalSince1970: ms / 1000)) |
| } |
| static func applyFilter(_ v: AppJSON, _ filter: String, _ scope: Scope) -> String { |
diff --git a/ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift b/ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift| index 6fd8840..b9d836e 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift |
| @@ -101,11 +101,12 @@ public struct TypeaheadStrip: View { |
| .clipShape(Circle()) |
| .task(id: source?.blobID) { |
| guard let source else { return } |
| - if let hit = BlobImageLoader.shared.cached(source.blobID) { |
| + if let hit = BlobImageLoader.shared.cached(source.blobID, |
| + maxPixel: avatarImageMaxPixel) { |
| image = hit |
| return |
| } |
| - image = await BlobImageLoader.shared.image(source) |
| + image = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel) |
| } |
| } |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index fb5cbff..7c1c2f4 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -1228,11 +1228,12 @@ struct NativeChatView: View { |
| } |
| .task(id: source?.blobID) { |
| guard let source else { return } |
| - if let hit = BlobImageLoader.shared.cached(source.blobID) { |
| + if let hit = BlobImageLoader.shared.cached(source.blobID, |
| + maxPixel: avatarImageMaxPixel) { |
| image = hit |
| return |
| } |
| - image = await BlobImageLoader.shared.image(source) |
| + image = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel) |
| } |
| } |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index ead36d8..a76eb98 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -602,6 +602,7 @@ struct NativeFeedView: View { |
| onImageTap: { att, image, frame in |
| app.lightbox = LightboxItem(id: att.id, name: att.name, |
| image: image, sourceFrame: frame) |
| + upgradeLightbox(att, frame: frame) |
| }, |
| onFileTap: { att in download(att) }, |
| onVideoTap: { att, frame in playVideo(att, from: frame) }, |
| @@ -1212,6 +1213,21 @@ struct NativeFeedView: View { |
| row: { m, showSender in threadBubble(m, showSender: showSender) }) |
| } |
| + // The feed hands the lightbox its downsampled thumbnail so the zoom opens |
| + // instantly; the full-fidelity decode (off-main, cached under its own size |
| + // class) swaps in underneath once it lands — unless the lightbox has |
| + // already moved on or closed. (Animated GIFs decode full in the feed, so |
| + // the swap is a cache hit and a no-op.) |
| + private func upgradeLightbox(_ att: Attachment, frame: CGRect) { |
| + guard let source = model.blobSource(att) else { return } |
| + Task { |
| + guard let full = await BlobImageLoader.shared.image(source), |
| + app.lightbox?.id == att.id else { return } |
| + app.lightbox = LightboxItem(id: att.id, name: att.name, |
| + image: full, sourceFrame: frame) |
| + } |
| + } |
| + |
| private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View { |
| MessageBubble(message: m, showSender: showSender, |
| theme: theme, |
| @@ -1219,6 +1235,7 @@ struct NativeFeedView: View { |
| onImageTap: { att, image, frame in |
| app.lightbox = LightboxItem(id: att.id, name: att.name, |
| image: image, sourceFrame: frame) |
| + upgradeLightbox(att, frame: frame) |
| }, |
| onFileTap: { att in download(att) }, |
| onVideoTap: { att, frame in playVideo(att, from: frame) }, |
diff --git a/ios/LChatShare/ShareViewController.swift b/ios/LChatShare/ShareViewController.swift| index 7f91d4b..407bd19 100644 |
| --- a/ios/LChatShare/ShareViewController.swift |
| +++ b/ios/LChatShare/ShareViewController.swift |
| @@ -290,7 +290,7 @@ struct ShareAvatar: View { |
| .clipShape(Circle()) |
| .task(id: (source?.blobID ?? "") + (serverOrigin ?? "")) { |
| if let source { |
| - image = await BlobImageLoader.shared.image(source) |
| + image = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel) |
| } else if let serverOrigin, let url = URL(string: serverOrigin + "/api/server-avatar") { |
| if let (data, resp) = try? await URLSession.shared.data(from: url), |
| (resp as? HTTPURLResponse)?.statusCode == 200 { |