eleven / ocho Public

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>
Paul Campbell f1d101e0ff4dfc40db13b48e38792dcfe140111a parent ef0d8d3
16 files changed, +349 −153
  • ios/LChat/AvatarLoader.swift +0 −48
  • ios/LChat/LChatApp.swift +1 −1
  • ios/LChat/Views/Avatar.swift +11 −6
  • ios/LChat/Views/NativeChatScreen.swift +68 −26
  • ios/LChat/Views/ThreadListCollection.swift +8 −3
  • ios/LChatCore/Sources/LChatCore/AppChatProjection.swift +9 −3
  • ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift +91 −36
  • ios/LChatCore/Sources/LChatCore/ImageProcessing.swift +52 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +33 −5
  • ios/LChatCore/Sources/LChatCore/RichText.swift +5 −1
  • ios/LChatCore/Sources/LChatCore/SchemaAppStore.swift +37 −16
  • ios/LChatCore/Sources/LChatCore/SchemaEngine.swift +10 −3
  • ios/LChatCore/Sources/LChatCore/TypeaheadStrip.swift +3 −2
  • ios/LChatMac/NativeChatView.swift +3 −2
  • ios/LChatMac/NativeFeedView.swift +17 −0
  • ios/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 {