eleven / messenger Public

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>
Paul Campbell 43292676d9b0943737b30bb28e6f292694350c0d parent d37cab8
6 files changed, +153 −24
  • changelog/2026-07-04-pin-and-close-chats-on-ios.md +1 −0
  • ios/LChat/LChatAPI.swift +12 −0
  • ios/LChat/Models.swift +2 −0
  • ios/LChat/ThreadStore.swift +34 −1
  • ios/LChat/Views/ChatView.swift +79 −18
  • ios/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 {