ios: show profile & group photos in native avatar circles (#177)
The native Avatar circle now fetches + decrypts the profile/group photo blob and shows it — in the chat list, the chat header, and the new-chat pickers — falling back to the coloured initials when there's no photo or we lack the key. Previously only the embedded web feed rendered photos; every native circle was initials-only. Mirrors avatarFor / threadAvatarFor in web/static/chat.js: DM/self circles decrypt with the instance key, group circles with the thread key. The fetch is the same token-gated GET /api/blob/<id> the web uses, decrypted on-device via the existing GroupKey helper — the server still only ever serves ciphertext. Server-branding (instance) circles are unchanged (a separate, unencrypted source).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8 files changed,
+151
−13
changelog/2026-07-04-native-avatar-photos.md+5 −0ios/LChat/AvatarLoader.swift+58 −0ios/LChat/Models.swift+14 −0ios/LChat/ThreadStore.swift+41 −1ios/LChat/Views/Avatar.swift+29 −8ios/LChat/Views/ChatView.swift+1 −1ios/LChat/Views/NewChatView.swift+2 −2ios/LChat/Views/ThreadRow.swift+1 −1
diff --git a/changelog/2026-07-04-native-avatar-photos.md b/changelog/2026-07-04-native-avatar-photos.md| new file mode 100644 |
| index 0000000..2756c28 |
| --- /dev/null |
| +++ b/changelog/2026-07-04-native-avatar-photos.md |
| @@ -0,0 +1,5 @@ |
| +Profile and group photos now show up in the app's chat list, chat headers, and |
| +the new-chat picker — not just inside an open conversation. Where someone has |
| +set a photo you'll see it in their circle; everyone else keeps the coloured |
| +initials. As always the photos are decrypted on your device, so the server only |
| +ever holds the scrambled version. |
diff --git a/ios/LChat/AvatarLoader.swift b/ios/LChat/AvatarLoader.swift| new file mode 100644 |
| index 0000000..3f06a81 |
| --- /dev/null |
| +++ b/ios/LChat/AvatarLoader.swift |
| @@ -0,0 +1,58 @@ |
| +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 = Self.blobURL(source), |
| + 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 |
| + } |
| + |
| + // /api/blob/<id>?token=… — blob ids are base64url (URL-safe), so appending |
| + // them as a path component needs no extra escaping. Token-gated like every |
| + // data endpoint, exactly as the web client fetches it. |
| + nonisolated private static func blobURL(_ s: AvatarSource) -> URL? { |
| + var comps = URLComponents( |
| + url: s.baseURL.appending(path: "api/blob").appending(path: s.blobID), |
| + resolvingAgainstBaseURL: false) |
| + comps?.queryItems = [URLQueryItem(name: "token", value: s.token)] |
| + return comps?.url |
| + } |
| +} |
diff --git a/ios/LChat/Models.swift b/ios/LChat/Models.swift| index 3f576fa..4ed8b00 100644 |
| --- a/ios/LChat/Models.swift |
| +++ b/ios/LChat/Models.swift |
| @@ -91,6 +91,19 @@ struct GroupNamePayload: Codable { var name: String? } |
| // ---- sidebar view model ------------------------------------------------- |
| +// Where a native avatar circle fetches its photo: the encrypted blob id plus the |
| +// AES key to open it — the instance key for a member's profile photo, the thread |
| +// key for a group photo — and the transport bits to reach /api/blob. Pure data |
| +// (no UIKit) so it can ride in the shared model; AvatarLoader turns it into an |
| +// image. Mirrors avatarFor / threadAvatarFor in web/static/chat.js — the server |
| +// only ever serves ciphertext, and the key never leaves the device. |
| +struct AvatarSource: Equatable { |
| + var blobID: String |
| + var keyB64: String |
| + var baseURL: URL |
| + var token: String |
| +} |
| + |
| // One row in the native thread sidebar — fully decrypted, ready to render. |
| struct Thread: Identifiable, Equatable { |
| let id: String |
| @@ -103,6 +116,7 @@ struct Thread: Identifiable, Equatable { |
| var hidden: Bool // user "closed" this chat: dropped from the list, still findable in search |
| var members: [String] |
| var threadKeyB64: String // unwrapped — handed to the webview to render the feed |
| + var avatar: AvatarSource? // the person's / group's photo, if one is set + we hold the key |
| } |
| // A one-line snippet for the sidebar: "Name: text" in groups, just text in DMs — |
diff --git a/ios/LChat/ThreadStore.swift b/ios/LChat/ThreadStore.swift| index 3b9b766..1f4049e 100644 |
| --- a/ios/LChat/ThreadStore.swift |
| +++ b/ios/LChat/ThreadStore.swift |
| @@ -34,6 +34,39 @@ final class ThreadStore: ObservableObject { |
| /// member profile photos. nil until loaded / if we haven't been granted it. |
| func instanceKey() -> String? { instanceKeyB64 } |
| + /// The photo source for a roster member — their profile photo (instance-key |
| + /// encrypted), or nil if they have none / we lack the key or address. For the |
| + /// new-chat picker. Mirrors avatarFor in web/static/chat.js. |
| + func avatarSource(for entry: RosterEntry) -> AvatarSource? { |
| + memberAvatar(entry.avatar) |
| + } |
| + |
| + // A member's profile photo (instance key), from their blob id. Shared by the |
| + // picker and the DM/self thread rows built in refresh(). |
| + private func memberAvatar(_ blobID: String?) -> AvatarSource? { |
| + guard let blobID, !blobID.isEmpty, let key = instanceKeyB64, |
| + let base = instance.baseURL else { return nil } |
| + return AvatarSource(blobID: blobID, keyB64: key, baseURL: base, token: instance.deviceToken) |
| + } |
| + |
| + // A thread row's avatar: a group's own photo (thread key) or, for a DM/self |
| + // chat, the person's profile photo (instance key) — the web list's |
| + // group→threadAvatarFor, dm/self→avatarFor split (web/static/chat.js). |
| + private func avatarSource(kind: String, threadAvatar: String?, threadKey: Data, |
| + members: [String], rosterAvatars: [String: String]) -> AvatarSource? { |
| + switch kind { |
| + case "group": |
| + guard let blob = threadAvatar, !blob.isEmpty, let base = instance.baseURL else { return nil } |
| + return AvatarSource(blobID: blob, keyB64: threadKey.base64urlEncodedString(), |
| + baseURL: base, token: instance.deviceToken) |
| + case "self": |
| + return memberAvatar(rosterAvatars[myUserId]) |
| + default: // dm |
| + let other = members.first { $0 != myUserId } ?? "" |
| + return memberAvatar(rosterAvatars[other]) |
| + } |
| + } |
| + |
| func refresh() async { |
| guard let api else { loadError = "This instance's address is invalid."; return } |
| loading = true |
| @@ -67,6 +100,11 @@ final class ThreadStore: ObservableObject { |
| } |
| roster = people |
| rosterLabels = Dictionary(people.map { ($0.user_id, $0.label) }, uniquingKeysWith: { a, _ in a }) |
| + // Member profile-photo blob ids, for DM/self avatars (decrypted under the |
| + // instance key, like the labels above). Groups use their own thread key. |
| + let rosterAvatars = Dictionary( |
| + people.compactMap { e in e.avatar.map { (e.user_id, $0) } }, |
| + uniquingKeysWith: { a, _ in a }) |
| instanceName = await api.groupName() |
| @@ -107,7 +145,9 @@ final class ThreadStore: ObservableObject { |
| pinned: t.pinned ?? false, |
| hidden: t.hidden ?? false, |
| members: t.members ?? [], |
| - threadKeyB64: raw.base64urlEncodedString() |
| + threadKeyB64: raw.base64urlEncodedString(), |
| + avatar: avatarSource(kind: t.kind, threadAvatar: t.avatar, threadKey: raw, |
| + members: t.members ?? [], rosterAvatars: rosterAvatars) |
| )) |
| } |
| // Pinned chats first, then most-recent activity — mirrors the web list. |
diff --git a/ios/LChat/Views/Avatar.swift b/ios/LChat/Views/Avatar.swift| index 80c8f04..da07ad5 100644 |
| --- a/ios/LChat/Views/Avatar.swift |
| +++ b/ios/LChat/Views/Avatar.swift |
| @@ -1,21 +1,42 @@ |
| import SwiftUI |
| -// A simple initials circle. The native sidebar uses initials rather than loading |
| -// the encrypted profile-photo blobs — the feed webview already renders real avatars |
| -// inside a thread; the sidebar just needs a stable, recognisable glyph per chat. |
| +// 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. |
| struct Avatar: View { |
| let name: String |
| var size: CGFloat = 40 |
| + var source: AvatarSource? = nil |
| + |
| + @State private var image: UIImage? |
| var body: some View { |
| - Circle() |
| - .fill(color) |
| - .frame(width: size, height: size) |
| - .overlay( |
| + ZStack { |
| + if let image { |
| + Image(uiImage: image).resizable().scaledToFill() |
| + } else { |
| + Circle().fill(color) |
| Text(initial) |
| .font(.system(size: size * 0.42, weight: .semibold)) |
| .foregroundStyle(.white) |
| - ) |
| + } |
| + } |
| + .frame(width: size, height: size) |
| + .clipShape(Circle()) |
| + .task(id: source) { await load() } |
| + } |
| + |
| + 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 } |
| + image = nil |
| + let img = await AvatarLoader.shared.image(source) |
| + // 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 } |
| } |
| private var initial: String { |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index 3a2e623..66ce995 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -73,7 +73,7 @@ struct ChatView: View { |
| // pill's material + explicit colour keep the name legible over any message. |
| private var chatHeader: some View { |
| VStack(spacing: -5) { |
| - Avatar(name: thread.title, size: 54) |
| + Avatar(name: thread.title, size: 54, source: thread.avatar) |
| .scaleEffect(avatarPop ? 1 : 0.7) |
| .zIndex(1) // sit on top where it overlaps the pill |
| Text(thread.title) |
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift| index fec8d29..f633a17 100644 |
| --- a/ios/LChat/Views/NewChatView.swift |
| +++ b/ios/LChat/Views/NewChatView.swift |
| @@ -64,7 +64,7 @@ struct NewChatView: View { |
| private func personRow(_ person: RosterEntry) -> some View { |
| Button { startDM(person) } label: { |
| HStack(spacing: 12) { |
| - Avatar(name: person.label, size: 36) |
| + Avatar(name: person.label, size: 36, source: store.avatarSource(for: person)) |
| Text(person.label).foregroundStyle(.primary) |
| if person.isAgent { BotBadge() } |
| Spacer() |
| @@ -113,7 +113,7 @@ struct NewGroupView: View { |
| ForEach(others, id: \.user_id) { person in |
| Button { toggle(person.user_id) } label: { |
| HStack(spacing: 12) { |
| - Avatar(name: person.label, size: 32) |
| + Avatar(name: person.label, size: 32, source: store.avatarSource(for: person)) |
| Text(person.label).foregroundStyle(.primary) |
| if person.isAgent { BotBadge() } |
| Spacer() |
diff --git a/ios/LChat/Views/ThreadRow.swift b/ios/LChat/Views/ThreadRow.swift| index e0cb2af..06de619 100644 |
| --- a/ios/LChat/Views/ThreadRow.swift |
| +++ b/ios/LChat/Views/ThreadRow.swift |
| @@ -8,7 +8,7 @@ struct ThreadRow: View { |
| var body: some View { |
| HStack(spacing: 12) { |
| - Avatar(name: thread.title, size: 44) |
| + Avatar(name: thread.title, size: 44, source: thread.avatar) |
| VStack(alignment: .leading, spacing: 2) { |
| HStack { |
| Text(thread.title).font(.headline).lineLimit(1) |