eleven / messenger Public

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>
Paul Campbell f7ef52ddcd48651665efa4d165b5dae6222b26b2 parent af2a0f7
8 files changed, +151 −13
  • changelog/2026-07-04-native-avatar-photos.md +5 −0
  • ios/LChat/AvatarLoader.swift +58 −0
  • ios/LChat/Models.swift +14 −0
  • ios/LChat/ThreadStore.swift +41 −1
  • ios/LChat/Views/Avatar.swift +29 −8
  • ios/LChat/Views/ChatView.swift +1 −1
  • ios/LChat/Views/NewChatView.swift +2 −2
  • ios/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)