eleven / messenger Public

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>
Paul Campbell 299c6a23b33f9cfac705b845326efd670772cc13 parent a9071a4
7 files changed, +89 −22
  • changelog/2026-07-05-server-rail-images.md +1 −0
  • ios/LChat/LChatApp.swift +29 −0
  • ios/LChat/Views/Avatar.swift +6 −2
  • ios/LChat/Views/InstanceSidebar.swift +1 −1
  • ios/LChat/Views/ThreadListView.swift +1 −1
  • ios/LChatMac/LChatMacApp.swift +4 −0
  • ios/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,