eleven / ocho Public

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>
Paul Campbell 2866c37ad348e3887c960a88457c6da13aa3b08a parent bcdd505
13 files changed, +264 −77
  • ios/LChat/LChatApp.swift +11 −1
  • ios/LChat/Views/InstanceSidebar.swift +24 −3
  • ios/LChat/Views/NativeChatScreen.swift +16 −1
  • ios/LChat/Views/SignInWithEleven.swift +4 −2
  • ios/LChatCore/Sources/LChatCore/HomeSync.swift +1 −0
  • ios/LChatCore/Sources/LChatCore/HomeVault.swift +7 −1
  • ios/LChatCore/Sources/LChatCore/Models.swift +7 −1
  • ios/LChatCore/Sources/LChatCore/TypingBubble.swift +26 −18
  • ios/LChatMac/LChatMacApp.swift +19 −1
  • ios/LChatMac/MacHomeSignIn.swift +1 −0
  • ios/LChatMac/NativeChatView.swift +69 −1
  • ios/LChatMac/NativeFeedView.swift +14 −1
  • ios/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