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>
18 files changed,
+160
−24
docs/icons.md+10 −4ios/LChat/Views/Avatar.swift+18 −0ios/LChat/Views/NewChatView.swift+3 −2ios/LChat/Views/ThreadListView.swift+5 −3ios/LChat/Views/ThreadRow.swift+8 −1ios/LChatCore/Package.swift+1 −1ios/LChatCore/Sources/LChatCore/AppIdentity.swift+69 −0ios/LChatCore/Sources/LChatCore/GroupApps.swift+1 −1ios/LChatCore/Sources/LChatCore/Marks/dnsimple.pngbinaryios/LChatCore/Sources/LChatCore/Marks/github.pngbinaryios/LChatCore/Sources/LChatCore/Marks/hubspot.pngbinaryios/LChatCore/Sources/LChatCore/Marks/linear.pngbinaryios/LChatCore/Sources/LChatCore/Marks/plug.pngbinaryios/LChatCore/Sources/LChatCore/Marks/stripe.pngbinaryios/LChatMac/LChatMacApp.swift+1 −0ios/LChatMac/NativeChatView.swift+27 −7ios/LChatMac/NewChatPopover.swift+5 −2ios/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)) |