ios: label mini-app threads by their app name, not "Notes" (#198)
Mini-app threads (To-do, Reminders, Poll, …) are kind=="self" threads with the app id encrypted in name_blob — the same kind as the user's own "Notes", which the server can't tell apart. The native thread list returned "Notes" for every self-thread; it now decrypts name_blob and, when an app is named, shows that app's title (from the blob when present, else a small built-in map, else a prettified id). web: createAppThread now also stores the app's display title in name_blob, so a future app is labelled correctly on native clients with no native change.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 files changed,
+28
−1
changelog/2026-07-05-ios-app-thread-titles.md+1 −0ios/LChat/Models.swift+6 −0ios/LChat/ThreadStore.swift+18 −0web/static/shell.js+3 −1
diff --git a/changelog/2026-07-05-ios-app-thread-titles.md b/changelog/2026-07-05-ios-app-thread-titles.md| new file mode 100644 |
| index 0000000..13f1b08 |
| --- /dev/null |
| +++ b/changelog/2026-07-05-ios-app-thread-titles.md |
| @@ -0,0 +1 @@ |
| +Fixed mini-app chats (To-do, Reminders, and the like) showing up as "Notes" in the iOS app — they now display their proper app name. |
diff --git a/ios/LChat/Models.swift b/ios/LChat/Models.swift| index 4ed8b00..66609a7 100644 |
| --- a/ios/LChat/Models.swift |
| +++ b/ios/LChat/Models.swift |
| @@ -89,6 +89,12 @@ struct Attachment: Codable, Hashable { |
| // A group's encrypted name payload (threads.name_blob decrypts to this). |
| struct GroupNamePayload: Codable { var name: String? } |
| +// A self-thread's encrypted name payload. A bare "Notes" self-thread has none (or |
| +// no `app`); a mini-app thread (To-do, Reminders, …) carries the app id, and — |
| +// for threads created by newer web clients — its display title, so a new app needs |
| +// no native change to be labelled correctly. (web: createAppThread in shell.js.) |
| +struct AppThreadPayload: Codable { var app: String?; var title: String? } |
| + |
| // ---- sidebar view model ------------------------------------------------- |
| // Where a native avatar circle fetches its photo: the encrypted blob id plus the |
diff --git a/ios/LChat/ThreadStore.swift b/ios/LChat/ThreadStore.swift| index 1f4049e..ca95678 100644 |
| --- a/ios/LChat/ThreadStore.swift |
| +++ b/ios/LChat/ThreadStore.swift |
| @@ -238,9 +238,27 @@ final class ThreadStore: ObservableObject { |
| if let i = threads.firstIndex(where: { $0.id == thread.id }) { threads[i].unread = false } |
| } |
| + // Built-in mini-app display titles, for app-threads created before the title was |
| + // stored in name_blob (newer ones carry their own). Keep in sync with each app's |
| + // `title` in web/static/apps/*.js. |
| + private static let appTitles: [String: String] = [ |
| + "todo": "To-do", "remind": "Reminders", "poll": "Poll", |
| + "bookmarks": "Bookmarks", "board": "Whiteboard", "chat": "Chat", |
| + ] |
| + |
| private func threadTitle(_ t: ServerThread, key: GroupKey?) -> String { |
| switch t.kind { |
| case "self": |
| + // A self-thread is either the user's own "Notes" or a mini-app thread. |
| + // The app id (and, from newer clients, its title) rides encrypted in |
| + // name_blob — the server can't tell them apart — so decrypt to find out. |
| + // Prefer the blob's title (future apps need no native change), then the |
| + // known-app map, then a prettified id; a bare self-thread is "Notes". |
| + if let nb = t.name_blob, !nb.isEmpty, let key, |
| + let p = try? key.decryptJSON(AppThreadPayload.self, blob: nb), |
| + let app = p.app, !app.isEmpty { |
| + return p.title ?? Self.appTitles[app] ?? app.capitalized |
| + } |
| return "Notes" |
| case "dm": |
| let other = (t.members ?? []).first { $0 != myUserId } ?? "" |
diff --git a/web/static/shell.js b/web/static/shell.js| index a8d8e1f..6364cb4 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -2218,7 +2218,9 @@ async function createAppThread(appName) { |
| if (!me || !me.public_key) return null; |
| const { key, b64 } = await generateKey(); |
| const wrapped = await wrapThreadKey(b64, me.public_key); |
| - const name_blob = await encrypt(key, { app: appName }); |
| + // Store the display title alongside the app id so native clients (which don't |
| + // have the app registry) can label the thread without knowing every app. |
| + const name_blob = await encrypt(key, { app: appName, title: (appByName(appName) || {}).title || appName }); |
| const r = await ( |
| await fetch("/api/threads", { |
| method: "POST", |