eleven / messenger Public

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>
Paul Campbell a9071a4e40a6fe96b4703c3196a85267ce6a5aa8 parent 55ec7b2
4 files changed, +28 −1
  • changelog/2026-07-05-ios-app-thread-titles.md +1 −0
  • ios/LChat/Models.swift +6 −0
  • ios/LChat/ThreadStore.swift +18 −0
  • web/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",