mac+ios: rail favourites-only + capped overflow menu, archive synced to home, unfurl re-pin (#446)
* mac+ios: rail favourites-only + capped, archive synced to home, unfurl re-pin Rail (Mac): favourites-only when any exist; otherwise up to 9 spaces. Everything else moves into the '…' MENU (no more inline fold): click a space to open it, submenu to star/archive, archived spaces grouped at the bottom, and 'Manage Spaces on Eleven…' opens home. iOS drawer: archived spaces fold under an Archived group with Unarchive in the context menu; main lists exclude them. Archive is a first-class synced flag: SavedInstance.archived + HomeVault.Server.archived, pushed/removed exactly like starred (home's web UI doesn't render it yet but preserves it — both writers patch unknown fields through). Also: posting a link now re-pins the feed when the unfurl card loads — the card grew the last bubble AFTER the send pin ran, cutting the link off below the viewport (Mac verified pin; iOS animated sentinel pin, gated on being at the bottom). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac: drop the rail's + button — Add a Chat lives in the always-present … menu Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac: the headers double as title bar — drag anywhere above the search box / chat divider The hidden-title-bar window only dragged in a thin strip at the very top. A window-local monitor turns presses in the chat header and the sidebar's Chats row into window drags — on MOVEMENT, not press, so the headers' buttons and menus keep working — and double-click zooms, like a real title bar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * core: typing dots hold still — a brightness wave crosses them; bubble sized as one message line No bounce: the dots keep their position and the phase-offset sine drives opacity instead. The bubble wraps an invisible one-line Text with a real bubble's font + insets, so it's exactly one message tall at every text size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
13 files changed,
+264
−77
ios/LChat/LChatApp.swift+11 −1ios/LChat/Views/InstanceSidebar.swift+24 −3ios/LChat/Views/NativeChatScreen.swift+16 −1ios/LChat/Views/SignInWithEleven.swift+4 −2ios/LChatCore/Sources/LChatCore/HomeSync.swift+1 −0ios/LChatCore/Sources/LChatCore/HomeVault.swift+7 −1ios/LChatCore/Sources/LChatCore/Models.swift+7 −1ios/LChatCore/Sources/LChatCore/TypingBubble.swift+26 −18ios/LChatMac/LChatMacApp.swift+19 −1ios/LChatMac/MacHomeSignIn.swift+1 −0ios/LChatMac/NativeChatView.swift+69 −1ios/LChatMac/NativeFeedView.swift+14 −1ios/LChatMac/Sidebar.swift+65 −47
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index c7c968a..f929285 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -97,10 +97,11 @@ final class AppState: ObservableObject { |
| /// device already has, and its array order (device-only servers tail, in |
| /// their existing relative order). One save, no push — the caller pushes |
| /// once after the whole merge, so the write-back reflects the merged state. |
| - func applyVault(_ entries: [(url: String, starred: Bool, name: String?)]) { |
| + func applyVault(_ entries: [(url: String, starred: Bool, archived: Bool, name: String?)]) { |
| for e in entries { |
| guard let i = instances.firstIndex(where: { $0.origin == e.url }) else { continue } |
| instances[i].starred = e.starred ? true : nil |
| + instances[i].archived = e.archived ? true : nil |
| if let n = e.name, !n.isEmpty { instances[i].groupName = n } |
| } |
| var rest = instances |
| @@ -140,6 +141,15 @@ final class AppState: ObservableObject { |
| HomeSync.pushSoon(instances: instances) |
| } |
| + /// Archive or unarchive a space: hidden from the drawer's main lists, |
| + /// grouped under Archived. No reordering — position is kept for unarchive. |
| + func toggleArchive(_ inst: SavedInstance) { |
| + guard let i = instances.firstIndex(where: { $0.id == inst.id }) else { return } |
| + instances[i].archived = instances[i].isArchived ? nil : true |
| + InstancesStore.save(instances) |
| + HomeSync.pushSoon(instances: instances) |
| + } |
| + |
| /// Persist resolved fields discovered after first load (our user id, group name). |
| func note(id: String, myUserId: String? = nil, groupName: String? = nil) { |
| guard let i = instances.firstIndex(where: { $0.id == id }) else { return } |
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift| index 4c9f417..1fae9c8 100644 |
| --- a/ios/LChat/Views/InstanceSidebar.swift |
| +++ b/ios/LChat/Views/InstanceSidebar.swift |
| @@ -18,10 +18,13 @@ struct ServerDrawer: View { |
| // "More" starts expanded if the active space is one of the collapsed ones, |
| // so you never lose sight of where you are. |
| @State private var showMore = false |
| + @State private var showArchived = false |
| @State private var confirmReset = false |
| - private var starred: [SavedInstance] { app.instances.filter(\.isStarred) } |
| - private var others: [SavedInstance] { app.instances.filter { !$0.isStarred } } |
| + private var active: [SavedInstance] { app.instances.filter { !$0.isArchived } } |
| + private var starred: [SavedInstance] { active.filter(\.isStarred) } |
| + private var others: [SavedInstance] { active.filter { !$0.isStarred } } |
| + private var archived: [SavedInstance] { app.instances.filter(\.isArchived) } |
| var body: some View { |
| VStack(alignment: .leading, spacing: 0) { |
| @@ -37,7 +40,7 @@ struct ServerDrawer: View { |
| // it's the flat, drag-reorderable list. |
| List { |
| if starred.isEmpty { |
| - ForEach(app.instances) { inst in serverRow(inst) } |
| + ForEach(active) { inst in serverRow(inst) } |
| .onMove { app.move(from: $0, to: $1) } |
| } else { |
| // Each group drags independently (home's independent lists); |
| @@ -58,6 +61,20 @@ struct ServerDrawer: View { |
| .listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16)) |
| } |
| } |
| + // Archived spaces fold at the bottom — out of the way, never |
| + // gone (context menu: Unarchive). Synced to home like starring. |
| + if !archived.isEmpty { |
| + DisclosureGroup(isExpanded: $showArchived) { |
| + ForEach(archived) { inst in serverRow(inst) } |
| + } label: { |
| + Text("Archived") |
| + .font(.subheadline.weight(.medium)) |
| + .foregroundStyle(.secondary) |
| + } |
| + .listRowSeparator(.hidden) |
| + .listRowBackground(Color.clear) |
| + .listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16)) |
| + } |
| } |
| .listStyle(.plain) |
| .scrollContentBackground(.hidden) |
| @@ -225,6 +242,10 @@ struct ServerDrawer: View { |
| Label(inst.isStarred ? "Unstar" : "Star", |
| systemImage: inst.isStarred ? "star.slash" : "star") |
| } |
| + Button { app.toggleArchive(inst) } label: { |
| + Label(inst.isArchived ? "Unarchive" : "Archive", |
| + systemImage: inst.isArchived ? "tray.and.arrow.up" : "archivebox") |
| + } |
| Button(role: .destructive) { app.remove(inst) } label: { |
| Label("Remove", systemImage: "trash") |
| } |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index ff820af..0ff9f00 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -529,7 +529,22 @@ struct NativeChatScreen: View { |
| onDelete: { model.deleteMessage(m.id) }, |
| onCopy: { UIPasteboard.general.string = m.text }, |
| onReact: { model.toggleReaction(m.id, emoji: $0) }), |
| - unfurl: { await model.unfurl($0) }) |
| + unfurl: { url in |
| + let card = await model.unfurl(url) |
| + // The link card loads AFTER the send pin ran |
| + // and grows the bubble below the viewport — |
| + // if this is the last row and the reader is |
| + // at the bottom, re-pin so the link they |
| + // just posted stays visible. |
| + if card != nil, m.id == model.messages.last?.id, bottomVisible { |
| + DispatchQueue.main.async { |
| + withAnimation(.easeOut(duration: 0.15)) { |
| + proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| + } |
| + } |
| + } |
| + return card |
| + }) |
| .environment(\.chatFontSize, chatFontSize) |
| if !m.mine { Spacer(minLength: 40) } |
| } |
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift| index 188bd05..cac79d0 100644 |
| --- a/ios/LChat/Views/SignInWithEleven.swift |
| +++ b/ios/LChat/Views/SignInWithEleven.swift |
| @@ -187,14 +187,15 @@ final class HomeSignInModel: ObservableObject { |
| userPub: creds.userPub, |
| userPriv: creds.userPriv, |
| groupName: server.name ?? "", |
| - starred: server.isStarred ? true : nil |
| + starred: server.isStarred ? true : nil, |
| + archived: server.isArchived ? true : nil |
| )) |
| added += 1 |
| } |
| // Vault wins for servers on BOTH sides: its stars/names/order apply to |
| // the device before anything flows back. |
| app.applyVault(opened.contents.servers.map { |
| - (url: $0.url, starred: $0.isStarred, name: $0.name) |
| + (url: $0.url, starred: $0.isStarred, archived: $0.isArchived, name: $0.name) |
| }) |
| // Device-only servers (joined before signing in) flow back up, so home |
| // matches the device from the very first sign-in. |
| @@ -209,6 +210,7 @@ final class HomeSignInModel: ObservableObject { |
| url: inst.origin, |
| name: inst.groupName.isEmpty ? nil : inst.groupName, |
| starred: inst.isStarred ? true : nil, |
| + archived: inst.isArchived ? true : nil, |
| credentials: .init(token: inst.deviceToken, userPub: inst.userPub, |
| userPriv: inst.userPriv, name: inst.screenName)) |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/HomeSync.swift b/ios/LChatCore/Sources/LChatCore/HomeSync.swift| index dabd4e5..f65f610 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/HomeSync.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/HomeSync.swift |
| @@ -96,6 +96,7 @@ public enum HomeSync { |
| // must not delete one another client stored. |
| if !inst.groupName.isEmpty { e["name"] = inst.groupName } |
| if inst.isStarred { e["starred"] = true } else { e.removeValue(forKey: "starred") } |
| + if inst.isArchived { e["archived"] = true } else { e.removeValue(forKey: "archived") } |
| var creds = (e["credentials"] as? [String: Any]) ?? [:] |
| creds["token"] = inst.deviceToken |
| creds["userPub"] = inst.userPub |
diff --git a/ios/LChatCore/Sources/LChatCore/HomeVault.swift b/ios/LChatCore/Sources/LChatCore/HomeVault.swift| index f47dbfa..b3bf95c 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/HomeVault.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/HomeVault.swift |
| @@ -15,6 +15,10 @@ public struct HomeVault { |
| public var url: String |
| public var name: String? |
| public var starred: Bool? |
| + // Archived: hidden from the rail + overflow, kept in the account. |
| + // Native-first field — home's web UI doesn't render it yet, but its |
| + // spread-merge writes preserve it (as does the native raw-JSON push). |
| + public var archived: Bool? |
| // Optional: home can hold a credential-less entry (a space saved by URL |
| // alone — home.js pushes bare {url, name}). Restore skips those; a native |
| // push must preserve them untouched. |
| @@ -35,12 +39,14 @@ public struct HomeVault { |
| } |
| public var isStarred: Bool { starred ?? false } |
| + public var isArchived: Bool { archived ?? false } |
| public init(url: String, name: String? = nil, starred: Bool? = nil, |
| - credentials: Credentials? = nil) { |
| + archived: Bool? = nil, credentials: Credentials? = nil) { |
| self.url = url |
| self.name = name |
| self.starred = starred |
| + self.archived = archived |
| self.credentials = credentials |
| } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index 6d20b25..ff8c42f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -18,12 +18,17 @@ public struct SavedInstance: Codable, Identifiable, Equatable, Hashable { |
| // under "More". Optional so old saved lists (no key) still decode. Synced to |
| // the home vault's `starred` flag when signed in. |
| public var starred: Bool? |
| + // Archived spaces hide from the rail AND the overflow list (reachable only |
| + // via the "…" menu's Archived group). Same optional/sync shape as starred. |
| + public var archived: Bool? |
| public var isStarred: Bool { starred ?? false } |
| + public var isArchived: Bool { archived ?? false } |
| public init(origin: String, deviceToken: String, screenName: String, |
| userPub: String, userPriv: String, |
| - myUserId: String = "", groupName: String = "", starred: Bool? = nil) { |
| + myUserId: String = "", groupName: String = "", starred: Bool? = nil, |
| + archived: Bool? = nil) { |
| self.origin = origin |
| self.deviceToken = deviceToken |
| self.screenName = screenName |
| @@ -32,6 +37,7 @@ public struct SavedInstance: Codable, Identifiable, Equatable, Hashable { |
| self.myUserId = myUserId |
| self.groupName = groupName |
| self.starred = starred |
| + self.archived = archived |
| } |
| // One identity per instance per device (v1). The origin is the stable handle. |
diff --git a/ios/LChatCore/Sources/LChatCore/TypingBubble.swift b/ios/LChatCore/Sources/LChatCore/TypingBubble.swift| index a3cdee1..7aa284a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/TypingBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/TypingBubble.swift |
| @@ -1,14 +1,16 @@ |
| import SwiftUI |
| -// 🤖 The web's typing indicator: a them-side bubble with three softly bouncing |
| -// dots (style.css .typing-bubble), shown at the bottom of the feed while |
| -// someone's composing. In a group a small name line rides above it. |
| +// 🤖 The typing indicator: a them-side bubble with three dots, shown at the |
| +// bottom of the feed while someone's composing. The dots hold their position — |
| +// no bounce — and a brightness wave travels across them left to right. In a |
| +// group a small name line rides above it. |
| public struct TypingBubble: View { |
| public let theme: ChatTheme |
| /// "Eleven is typing…" — shown as a quiet label above the bubble in groups. |
| public let label: String? |
| @Environment(\.colorScheme) private var scheme |
| + @Environment(\.chatFontSize) private var fontSize |
| public init(theme: ChatTheme, label: String? = nil) { |
| self.theme = theme |
| @@ -23,24 +25,30 @@ public struct TypingBubble: View { |
| .foregroundStyle(.secondary) |
| .padding(.horizontal, 6) |
| } |
| - // Positions come from the CLOCK, not delay-staggered repeatForever |
| - // animations: those re-sync into a lockstep pogo whenever the row |
| - // re-renders (any feed publish collapses the per-dot delays). A |
| - // phase-offset sine can't drift or sync by construction. |
| - TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in |
| - let t = context.date.timeIntervalSinceReferenceDate |
| - HStack(spacing: 5) { |
| - ForEach(0..<3, id: \.self) { i in |
| - let phase = sin((t / 0.55 - Double(i) * 0.33) * .pi) |
| - Circle() |
| - .fill(.secondary) |
| - .frame(width: 7, height: 7) |
| - .offset(y: -1 + phase * -2) |
| + ZStack { |
| + // Invisible one-line message: same font + insets as a real text |
| + // bubble (MessageBubble's 9/13), so this bubble is EXACTLY one |
| + // message tall at every text size. |
| + Text(verbatim: " ") |
| + .font(.system(size: fontSize)) |
| + .opacity(0) |
| + // Brightness comes from the CLOCK, not delay-staggered |
| + // repeatForever animations: those re-sync into lockstep on any |
| + // feed publish. A phase-offset sine can't drift or sync. |
| + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in |
| + let t = context.date.timeIntervalSinceReferenceDate |
| + HStack(spacing: 5) { |
| + ForEach(0..<3, id: \.self) { i in |
| + let phase = sin((t / 0.55 - Double(i) * 0.33) * .pi) |
| + Circle() |
| + .fill(.secondary) |
| + .frame(width: 7, height: 7) |
| + .opacity(0.35 + 0.6 * (phase + 1) / 2) |
| + } |
| } |
| } |
| } |
| - .padding(.horizontal, 14) |
| - .padding(.vertical, 12) |
| + .padding(EdgeInsets(top: 9, leading: 13, bottom: 9, trailing: 13)) |
| .background(theme.bubbleThem.resolved(scheme)) |
| .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| } |
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift| index 2d3aa66..aa8cac9 100644 |
| --- a/ios/LChatMac/LChatMacApp.swift |
| +++ b/ios/LChatMac/LChatMacApp.swift |
| @@ -389,7 +389,8 @@ final class MacAppState: ObservableObject { |
| screenName: creds.name ?? s.name ?? "", |
| userPub: creds.userPub, userPriv: creds.userPriv, |
| groupName: s.name ?? "", |
| - starred: s.isStarred ? true : nil) |
| + starred: s.isStarred ? true : nil, |
| + archived: s.isArchived ? true : nil) |
| if let i = instances.firstIndex(where: { $0.origin == s.url }) { |
| inst.myUserId = instances[i].myUserId |
| if inst.groupName.isEmpty { inst.groupName = instances[i].groupName } |
| @@ -458,6 +459,23 @@ final class MacAppState: ObservableObject { |
| syncHome() |
| } |
| + /// Archived: hidden from the rail and the overflow list, reachable via the |
| + /// "…" menu's Archived group. Same identity-flag shape as starring (and |
| + /// synced to the vault the same way); no reordering — position is kept for |
| + /// when it's unarchived. |
| + func isArchived(_ chat: SavedChat) -> Bool { |
| + guard let origin = chat.origin?.absoluteString else { return false } |
| + return instances.first { $0.origin == origin }?.isArchived ?? false |
| + } |
| + |
| + func toggleArchive(_ chat: SavedChat) { |
| + guard let origin = chat.origin?.absoluteString, |
| + let i = instances.firstIndex(where: { $0.origin == origin }) else { return } |
| + instances[i].archived = instances[i].isArchived ? nil : true |
| + InstancesStore.save(instances) |
| + syncHome() |
| + } |
| + |
| // Live-reorder the rail during a drag: pull the dragged chat out and reinsert it |
| // at the hovered chat's slot. Call persistChats() when the drop lands. |
| func reorder(_ dragging: SavedChat, over target: SavedChat) { |
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift| index a3f5f04..91500cd 100644 |
| --- a/ios/LChatMac/MacHomeSignIn.swift |
| +++ b/ios/LChatMac/MacHomeSignIn.swift |
| @@ -168,6 +168,7 @@ final class MacHomeSignIn: ObservableObject { |
| url: inst.origin, |
| name: inst.groupName.isEmpty ? nil : inst.groupName, |
| starred: inst.isStarred ? true : nil, |
| + archived: inst.isArchived ? true : nil, |
| credentials: .init(token: inst.deviceToken, userPub: inst.userPub, |
| userPriv: inst.userPriv, name: inst.screenName)) |
| } |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index 5f32e68..b507ae5 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -41,6 +41,63 @@ struct NativeChatView: View { |
| private var collapsed: Bool { listWidth < 170 } |
| + // The hidden-title-bar window is only draggable in a thin strip at the |
| + // very top — extend the grab area over the whole chat header and the |
| + // list's "Chats" row (down to the search box), like a real title bar. |
| + // A box (not view state): the event-monitor closure must read live values. |
| + private final class DragZones: ObservableObject { |
| + var feed: CGRect = .zero // the chat header row (global coords) |
| + var list: CGRect = .zero // the sidebar header row above search |
| + var down: NSEvent? // a press waiting to become a window drag |
| + var isActive = true |
| + } |
| + @StateObject private var dragZones = DragZones() |
| + @State private var windowDragMonitor: Any? |
| + |
| + // Drag-on-move, not drag-on-press: the press passes through (buttons and |
| + // menus in the header keep working); only actual movement moves the |
| + // window. Double-click zooms, like a title bar. |
| + private func installWindowDragMonitor() { |
| + let zones = dragZones |
| + windowDragMonitor = NSEvent.addLocalMonitorForEvents( |
| + matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp] |
| + ) { event in |
| + guard zones.isActive, let window = event.window, |
| + let content = window.contentView else { return event } |
| + switch event.type { |
| + case .leftMouseDown: |
| + let p = event.locationInWindow |
| + let point = CGPoint(x: p.x, y: content.bounds.height - p.y) |
| + guard zones.feed.contains(point) || zones.list.contains(point) else { return event } |
| + if event.clickCount == 2 { |
| + window.zoom(nil) |
| + return nil |
| + } |
| + zones.down = event |
| + return event |
| + case .leftMouseDragged: |
| + if let down = zones.down { |
| + zones.down = nil |
| + window.performDrag(with: down) |
| + return nil |
| + } |
| + return event |
| + default: |
| + zones.down = nil |
| + return event |
| + } |
| + } |
| + } |
| + |
| + // Publishes a header row's window frame into the drag-zone box. |
| + private func dragZoneReporter(_ assign: @escaping (CGRect) -> Void) -> some View { |
| + GeometryReader { g in |
| + Color.clear |
| + .onAppear { assign(g.frame(in: .global)) } |
| + .onChange(of: g.frame(in: .global)) { _, f in assign(f) } |
| + } |
| + } |
| + |
| var body: some View { |
| HStack(spacing: 0) { |
| VStack(spacing: 0) { |
| @@ -79,15 +136,22 @@ struct NativeChatView: View { |
| threads: store.threads.filter { !$0.hidden }, |
| myUserId: store.myUserId) |
| } |
| - .onDisappear { liveSocket?.stop() } |
| + .onDisappear { |
| + liveSocket?.stop() |
| + if let windowDragMonitor { NSEvent.removeMonitor(windowDragMonitor) } |
| + windowDragMonitor = nil |
| + } |
| // ⌘K: the active native surface is the palette's chat source (the |
| // webview's lchatPaletteItems equivalent), and openThread requests |
| // from the palette land here. |
| .onAppear { |
| publishPaletteChats() |
| refreshNotificationStatus() |
| + dragZones.isActive = isActive |
| + installWindowDragMonitor() |
| } |
| .onChange(of: isActive) { _, active in |
| + dragZones.isActive = active |
| if active { |
| publishPaletteChats() |
| } else if app.nativeChatsOwner == instance.id { |
| @@ -406,6 +470,8 @@ struct NativeChatView: View { |
| // A hair shorter than the side-head, so its border sits 1pt above the |
| // search box's top edge. |
| .frame(height: Self.headerHeight - 1) |
| + // The header doubles as title bar: drag here moves the window. |
| + .background(dragZoneReporter { dragZones.feed = $0 }) |
| } |
| @State private var membersSheetThread: ChatThread? |
| @@ -542,6 +608,8 @@ struct NativeChatView: View { |
| } |
| .padding(.horizontal, 12) |
| .frame(height: Self.headerHeight) |
| + // Doubles as title bar, down to the search box's top edge. |
| + .background(dragZoneReporter { dragZones.list = $0 }) |
| // Thread search — like the web's, closed chats surface here too, |
| // so search is also how a closed chat gets found and reopened. |
| // Sits right under the header's border. |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 3d43cc3..79972bc 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -573,7 +573,20 @@ struct NativeFeedView: View { |
| mimeType: att.type, isImage: att.isImage, |
| fetch: { await model.blobData(att) }) |
| }, |
| - unfurl: { url in await model.unfurl(url) }, |
| + unfurl: { url in |
| + let card = await model.unfurl(url) |
| + // The link card loads AFTER the send pin ran and |
| + // grows the bubble below the viewport — if this |
| + // is the last row and the reader is at the |
| + // bottom, re-pin so the posted link stays |
| + // visible (verified, like every other pin). |
| + if card != nil, m.id == model.messages.last?.id, |
| + let f = frames.rows[m.id], |
| + f.minY < frames.viewport.height + 150 { |
| + DispatchQueue.main.async { pinUntilSettled(proxy) } |
| + } |
| + return card |
| + }, |
| onYouTube: { videoID, start, frame in |
| app.youtubeLightbox = YouTubeLightboxItem(videoID: videoID, start: start, |
| sourceFrame: frame) |
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift| index 40f59ae..82be11f 100644 |
| --- a/ios/LChatMac/Sidebar.swift |
| +++ b/ios/LChatMac/Sidebar.swift |
| @@ -6,39 +6,40 @@ import UniformTypeIdentifiers |
| // The native sidebar as a Discord/Slack-style server rail: one circular icon per |
| // saved instance — its server photo when set, else a coloured initial — the |
| // selected icon morphs to a squircle with a leading pill indicator, and a trailing |
| -// "+" adds one. |
| +// "…" menu holds everything else (overflow spaces, archived, add, home). |
| // Threads *within* an instance are still handled by the web UI — this rail is the |
| // native "which instance" switch. Hovering an icon publishes its name + position |
| // via RailTooltipKey, which RootView draws as an instant flyout to the right. |
| struct Sidebar: View { |
| @EnvironmentObject var app: MacAppState |
| @State private var dragging: SavedChat? |
| - // The folded (unstarred) icons, revealed by the "More" toggle. Auto-opens |
| - // when the selected chat lives in the fold, so you never lose your place. |
| - @State private var showMore = false |
| - private var starredChats: [SavedChat] { app.chats.filter { app.isStarred($0) } } |
| - private var otherChats: [SavedChat] { app.chats.filter { !app.isStarred($0) } } |
| + // The rail shows favourites ONLY when any exist; otherwise up to nine |
| + // spaces. Everything else lives in the "…" menu — archived spaces at its |
| + // bottom — matching the home page's starred-first model. |
| + static let railCap = 9 |
| + |
| + private var activeChats: [SavedChat] { app.chats.filter { !app.isArchived($0) } } |
| + private var starredChats: [SavedChat] { activeChats.filter { app.isStarred($0) } } |
| + private var railChats: [SavedChat] { |
| + starredChats.isEmpty ? Array(activeChats.prefix(Self.railCap)) : starredChats |
| + } |
| + private var overflowChats: [SavedChat] { |
| + starredChats.isEmpty ? Array(activeChats.dropFirst(Self.railCap)) |
| + : activeChats.filter { !app.isStarred($0) } |
| + } |
| + private var archivedChats: [SavedChat] { app.chats.filter { app.isArchived($0) } } |
| var body: some View { |
| - // Once anything is starred, the rail shows only the stars; the rest fold |
| - // behind "More" (matching the home page + iOS drawer). Nothing starred → |
| - // the flat rail, exactly as before. |
| - let folded = !starredChats.isEmpty |
| - return ScrollView { |
| + ScrollView { |
| VStack(spacing: 10) { |
| - ForEach(folded ? starredChats : app.chats) { chat in |
| + ForEach(railChats) { chat in |
| icon(chat) |
| } |
| - if folded, !otherChats.isEmpty { |
| - moreButton |
| - if showMore { |
| - ForEach(otherChats) { chat in |
| - icon(chat) |
| - } |
| - } |
| - } |
| - addButton |
| + // Always present: besides the overflow it carries Add a Chat |
| + // and the home link (the big "+" it replaces looked heavy under |
| + // the ellipsis). |
| + moreMenu |
| } |
| // Top inset clears the floating traffic-light controls (title bar hidden). |
| .padding(.top, 34) |
| @@ -54,11 +55,6 @@ struct Sidebar: View { |
| // Keyed by the SET of chat ids (not the array) so reordering doesn't re-run |
| // the name fetch — only adding/removing an instance does. |
| .task(id: Set(app.chats.map(\.id))) { await loadNames() } |
| - // Selecting something folded pops the fold open (and keeps it open). |
| - .onChange(of: app.selection) { _, sel in |
| - if otherChats.contains(where: { $0.id == sel }) { showMore = true } |
| - } |
| - .onAppear { showMore = otherChats.contains { $0.id == app.selection } } |
| } |
| private func icon(_ chat: SavedChat) -> some View { |
| @@ -71,10 +67,11 @@ struct Sidebar: View { |
| ) { app.selection = chat.id } |
| .opacity(dragging?.id == chat.id ? 0.5 : 1) |
| .contextMenu { |
| - // Star/Unstar rides the native identity; a never-opened chat has |
| - // none yet, so the item only appears once there's something to pin. |
| + // Star/Archive ride the native identity; a never-opened chat has |
| + // none yet, so the items only appear once there's something to pin. |
| if app.canStar(chat) { |
| Button(app.isStarred(chat) ? "Unstar" : "Star") { app.toggleStar(chat) } |
| + Button(app.isArchived(chat) ? "Unarchive" : "Archive") { app.toggleArchive(chat) } |
| } |
| Button("Remove", role: .destructive) { app.remove(chat) } |
| } |
| @@ -85,11 +82,30 @@ struct Sidebar: View { |
| .onDrop(of: [.text], delegate: ServerDropDelegate(item: chat, dragging: $dragging, app: app)) |
| } |
| - // The fold toggle between the starred rail and everything else — a quiet |
| - // ellipsis circle, Discord's "more servers" idiom. |
| - private var moreButton: some View { |
| - Button { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { showMore.toggle() } } label: { |
| - Image(systemName: showMore ? "chevron.up" : "ellipsis") |
| + // Everything not on the rail, behind a quiet ellipsis circle (Discord's |
| + // "more servers" idiom) — as a MENU, not an inline fold: click a space to |
| + // open it, or use its submenu to star/archive; archived spaces group at |
| + // the bottom, and the account itself (home) is one click away. |
| + private var moreMenu: some View { |
| + Menu { |
| + ForEach(overflowChats) { chat in |
| + chatMenuItem(chat) |
| + } |
| + if !archivedChats.isEmpty { |
| + Divider() |
| + Menu("Archived") { |
| + ForEach(archivedChats) { chat in |
| + chatMenuItem(chat) |
| + } |
| + } |
| + } |
| + Divider() |
| + Button("Add a Chat…") { app.showAdd = true } |
| + Button("Manage Spaces on Eleven…") { |
| + NSWorkspace.shared.open(URL(string: "https://home.elevenmessenger.com/")!) |
| + } |
| + } label: { |
| + Image(systemName: "ellipsis") |
| .font(.callout.weight(.semibold)) |
| .foregroundStyle(.secondary) |
| .frame(width: 34, height: 34) |
| @@ -97,26 +113,28 @@ struct Sidebar: View { |
| .frame(maxWidth: .infinity) |
| .contentShape(Circle()) |
| } |
| + .menuStyle(.button) |
| .buttonStyle(.plain) |
| - .railTooltip(showMore ? "Less" : "More") |
| + .menuIndicator(.hidden) |
| + .railTooltip("More") |
| } |
| - private var addButton: some View { |
| - Button { app.showAdd = true } label: { |
| - Image(systemName: "plus") |
| - .font(.title3.weight(.semibold)) |
| - .foregroundStyle(.secondary) |
| - .frame(width: 46, height: 46) |
| - .background( |
| - RoundedRectangle(cornerRadius: 23, style: .continuous) |
| - .fill(Color.secondary.opacity(0.15)) |
| - ) |
| - .frame(maxWidth: .infinity) |
| + // A space as a menu row: click opens it; the submenu holds star/archive |
| + // (identity-gated, like the icon context menu). |
| + @ViewBuilder private func chatMenuItem(_ chat: SavedChat) -> some View { |
| + if app.canStar(chat) { |
| + Menu(app.serverName(chat)) { |
| + Button(app.isStarred(chat) ? "Unstar" : "Star") { app.toggleStar(chat) } |
| + Button(app.isArchived(chat) ? "Unarchive" : "Archive") { app.toggleArchive(chat) } |
| + } primaryAction: { |
| + app.selection = chat.id |
| + } |
| + } else { |
| + Button(app.serverName(chat)) { app.selection = chat.id } |
| } |
| - .buttonStyle(.plain) |
| - .railTooltip("Add a chat") |
| } |
| + |
| // Per-instance branding for the rail: the friendly name (icon initial + tooltip) |
| // and the server photo. /api/group is public (no token) — name + whether a photo |
| // is set are server-known metadata, not E2EE content — so this works for |