ios: pin & close chats + a centered avatar chat header (#136)
Wire the existing per-user pin/hide thread flags into the native iOS UI
(they already shipped server-side and on the web client):
- Thread list: swipe a row to close (hide) a chat; closed chats drop off
the default list but stay findable via search, and opening one reopens
it (mirrors web openThread). Pinned rows float to the top with a pin
marker.
- Chat: a top-right ⋯ menu to pin/unpin or close the chat (close pops
back to the list). Both call POST /api/threads/{id}/pinned and /hidden.
- Chat header: replace the plain inline title with a Messages-style
header — a big avatar sitting level with the bar buttons over a
transparent nav bar, the name in a Liquid-glass pill just beneath it,
with a gentle spring-in pop once the pane lands. It's a content
safe-area inset (not a nav-bar title view) so it never clips on
push/pop and doesn't flash colour.
Server, DB, web and crypto are unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6 files changed,
+153
−24
changelog/2026-07-04-pin-and-close-chats-on-ios.md+1 −0ios/LChat/LChatAPI.swift+12 −0ios/LChat/Models.swift+2 −0ios/LChat/ThreadStore.swift+34 −1ios/LChat/Views/ChatView.swift+79 −18ios/LChat/Views/ThreadListView.swift+25 −5
diff --git a/changelog/2026-07-04-pin-and-close-chats-on-ios.md b/changelog/2026-07-04-pin-and-close-chats-on-ios.md| new file mode 100644 |
| index 0000000..0061c00 |
| --- /dev/null |
| +++ b/changelog/2026-07-04-pin-and-close-chats-on-ios.md |
| @@ -0,0 +1 @@ |
| +Pin and close chats now work in the iPhone and iPad app, matching the web: swipe a chat in your list to close it, or use the new ⋯ menu inside a chat to pin it to the top or close it. Closed chats aren't gone — search for one by name to bring it back. Each chat also gets a tidier header now, with the person's (or group's) avatar and name centered up top. |
diff --git a/ios/LChat/LChatAPI.swift b/ios/LChat/LChatAPI.swift| index fe2e821..8988e2c 100644 |
| --- a/ios/LChat/LChatAPI.swift |
| +++ b/ios/LChat/LChatAPI.swift |
| @@ -63,6 +63,18 @@ struct LChatAPI { |
| return r.thread_id |
| } |
| + /// Pin/unpin a chat for this user — a per-user marker the server syncs across |
| + /// the user's devices and floats to the top of their list. |
| + func setPinned(thread: String, pinned: Bool) async { |
| + await postJSON("/api/threads/\(thread)/pinned", ["token": token, "pinned": pinned]) |
| + } |
| + |
| + /// Close (hidden=true) / reopen (hidden=false) a chat for this user — a per-user |
| + /// marker synced across their devices; a closed chat drops off the list. |
| + func setHidden(thread: String, hidden: Bool) async { |
| + await postJSON("/api/threads/\(thread)/hidden", ["token": token, "hidden": hidden]) |
| + } |
| + |
| /// The instance's display name (public; substituted into pages too). |
| func groupName() async -> String { |
| struct Resp: Codable { let name: String? } |
diff --git a/ios/LChat/Models.swift b/ios/LChat/Models.swift| index 9f8b11a..3f576fa 100644 |
| --- a/ios/LChat/Models.swift |
| +++ b/ios/LChat/Models.swift |
| @@ -45,6 +45,7 @@ struct ServerThread: Codable { |
| var last_at: Int64? |
| var avatar: String? |
| var pinned: Bool? |
| + var hidden: Bool? // this user "closed" the chat (per-user, server-synced) |
| } |
| struct RosterEntry: Codable, Equatable { |
| @@ -99,6 +100,7 @@ struct Thread: Identifiable, Equatable { |
| var lastAt: Int64 // seconds; drives ordering + the relative time |
| var unread: Bool |
| var pinned: Bool // user pinned this chat to the top (set on the web/Mac client) |
| + var hidden: Bool // user "closed" this chat: dropped from the list, still findable in search |
| var members: [String] |
| var threadKeyB64: String // unwrapped — handed to the webview to render the feed |
| } |
diff --git a/ios/LChat/ThreadStore.swift b/ios/LChat/ThreadStore.swift| index edfbe18..3b9b766 100644 |
| --- a/ios/LChat/ThreadStore.swift |
| +++ b/ios/LChat/ThreadStore.swift |
| @@ -105,15 +105,48 @@ final class ThreadStore: ObservableObject { |
| lastAt: lastAt, |
| unread: !lastMine && lastAt > ReadState.lastSeen(instance: instance.id, thread: t.id), |
| pinned: t.pinned ?? false, |
| + hidden: t.hidden ?? false, |
| members: t.members ?? [], |
| threadKeyB64: raw.base64urlEncodedString() |
| )) |
| } |
| // Pinned chats first, then most-recent activity — mirrors the web list. |
| - rows.sort { ($0.pinned != $1.pinned) ? $0.pinned : $0.lastAt > $1.lastAt } |
| + // Closed (hidden) chats stay in the array — ThreadListView drops them from |
| + // the default view but reveals them in search, so they can be reopened. |
| + rows.sort(by: Self.order) |
| threads = rows |
| } |
| + // Pinned chats first, then most-recent activity — mirrors the web list. |
| + static func order(_ a: Thread, _ b: Thread) -> Bool { |
| + (a.pinned != b.pinned) ? a.pinned : a.lastAt > b.lastAt |
| + } |
| + |
| + /// Pin or unpin a chat: update locally (re-sorting so it floats/settles), then |
| + /// tell the server. The flag is per-user and syncs to the user's other devices. |
| + func setPinned(_ thread: Thread, _ pinned: Bool) { |
| + guard let i = threads.firstIndex(where: { $0.id == thread.id }) else { return } |
| + threads[i].pinned = pinned |
| + threads.sort(by: Self.order) |
| + Task { await api?.setPinned(thread: thread.id, pinned: pinned) } |
| + } |
| + |
| + /// Close (hidden=true) or reopen (hidden=false) a chat. ThreadListView hides |
| + /// closed chats from the default list; the row stays in the store so search can |
| + /// still surface it. Syncs to the user's other devices via the server flag. |
| + func setHidden(_ thread: Thread, _ hidden: Bool) { |
| + guard let i = threads.firstIndex(where: { $0.id == thread.id }) else { return } |
| + threads[i].hidden = hidden |
| + Task { await api?.setHidden(thread: thread.id, hidden: hidden) } |
| + } |
| + |
| + /// Opening a closed chat reopens it (locally + server) — mirrors the web's |
| + /// openThread. A no-op for chats that aren't hidden. |
| + func reopenIfHidden(_ thread: Thread) { |
| + guard let i = threads.firstIndex(where: { $0.id == thread.id }), threads[i].hidden else { return } |
| + setHidden(threads[i], false) |
| + } |
| + |
| // Start a chat with the given other members (empty = a "Notes" self thread, one = |
| // DM, more = group). Generates a fresh thread key, wraps it to every member |
| // (including us) via ECIES, encrypts the group name, and creates the thread; the |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index 9f66d5a..70817ca 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -21,6 +21,10 @@ struct ChatView: View { |
| // Owned by FeedWebCache alongside the thread's webview, so both survive |
| // leaving the chat and re-opening attaches the same live page. |
| @ObservedObject private var bridge: WebBridge |
| + // The sidebar store, so the header menu can pin/close this chat and read the |
| + // live pinned state (it changes as the menu toggles it). |
| + @ObservedObject private var store: ThreadStore |
| + @Environment(\.dismiss) private var dismiss |
| @State private var text = "" |
| @State private var caret = 0 // UTF-16, kept in sync by ComposerField |
| @@ -30,6 +34,7 @@ struct ChatView: View { |
| @State private var attachError: String? |
| @State private var fullFrame: CGRect = .zero // the full-bleed webview, global coords |
| @State private var safeFrame: CGRect = .zero // the safe region between the bars |
| + @State private var avatarPop = false // header avatar springs to full size once the pane lands |
| @Environment(\.scenePhase) private var scenePhase |
| let instance: SavedInstance |
| @@ -38,9 +43,11 @@ struct ChatView: View { |
| let instanceKeyB64: String? |
| @MainActor |
| - init(instance: SavedInstance, thread: Thread, myUserId: String = "", instanceKeyB64: String?) { |
| + init(instance: SavedInstance, thread: Thread, store: ThreadStore, |
| + myUserId: String = "", instanceKeyB64: String?) { |
| self.instance = instance |
| self.thread = thread |
| + self.store = store |
| self.myUserId = myUserId |
| self.instanceKeyB64 = instanceKeyB64 |
| bridge = FeedWebCache.shared.bridge(for: instance, thread: thread, |
| @@ -49,10 +56,40 @@ struct ChatView: View { |
| private var isMac: Bool { ProcessInfo.processInfo.isMacCatalystApp } |
| + // Live pinned state: the store row changes as the menu toggles it, so read from |
| + // there (falling back to the value captured when this view was built). |
| + private var isPinned: Bool { |
| + store.threads.first { $0.id == thread.id }?.pinned ?? thread.pinned |
| + } |
| + |
| private var canSend: Bool { |
| !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || bridge.attachmentStaged |
| } |
| + // The Messages-style header: a big avatar sitting level with the bar buttons, |
| + // the name in a Liquid-glass pill tucked just under it (the avatar overlaps the |
| + // pill's top edge slightly). The offset lifts the whole stack up into the (now |
| + // transparent) bar so the avatar's top lines up with the back button / ⋯. The |
| + // pill's material + explicit colour keep the name legible over any message. |
| + private var chatHeader: some View { |
| + VStack(spacing: -5) { |
| + Avatar(name: thread.title, size: 54) |
| + .scaleEffect(avatarPop ? 1 : 0.7) |
| + .zIndex(1) // sit on top where it overlaps the pill |
| + Text(thread.title) |
| + .font(.subheadline.weight(.semibold)) |
| + .lineLimit(1) |
| + .foregroundStyle(.primary) |
| + .padding(.horizontal, 12) |
| + .padding(.vertical, 4) |
| + .floatingGlass(in: Capsule()) |
| + } |
| + .frame(maxWidth: .infinity) |
| + .padding(.bottom, 6) |
| + .offset(y: -52) // rise up level with the top of the back button / ⋯ menu |
| + .accessibilityElement(children: .combine) |
| + } |
| + |
| var body: some View { |
| ZStack { |
| if instance.baseURL != nil { |
| @@ -100,12 +137,51 @@ struct ChatView: View { |
| if instance.baseURL != nil { composer } |
| } |
| .background(EdgeOnlyBackGesture()) |
| - .modifier(GlassNavigationBar()) |
| - .navigationTitle(isMac ? "" : thread.title) |
| + .navigationTitle("") |
| .navigationBarTitleDisplayMode(.inline) |
| + // Transparent nav bar so the big avatar can rise up LEVEL with the back |
| + // button and ⋯ menu (iMessage-style) and float over the feed. The bar itself |
| + // stays — keeping the system back button, its edge-swipe (EdgeOnlyBackGesture) |
| + // and the ⋯ menu — only its background is hidden. |
| + .toolbarBackground(.hidden, for: .navigationBar) |
| + // The header is CONTENT (a top safe-area inset), not a nav-bar title view, so |
| + // it slides cleanly with the pane — no clipping against the bar on push OR |
| + // pop, and no title-colour flash. Its own offset lifts it into the bar; the |
| + // feed's top inset (pushInsets) still grows to clear its laid-out height. |
| + .safeAreaInset(edge: .top, spacing: 0) { |
| + if !isMac { chatHeader } |
| + } |
| + .toolbar { |
| + // Top-right overflow: pin/unpin and close this chat (both per-user, |
| + // synced by the server). Closing pops back to the list. |
| + ToolbarItem(placement: .primaryAction) { |
| + Menu { |
| + Button { |
| + store.setPinned(thread, !isPinned) |
| + } label: { |
| + Label(isPinned ? "Unpin chat" : "Pin chat", |
| + systemImage: isPinned ? "pin.slash" : "pin") |
| + } |
| + Button(role: .destructive) { |
| + store.setHidden(thread, true) |
| + dismiss() |
| + } label: { |
| + Label("Close chat", systemImage: "xmark.circle") |
| + } |
| + } label: { |
| + Image(systemName: "ellipsis.circle") |
| + } |
| + .accessibilityLabel("Chat options") |
| + } |
| + } |
| .onAppear { |
| bridge.didAttach(force: consumeNotificationScroll()) // a cached feed may need re-pinning to its newest message |
| if isMac { composerFocused = true } |
| + // A little finesse once the pane has landed: the avatar springs up past |
| + // full size and settles back — a low damping fraction gives the overshoot. |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { |
| + withAnimation(.spring(response: 0.42, dampingFraction: 0.45)) { avatarPop = true } |
| + } |
| } |
| // A notification tap can foreground the app with this chat already on top — |
| // no onAppear fires, but the feed was parked all the same and sits shy of |
| @@ -657,18 +733,3 @@ extension View { |
| #endif |
| } |
| } |
| - |
| -// The nav bar over a full-bleed feed: iOS 26 draws its own Liquid Glass; older |
| -// systems need the material made explicit or the bar renders clear over the |
| -// webview. |
| -struct GlassNavigationBar: ViewModifier { |
| - func body(content: Content) -> some View { |
| - if #available(iOS 26.0, *) { |
| - content |
| - } else { |
| - content |
| - .toolbarBackground(.ultraThinMaterial, for: .navigationBar) |
| - .toolbarBackground(.visible, for: .navigationBar) |
| - } |
| - } |
| -} |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index 41cea5f..9c0eab9 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -15,10 +15,12 @@ struct ThreadListView: View { |
| @State private var showNew = false |
| @State private var search = "" |
| - // Mirrors the web's sidebar filter: case-insensitive match on the chat name. |
| + // Mirrors renderThreadList in web/static/chat.js: a search reveals everything |
| + // that matches (so a closed chat can be found and reopened), while the default |
| + // list hides closed (hidden) chats. Case-insensitive match on the chat name. |
| private var visibleThreads: [Thread] { |
| let q = search.trimmingCharacters(in: .whitespaces) |
| - guard !q.isEmpty else { return store.threads } |
| + guard !q.isEmpty else { return store.threads.filter { !$0.hidden } } |
| return store.threads.filter { $0.title.localizedCaseInsensitiveContains(q) } |
| } |
| @@ -26,6 +28,15 @@ struct ThreadListView: View { |
| List { |
| ForEach(visibleThreads) { t in |
| NavigationLink(value: t.id) { row(t) } |
| + // Swipe a row to close (hide) the chat — the list mirror of the |
| + // in-chat menu's "Close chat". It stays findable via search. |
| + .swipeActions(edge: .trailing) { |
| + Button(role: .destructive) { |
| + store.setHidden(t, true) |
| + } label: { |
| + Label("Close", systemImage: "xmark.circle") |
| + } |
| + } |
| } |
| } |
| .listStyle(.plain) |
| @@ -73,8 +84,16 @@ struct ThreadListView: View { |
| HStack { |
| Text(t.title).font(.headline).lineLimit(1) |
| Spacer() |
| - if t.lastAt > 0 { |
| - Text(relTime(t.lastAt)).font(.caption).foregroundStyle(.secondary) |
| + HStack(spacing: 4) { |
| + if t.pinned { |
| + Image(systemName: "pin.fill") |
| + .font(.caption2) |
| + .foregroundStyle(.secondary) |
| + .accessibilityLabel("Pinned") |
| + } |
| + if t.lastAt > 0 { |
| + Text(relTime(t.lastAt)).font(.caption).foregroundStyle(.secondary) |
| + } |
| } |
| } |
| HStack(spacing: 6) { |
| @@ -122,11 +141,12 @@ struct ThreadDestination: View { |
| var body: some View { |
| if let t = store.threads.first(where: { $0.id == id }) { |
| - ChatView(instance: store.instance, thread: t, |
| + ChatView(instance: store.instance, thread: t, store: store, |
| myUserId: store.myUserId, instanceKeyB64: store.instanceKey()) |
| .id(store.instance.id + "|" + t.id) // stable identity per thread (webview reused via FeedWebCache) |
| .onAppear { |
| store.markRead(t) |
| + store.reopenIfHidden(t) // opening a closed chat reopens it (mirrors web openThread) |
| PushManager.activeThreadID = t.id // suppress banners for the open chat |
| } |
| .onDisappear { |