ios,mac: show server photos in the switcher (#199)
The server switcher now shows each instance's public server photo when one is set, instead of only its coloured initials: - Mac: the rail (Sidebar/ServerIcon) renders the photo clipped to the same circle→squircle shape, keeping the selection pill, hover morph and drag-reorder. - iOS: the server drawer rows and the top-left server button show it too. Both fetch it via the public /api/group (which reports whether a photo exists) and /api/server-avatar — no key needed (it's plaintext branding, not an E2E blob), and the bytes are only fetched when one is set. The shared Avatar view gains an optional ready `photo:` that wins over the initials; the E2E AvatarSource/AvatarLoader path is untouched.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7 files changed,
+89
−22
changelog/2026-07-05-server-rail-images.md+1 −0ios/LChat/LChatApp.swift+29 −0ios/LChat/Views/Avatar.swift+6 −2ios/LChat/Views/InstanceSidebar.swift+1 −1ios/LChat/Views/ThreadListView.swift+1 −1ios/LChatMac/LChatMacApp.swift+4 −0ios/LChatMac/Sidebar.swift+47 −18
diff --git a/changelog/2026-07-05-server-rail-images.md b/changelog/2026-07-05-server-rail-images.md| new file mode 100644 |
| index 0000000..4182248 |
| --- /dev/null |
| +++ b/changelog/2026-07-05-server-rail-images.md |
| @@ -0,0 +1 @@ |
| +The server switcher now shows each server's photo when one is set, instead of just its coloured initials — in the Mac app's rail and in the iOS server drawer (and on the iOS server button). |
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 3c80026..8fa74c0 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -1,4 +1,5 @@ |
| import SwiftUI |
| +import UIKit |
| @main |
| struct LChatApp: App { |
| @@ -17,9 +18,34 @@ struct LChatApp: App { |
| // keypair; the list lives in the Keychain (InstancesStore). |
| 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. |
| + @Published var serverAvatars: [String: UIImage] = [:] |
| init() { instances = InstancesStore.load() } |
| + /// Fetch each server's photo for the switcher. /api/group is public and reports |
| + /// whether a photo is set, so this works with no key and only fetches the bytes |
| + /// when one exists (and only when we don't already have it). Cheap + idempotent — |
| + /// safe to call whenever the drawer appears or the instance set changes. |
| + @MainActor |
| + func loadServerAvatars() async { |
| + for inst in instances { |
| + guard let base = inst.baseURL else { continue } |
| + let api = LChatAPI(baseURL: base, token: inst.deviceToken) |
| + let info = await api.serverInfo() |
| + if info.hasAvatar { |
| + if serverAvatars[inst.id] == nil, let data = await api.serverAvatar(), |
| + let image = UIImage(data: data) { |
| + serverAvatars[inst.id] = image |
| + } |
| + } else if serverAvatars[inst.id] != nil { |
| + serverAvatars[inst.id] = nil // photo removed on the server — drop back to initials |
| + } |
| + } |
| + } |
| + |
| /// Add a new instance, or replace one with the same id (re-join). |
| func add(_ inst: SavedInstance) { |
| if let i = instances.firstIndex(where: { $0.id == inst.id }) { |
| @@ -126,6 +152,9 @@ struct RootView: View { |
| } |
| } |
| .overlay { drawer } |
| + // Load server photos once at launch (and when the server set changes) so both |
| + // the toolbar server button and the drawer show them without opening anything. |
| + .task(id: Set(app.instances.map(\.id))) { await app.loadServerAvatars() } |
| .simultaneousGesture(drawerDragGesture) |
| // Flips edgeTouch the moment a finger lands in the drawer's opening zone, |
| // so the list is already scroll-locked before it can move. A UIKit probe |
diff --git a/ios/LChat/Views/Avatar.swift b/ios/LChat/Views/Avatar.swift| index da07ad5..2ab5901 100644 |
| --- a/ios/LChat/Views/Avatar.swift |
| +++ b/ios/LChat/Views/Avatar.swift |
| @@ -9,13 +9,17 @@ 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. |
| + var photo: UIImage? = nil |
| @State private var image: UIImage? |
| var body: some View { |
| ZStack { |
| - if let image { |
| - Image(uiImage: image).resizable().scaledToFill() |
| + if let shown = photo ?? image { |
| + Image(uiImage: shown).resizable().scaledToFill() |
| } else { |
| Circle().fill(color) |
| Text(initial) |
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift| index 37e104d..30f49e2 100644 |
| --- a/ios/LChat/Views/InstanceSidebar.swift |
| +++ b/ios/LChat/Views/InstanceSidebar.swift |
| @@ -49,7 +49,7 @@ struct ServerDrawer: View { |
| private func row(_ inst: SavedInstance) -> some View { |
| Button { onSelect(inst) } label: { |
| HStack(spacing: 12) { |
| - Avatar(name: inst.displayName, size: 36) |
| + Avatar(name: inst.displayName, size: 36, photo: app.serverAvatars[inst.id]) |
| VStack(alignment: .leading, spacing: 2) { |
| Text(inst.displayName).font(.headline).lineLimit(1) |
| // The host, unless the title already IS the host (no group |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index 466227e..ea8df39 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -56,7 +56,7 @@ struct ThreadListView: View { |
| .toolbar { |
| ToolbarItem(placement: .topBarLeading) { |
| Button { showServers = true } label: { |
| - Avatar(name: serverTitle, size: 30) |
| + Avatar(name: serverTitle, size: 30, photo: app.serverAvatars[store.instance.id]) |
| } |
| .accessibilityLabel("Servers") |
| } |
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift| index d9133d0..d6f3de0 100644 |
| --- a/ios/LChatMac/LChatMacApp.swift |
| +++ b/ios/LChatMac/LChatMacApp.swift |
| @@ -110,6 +110,10 @@ final class MacAppState: ObservableObject { |
| @Published var showPalette = false |
| // Fetched group names (chat id -> name), shared by the rail and the ⌘K palette. |
| @Published var names: [String: String] = [:] |
| + // Fetched per-instance server photos (chat id -> image). Public branding served |
| + // in the clear at /api/server-avatar; the rail shows it instead of the initial |
| + // when set. Only populated for instances whose /api/group reports one. |
| + @Published var avatars: [String: NSImage] = [:] |
| // Chat ids, most-recently-selected first — drives the ⌘K palette order. The rail |
| // keeps its own manual (drag-reorderable) order in `chats`. |
| @Published var recent: [String] = [] |
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift| index 431097f..24bda06 100644 |
| --- a/ios/LChatMac/Sidebar.swift |
| +++ b/ios/LChatMac/Sidebar.swift |
| @@ -1,9 +1,11 @@ |
| import SwiftUI |
| +import AppKit |
| import UniformTypeIdentifiers |
| // The native sidebar as a Discord/Slack-style server rail: one circular icon per |
| -// saved instance (an initial for now; server images come later), selected icon |
| -// morphs to a squircle with a leading pill indicator, and a trailing "+" adds one. |
| +// saved instance — its server photo when set, else a coloured initial — the |
| +// selected icon morphs to a squircle with a leading pill indicator, and a trailing |
| +// "+" adds one. |
| // Threads *within* an instance are still handled by the web UI — this rail is the |
| // native "which instance" switch. Hovering an icon publishes its name + position |
| // via RailTooltipKey, which RootView draws as an instant flyout to the right. |
| @@ -18,6 +20,7 @@ struct Sidebar: View { |
| ForEach(app.chats) { chat in |
| ServerIcon( |
| initial: inits[chat.id] ?? "?", |
| + image: app.avatars[chat.id], |
| hue: app.hue(for: chat.id), |
| selected: app.selection == chat.id, |
| tooltip: app.serverName(chat) |
| @@ -66,15 +69,26 @@ struct Sidebar: View { |
| .railTooltip("Add a chat") |
| } |
| - // Group-name lookup for the icon initial + tooltip. /api/group is public (no |
| - // token) — the instance name is server-known metadata, not E2EE content — so |
| - // this works for new-format chats too (where `session` is nil), and gives the |
| - // friendly name instead of the host. Falls back to the host until it loads. |
| + // Per-instance branding for the rail: the friendly name (icon initial + tooltip) |
| + // and the server photo. /api/group is public (no token) — name + whether a photo |
| + // is set are server-known metadata, not E2EE content — so this works for |
| + // new-format chats too (where `session` is nil). Falls back to the host until it |
| + // loads. Only fetches the photo bytes when /api/group says one exists, and skips |
| + // instances whose photo we already have. |
| private func loadNames() async { |
| for chat in app.chats { |
| guard let base = chat.origin else { continue } |
| - let name = await LChatAPI(baseURL: base, token: chat.session?.token ?? "").groupName() |
| - if !name.isEmpty { app.names[chat.id] = name } |
| + let api = LChatAPI(baseURL: base, token: chat.session?.token ?? "") |
| + let info = await api.serverInfo() |
| + if !info.name.isEmpty { app.names[chat.id] = info.name } |
| + if info.hasAvatar { |
| + if app.avatars[chat.id] == nil, let data = await api.serverAvatar(), |
| + let image = NSImage(data: data) { |
| + app.avatars[chat.id] = image |
| + } |
| + } else { |
| + app.avatars[chat.id] = nil // photo removed on the server — drop back to the initial |
| + } |
| } |
| } |
| } |
| @@ -83,26 +97,41 @@ struct Sidebar: View { |
| // squircle and grows a leading pill indicator, the way Slack/Discord rails do. |
| private struct ServerIcon: View { |
| let initial: String |
| + var image: NSImage? = nil |
| let hue: Double |
| let selected: Bool |
| let tooltip: String |
| let action: () -> Void |
| @State private var hovered = false |
| + // Circle at rest, morphing toward a squircle on hover/selection — the icon and |
| + // (when present) the server photo share this shape so the photo clips to it. |
| + private var shape: RoundedRectangle { |
| + RoundedRectangle(cornerRadius: selected || hovered ? 15 : 23, style: .continuous) |
| + } |
| + |
| var body: some View { |
| // A plain tappable view — NOT a Button — so the rail's `.onDrag` actually |
| // starts a drag. On macOS a Button's press gesture swallows the drag, which |
| // is why drag-to-reorder didn't work while this was a Button. |
| - Text(initial) |
| - .font(.title3.weight(.semibold)) |
| - .lineLimit(1) |
| - .minimumScaleFactor(0.6) |
| - .foregroundStyle(.white) |
| - .frame(width: 46, height: 46) |
| - .background( |
| - RoundedRectangle(cornerRadius: selected || hovered ? 15 : 23, style: .continuous) |
| - .fill(Color(hue: hue, saturation: 0.55, brightness: 0.78)) |
| - ) |
| + Group { |
| + if let image { |
| + Image(nsImage: image) |
| + .resizable() |
| + .interpolation(.high) |
| + .scaledToFill() |
| + .frame(width: 46, height: 46) |
| + .clipShape(shape) |
| + } else { |
| + Text(initial) |
| + .font(.title3.weight(.semibold)) |
| + .lineLimit(1) |
| + .minimumScaleFactor(0.6) |
| + .foregroundStyle(.white) |
| + .frame(width: 46, height: 46) |
| + .background(shape.fill(Color(hue: hue, saturation: 0.55, brightness: 0.78))) |
| + } |
| + } |
| .frame(maxWidth: .infinity) |
| .overlay(alignment: .leading) { |
| // Discord-style selection pill: flush against the rail's left edge, |