eleven / messenger Public

ios+mac: the native icon half — SF Symbols + brand marks for app identity (#848)

The native side of docs/icons.md: AppIdentity (LChatCore) maps each
first-party appId to its SF Symbol (the table's names, kept in step
with the web's Lucide glyphs) and each integration app to its vendored
brand mark — the same monochrome SVGs as web/static/logos.js,
rasterized to 96px template PNGs in LChatCore/Marks and tinted at
render like a symbol.

Both platforms' avatar ladders grow mark/symbol steps (photo -> mark ->
symbol -> glyph -> initials): the iOS thread list (whose main rows
never showed app identity at all), search and share rows, the group-app
picker; the Mac sidebar, collapsed rail, feed header, search rows, the
New-chat popover's app rows, and the cmd-K palette (NativePaletteChat
carries appId through now). GroupApps' todo symbol aligns to
checkmark.square per the table.

Emoji stays content, never chrome: member-made apps and bots keep their
chosen glyphs (the glyph remains every unmapped app's fallback), and
the old dense-grid "▦" placeholder — the "weird Linear icon" — is gone,
replaced by square.grid.2x2 for genuinely unknown apps.

No changelog: native entries ride the next release mint (CLAUDE.md).
Verified: Mac Debug build, iOS simulator build, LChatCore swift test
(277 passed).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 95ae9ffacf058e7f6075ae31dc1392c937333f93 parent 6a455eb
18 files changed, +160 −24
  • docs/icons.md +10 −4
  • ios/LChat/Views/Avatar.swift +18 −0
  • ios/LChat/Views/NewChatView.swift +3 −2
  • ios/LChat/Views/ThreadListView.swift +5 −3
  • ios/LChat/Views/ThreadRow.swift +8 −1
  • ios/LChatCore/Package.swift +1 −1
  • ios/LChatCore/Sources/LChatCore/AppIdentity.swift +69 −0
  • ios/LChatCore/Sources/LChatCore/GroupApps.swift +1 −1
  • ios/LChatCore/Sources/LChatCore/Marks/dnsimple.png binary
  • ios/LChatCore/Sources/LChatCore/Marks/github.png binary
  • ios/LChatCore/Sources/LChatCore/Marks/hubspot.png binary
  • ios/LChatCore/Sources/LChatCore/Marks/linear.png binary
  • ios/LChatCore/Sources/LChatCore/Marks/plug.png binary
  • ios/LChatCore/Sources/LChatCore/Marks/stripe.png binary
  • ios/LChatMac/LChatMacApp.swift +1 −0
  • ios/LChatMac/NativeChatView.swift +27 −7
  • ios/LChatMac/NewChatPopover.swift +5 −2
  • ios/LChatMac/RootView.swift +12 −3
diff --git a/docs/icons.md b/docs/icons.md
index eb1d083..8779617 100644
--- a/docs/icons.md
+++ b/docs/icons.md
@@ -71,7 +71,13 @@ itself (a capability, not a brand).
## Native status
-Chrome on iOS/Mac already leans on SF Symbols ad hoc; the owed work is
-aligning names to this table and carrying the brand marks + first-party app
-marks into the native pickers/rows (template-image assets). Track alongside
-the next native release.
+Built (`ios/LChatCore/Sources/LChatCore/AppIdentity.swift`): the appId →
+SF-Symbol map above plus the brand marks as vendored template PNGs in
+`LChatCore/Marks/` (the same SVGs as `logos.js`, rasterized
+black-on-transparent at 96px, tinted at render — regenerate with resvg,
+remembering the vendored strings need an `xmlns` added). Wired into both
+platforms' avatar ladders (photo → mark → symbol → glyph → initials): the
+iOS thread list/search/share rows, the Mac sidebar, rail, feed header,
+search, ⌘K palette, and both New-chat pickers. The old "▦" placeholder is
+gone — unmapped apps get `square.grid.2x2`. Device verify + the release
+changelog ride the next native release mint.
diff --git a/ios/LChat/Views/Avatar.swift b/ios/LChat/Views/Avatar.swift
index 66a3586..ab24862 100644
--- a/ios/LChat/Views/Avatar.swift
+++ b/ios/LChat/Views/Avatar.swift
@@ -17,6 +17,11 @@ struct Avatar: View {
var photo: UIImage? = nil
// An app-thread's emoji glyph (⏰, ✓, …) — replaces the initial.
var glyph: String? = nil
+ // The app's identity mark (docs/icons.md): an SF Symbol name for
+ // first-party apps, or a vendored brand template (GitHub, Linear, …).
+ // Either wins over the glyph — emoji is content, never chrome.
+ var symbol: String? = nil
+ var mark: UIImage? = nil
// Presence, iOS-style: a subtle green ring just outside the avatar edge.
// Mobile has no room for the web/Mac dot+word status line, so online is
// signalled here — and ONLY online; away/offline draw nothing (see
@@ -30,6 +35,19 @@ struct Avatar: View {
ZStack {
if let shown = photo ?? image {
Image(uiImage: shown).resizable().scaledToFill()
+ } else if let mark {
+ Circle().fill(.primary.opacity(0.08))
+ Image(uiImage: mark)
+ .renderingMode(.template)
+ .resizable()
+ .scaledToFit()
+ .frame(width: size * 0.5, height: size * 0.5)
+ .foregroundStyle(.primary)
+ } else if let symbol {
+ Circle().fill(.primary.opacity(0.08))
+ Image(systemName: symbol)
+ .font(.system(size: size * 0.42, weight: .medium))
+ .foregroundStyle(.primary)
} else if let glyph {
Circle().fill(.primary.opacity(0.08))
Text(glyph).font(.system(size: size * 0.5))
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift
index 0de7c2c..0d33b72 100644
--- a/ios/LChat/Views/NewChatView.swift
+++ b/ios/LChat/Views/NewChatView.swift
@@ -256,8 +256,9 @@ struct NewGroupView: View {
ForEach(GroupApps.catalog, id: \.id) { app in
Button { toggleApp(app.id) } label: {
HStack(spacing: 12) {
- Text(app.glyph)
- .font(.system(size: 15))
+ Image(systemName: app.symbol)
+ .font(.system(size: 14, weight: .medium))
+ .foregroundStyle(.primary)
.frame(width: 32, height: 32)
.background(Circle().fill(.primary.opacity(0.06)))
Text(app.title).foregroundStyle(.primary)
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 9151c82..67aa260 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -217,7 +217,7 @@ struct ThreadListView: View {
path.append(.thread(t.id))
} label: {
HStack(spacing: 10) {
- Avatar(name: t.title, size: 34, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph)
+ Avatar(name: t.title, size: 34, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph, symbol: AppIdentity.symbol(for: t.appId), mark: AppIdentity.mark(for: t.appId))
Text(t.title).font(.body.weight(.medium)).lineLimit(1)
if let badge = t.badge { AgentBadge(badge) }
}
@@ -286,7 +286,7 @@ struct ThreadListView: View {
path.append(.thread(t.id))
} label: {
HStack(spacing: 10) {
- Avatar(name: t.title, size: 34, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph)
+ Avatar(name: t.title, size: 34, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph, symbol: AppIdentity.symbol(for: t.appId), mark: AppIdentity.mark(for: t.appId))
Text(t.title).font(.body.weight(.medium)).lineLimit(1)
if let badge = t.badge { AgentBadge(badge) }
}
@@ -304,7 +304,9 @@ struct ThreadListView: View {
} label: {
HStack(spacing: 10) {
Avatar(name: hit.thread.title, size: 34,
- source: hit.thread.avatar, glyph: hit.thread.glyph)
+ source: hit.thread.avatar, glyph: hit.thread.glyph,
+ symbol: AppIdentity.symbol(for: hit.thread.appId),
+ mark: AppIdentity.mark(for: hit.thread.appId))
VStack(alignment: .leading, spacing: 1) {
Text(hit.thread.title)
.font(.subheadline.weight(.medium)).lineLimit(1)
diff --git a/ios/LChat/Views/ThreadRow.swift b/ios/LChat/Views/ThreadRow.swift
index ae6ce4c..9a42deb 100644
--- a/ios/LChat/Views/ThreadRow.swift
+++ b/ios/LChat/Views/ThreadRow.swift
@@ -9,7 +9,14 @@ struct ThreadRow: View {
var body: some View {
HStack(spacing: 12) {
- Avatar(name: thread.title, size: 44, source: thread.avatar)
+ // The main list finally shows app identity: Eleven's own threads
+ // wear the brand tile, apps their mark (docs/icons.md), and the
+ // emoji glyph stays the fallback for anything unmapped.
+ Avatar(name: thread.title, size: 44, source: thread.avatar,
+ photo: thread.isEleven ? ElevenBrand.icon : nil,
+ glyph: thread.glyph,
+ symbol: AppIdentity.symbol(for: thread.appId),
+ mark: AppIdentity.mark(for: thread.appId))
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(thread.title).font(.headline).lineLimit(1)
diff --git a/ios/LChatCore/Package.swift b/ios/LChatCore/Package.swift
index 688b823..2c880ab 100644
--- a/ios/LChatCore/Package.swift
+++ b/ios/LChatCore/Package.swift
@@ -17,7 +17,7 @@ let package = Package(
// Schemas/ carries the app-schema JSON (byte-identical copies of the
// canonical web/static/apps/schemas/, drift-gated by
// test/schema-engine.test.mjs).
- .target(name: "LChatCore", resources: [.copy("Flows"), .copy("Icons"), .copy("Schemas")]),
+ .target(name: "LChatCore", resources: [.copy("Flows"), .copy("Icons"), .copy("Schemas"), .copy("Marks")]),
// The cross-implementation drift gates (fixtures generated by the web
// crypto — see test/threadkey.test.mjs). `swift test` runs them natively
// on the Mac, no simulator needed.
diff --git a/ios/LChatCore/Sources/LChatCore/AppIdentity.swift b/ios/LChatCore/Sources/LChatCore/AppIdentity.swift
new file mode 100644
index 0000000..fd4a32a
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/AppIdentity.swift
@@ -0,0 +1,69 @@
+import Foundation
+
+// 🤖 AppIdentity — the native half of the icon system (docs/icons.md): which
+// mark an app wears in natively-rendered rows, headers, and pickers. Three
+// kinds, mirroring the web exactly:
+//
+// 1. First-party apps → an SF Symbol (the table in docs/icons.md keeps the
+// web's Lucide glyph and the SF name in step).
+// 2. Integration apps → the real brand mark, vendored as a monochrome
+// template PNG in Marks/ (brands have no SF Symbols; these are the same
+// SVGs web/static/logos.js carries, rasterized black-on-transparent so
+// the platform tints them like a symbol).
+// 3. Everything else → emoji stays CONTENT, never chrome: a member-made
+// app or bot keeps its chosen glyph, and unmapped apps get the generic
+// grid symbol rather than the old "▦" text placeholder.
+public enum AppIdentity {
+ // SF Symbol per first-party app id — one row per docs/icons.md entry.
+ // (welcome/setup are Eleven's own threads and wear ElevenBrand.icon; the
+ // chat feed app never fronts a thread.)
+ private static let symbols: [String: String] = [
+ "todo": "checkmark.square",
+ "remind": "alarm",
+ "bookmarks": "bookmark",
+ "poll": "chart.bar",
+ "board": "rectangle.on.rectangle",
+ "vault": "lock",
+ "emoji": "face.smiling",
+ "gif": "film",
+ "admin": "wrench.adjustable",
+ "appstudio": "puzzlepiece",
+ "memory": "brain",
+ ]
+
+ // Brand-marked apps — the file in Marks/ (all six also exist on web in
+ // logos.js; keep the two in step). The Integrations app ("connect") is a
+ // capability, not a brand — it wears the plug, like the web.
+ private static let marks: [String: String] = [
+ "github": "github",
+ "dnsimple": "dnsimple",
+ "stripe": "stripe",
+ "hubspot": "hubspot",
+ "linear": "linear",
+ "connect": "plug",
+ ]
+
+ /// The SF Symbol an app-thread's avatar renders, or nil when the app
+ /// wears a brand mark (see mark(for:)) or isn't an app at all. Unmapped
+ /// app ids get the generic grid — never the old "▦" text placeholder.
+ public static func symbol(for appId: String?) -> String? {
+ guard let id = appId else { return nil }
+ if let s = symbols[id] { return s }
+ if marks[id] != nil { return nil } // brand mark wins
+ return "square.grid.2x2"
+ }
+
+ /// The vendored brand mark for an integration app, template-tinted by the
+ /// caller. Cached — the PNGs are tiny and the rows re-render constantly.
+ public static func mark(for appId: String?) -> PlatformImage? {
+ guard let id = appId, let file = marks[id] else { return nil }
+ if let cached = cache[id] { return cached }
+ guard let url = Bundle.module.url(forResource: file, withExtension: "png", subdirectory: "Marks"),
+ let data = try? Data(contentsOf: url),
+ let image = PlatformImage(data: data) else { return nil }
+ cache[id] = image
+ return image
+ }
+
+ private nonisolated(unsafe) static var cache: [String: PlatformImage] = [:]
+}
diff --git a/ios/LChatCore/Sources/LChatCore/GroupApps.swift b/ios/LChatCore/Sources/LChatCore/GroupApps.swift
index a0e9114..1f48c03 100644
--- a/ios/LChatCore/Sources/LChatCore/GroupApps.swift
+++ b/ios/LChatCore/Sources/LChatCore/GroupApps.swift
@@ -42,7 +42,7 @@ public enum GroupApps {
/// is the SF-symbol twin for iOS menus, which drop any Label icon that
/// isn't a real image.
public static let catalog: [(id: String, title: String, glyph: String, symbol: String)] = [
- ("todo", "To-do", "✓", "checkmark"),
+ ("todo", "To-do", "✓", "checkmark.square"),
("remind", "Reminders", "⏰", "alarm"),
("bookmarks", "Bookmarks", "🔖", "bookmark"),
("vault", "Vault", "🔒", "lock"),
diff --git a/ios/LChatCore/Sources/LChatCore/Marks/dnsimple.png b/ios/LChatCore/Sources/LChatCore/Marks/dnsimple.png
new file mode 100644
index 0000000..767bb34
Binary files /dev/null and b/ios/LChatCore/Sources/LChatCore/Marks/dnsimple.png differ
diff --git a/ios/LChatCore/Sources/LChatCore/Marks/github.png b/ios/LChatCore/Sources/LChatCore/Marks/github.png
new file mode 100644
index 0000000..2b2453b
Binary files /dev/null and b/ios/LChatCore/Sources/LChatCore/Marks/github.png differ
diff --git a/ios/LChatCore/Sources/LChatCore/Marks/hubspot.png b/ios/LChatCore/Sources/LChatCore/Marks/hubspot.png
new file mode 100644
index 0000000..02a9978
Binary files /dev/null and b/ios/LChatCore/Sources/LChatCore/Marks/hubspot.png differ
diff --git a/ios/LChatCore/Sources/LChatCore/Marks/linear.png b/ios/LChatCore/Sources/LChatCore/Marks/linear.png
new file mode 100644
index 0000000..7dd91d7
Binary files /dev/null and b/ios/LChatCore/Sources/LChatCore/Marks/linear.png differ
diff --git a/ios/LChatCore/Sources/LChatCore/Marks/plug.png b/ios/LChatCore/Sources/LChatCore/Marks/plug.png
new file mode 100644
index 0000000..57183eb
Binary files /dev/null and b/ios/LChatCore/Sources/LChatCore/Marks/plug.png differ
diff --git a/ios/LChatCore/Sources/LChatCore/Marks/stripe.png b/ios/LChatCore/Sources/LChatCore/Marks/stripe.png
new file mode 100644
index 0000000..6521fbd
Binary files /dev/null and b/ios/LChatCore/Sources/LChatCore/Marks/stripe.png differ
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index a284f1d..2fc67ae 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -271,6 +271,7 @@ final class MacAppState: ObservableObject {
let title: String
let glyph: String?
let avatar: AvatarSource? // the chat's real photo, when we hold the key
+ var appId: String? = nil // set for app-threads — drives the identity mark
}
// Selecting a chat records it as most-recently-accessed (for the palette).
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 6f99002..538364b 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -271,7 +271,7 @@ struct NativeChatView: View {
// people/bots with no DM yet ("user:" rows create one on pick) and
// personal apps not yet started ("app:" rows likewise).
var items: [MacAppState.NativePaletteChat] = store.threads.map {
- .init(id: $0.id, title: $0.title, glyph: $0.glyph, avatar: $0.avatar)
+ .init(id: $0.id, title: $0.title, glyph: $0.glyph, avatar: $0.avatar, appId: $0.appId)
}
let dmPartners = Set(store.threads.filter { $0.kind == "dm" }.flatMap(\.members))
items += store.roster
@@ -282,7 +282,7 @@ struct NativeChatView: View {
let openApps = Set(store.threads.compactMap(\.appId))
items += ThreadStore.personalApps
.filter { !openApps.contains($0.id) }
- .map { .init(id: "app:" + $0.id, title: $0.title, glyph: $0.glyph, avatar: nil) }
+ .map { .init(id: "app:" + $0.id, title: $0.title, glyph: $0.glyph, avatar: nil, appId: $0.id) }
app.nativeChats = items
}
@@ -321,7 +321,7 @@ struct NativeChatView: View {
selectedID = thread.id
store.markRead(thread)
} label: {
- ThreadAvatar(title: thread.title, source: thread.avatar, photo: thread.isEleven ? ElevenBrand.icon : nil, glyph: thread.glyph)
+ ThreadAvatar(title: thread.title, source: thread.avatar, photo: thread.isEleven ? ElevenBrand.icon : nil, glyph: thread.glyph, symbol: AppIdentity.symbol(for: thread.appId), mark: AppIdentity.mark(for: thread.appId))
.overlay(alignment: .topTrailing) {
if thread.unread {
Circle().fill(theme.accent.resolved(scheme))
@@ -574,7 +574,7 @@ struct NativeChatView: View {
private func feedHeader(_ t: ChatThread) -> some View {
HStack(spacing: 10) {
- ThreadAvatar(title: t.title, source: t.avatar, size: 34, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph,
+ ThreadAvatar(title: t.title, source: t.avatar, size: 34, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph, symbol: AppIdentity.symbol(for: t.appId), mark: AppIdentity.mark(for: t.appId),
onTap: t.glyph == nil ? openAvatarLightbox(named: t.title) : nil)
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 5) {
@@ -1031,7 +1031,7 @@ struct NativeChatView: View {
.padding(EdgeInsets(top: 8, leading: 12, bottom: 4, trailing: 12))
ForEach(Array(convoMatches.enumerated()), id: \.element.id) { i, t in
searchRow(index: i) {
- ThreadAvatar(title: t.title, source: t.avatar, size: 24, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph)
+ ThreadAvatar(title: t.title, source: t.avatar, size: 24, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph, symbol: AppIdentity.symbol(for: t.appId), mark: AppIdentity.mark(for: t.appId))
Text(t.title).font(.system(size: 13, weight: .medium)).lineLimit(1)
}
}
@@ -1045,7 +1045,9 @@ struct NativeChatView: View {
ForEach(Array(messageMatches.enumerated()), id: \.offset) { i, hit in
searchRow(index: convoMatches.count + i) {
ThreadAvatar(title: hit.thread.title, source: hit.thread.avatar,
- size: 24, glyph: hit.thread.glyph)
+ size: 24, glyph: hit.thread.glyph,
+ symbol: AppIdentity.symbol(for: hit.thread.appId),
+ mark: AppIdentity.mark(for: hit.thread.appId))
VStack(alignment: .leading, spacing: 0) {
Text(hit.thread.title)
.font(.system(size: 12, weight: .medium)).lineLimit(1)
@@ -1275,7 +1277,7 @@ struct NativeChatView: View {
store.reopenIfHidden(t) // opening a closed chat from search reopens it
} label: {
HStack(spacing: 10) {
- ThreadAvatar(title: t.title, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph)
+ ThreadAvatar(title: t.title, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph, symbol: AppIdentity.symbol(for: t.appId), mark: AppIdentity.mark(for: t.appId))
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(t.title)
@@ -1341,6 +1343,11 @@ struct NativeChatView: View {
/// A mini-app's emoji face — overrides photo/initials (apps have no
/// member photo; a self-thread would show YOUR face otherwise).
var glyph: String? = nil
+ /// The app's identity mark (docs/icons.md): an SF Symbol for
+ /// first-party apps, or a vendored brand template (GitHub, Linear, …).
+ /// Either wins over the glyph — emoji is content, never chrome.
+ var symbol: String? = nil
+ var mark: PlatformImage? = nil
/// When set and a real photo is loaded, clicking hands (image, window
/// frame) over — the container zooms it into the lightbox.
var onTap: ((PlatformImage, CGRect) -> Void)?
@@ -1352,6 +1359,19 @@ struct NativeChatView: View {
ZStack {
if let photo {
Image(platform: photo).resizable().scaledToFill()
+ } else if let mark {
+ Circle().fill(.primary.opacity(0.08))
+ Image(platform: mark)
+ .renderingMode(.template)
+ .resizable()
+ .scaledToFit()
+ .frame(width: size * 0.5, height: size * 0.5)
+ .foregroundStyle(.primary)
+ } else if let symbol {
+ Circle().fill(.primary.opacity(0.08))
+ Image(systemName: symbol)
+ .font(.system(size: size * 0.38, weight: .medium))
+ .foregroundStyle(.primary)
} else if let glyph {
Circle().fill(.primary.opacity(0.08))
Text(glyph).font(.system(size: size * 0.45))
diff --git a/ios/LChatMac/NewChatPopover.swift b/ios/LChatMac/NewChatPopover.swift
index 1d35131..8a1b947 100644
--- a/ios/LChatMac/NewChatPopover.swift
+++ b/ios/LChatMac/NewChatPopover.swift
@@ -379,7 +379,9 @@ struct NewChatPopover: View {
} label: {
HStack(spacing: 8) {
NativeChatView.ThreadAvatar(title: entry.title, source: nil,
- size: 26, glyph: entry.glyph)
+ size: 26, glyph: entry.glyph,
+ symbol: AppIdentity.symbol(for: entry.id),
+ mark: AppIdentity.mark(for: entry.id))
Text(entry.title)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
@@ -406,7 +408,8 @@ struct NewChatPopover: View {
} label: {
HStack(spacing: 8) {
NativeChatView.ThreadAvatar(title: entry.title, source: nil,
- size: 26, glyph: entry.glyph)
+ size: 26, glyph: entry.glyph,
+ symbol: entry.symbol)
Text(entry.title)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index 78cc1f7..70ad932 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -518,9 +518,18 @@ struct CommandPalette: View {
private func chatRow(_ c: PaletteChat, highlighted: Bool) -> some View {
rowShell(highlighted: highlighted, action: { pick(.chat(c)) }) {
statusDot(current: c.active)
- // The native surface knows each chat's real (decrypted) photo —
- // show it; glyph/initials remain the webview path's fallback.
- if let avatar = app.nativeChats.first(where: { $0.id == c.id })?.avatar {
+ // The native surface knows each chat's real (decrypted) photo and
+ // which app a thread belongs to — show the photo, or the app's
+ // identity mark (docs/icons.md); glyph/initials remain the webview
+ // path's fallback.
+ let native = app.nativeChats.first(where: { $0.id == c.id })
+ if let appId = native?.appId,
+ AppIdentity.symbol(for: appId) != nil || AppIdentity.mark(for: appId) != nil {
+ NativeChatView.ThreadAvatar(title: c.name, source: nil, size: 38,
+ glyph: native?.glyph,
+ symbol: AppIdentity.symbol(for: appId),
+ mark: AppIdentity.mark(for: appId))
+ } else if let avatar = native?.avatar {
NativeChatView.ThreadAvatar(title: c.name, source: avatar, size: 38)
} else {
Text(c.glyph ?? chatInitials(c.name))