import LChatCore import SwiftUI import AppKit 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 // "…" 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? // Sign out from the rail's "…" menu — same full-reset semantics as the // Settings button (MacHomeSignIn.signOut: final push, wipe, relaunch). @StateObject private var signOut = MacHomeSignIn() @State private var confirmSignOut = false // Create-a-space: the rail's "+" (shown when home offers provisioning and // this Mac is signed in with at least one space — the web's gate). The // takeover itself is RootView's (app.requestCreateSpace bumps the tick). @State private var spacesOffered = false // Gentle update reminders (UpdaterUI): a scheduled Sparkle check that // found an update publishes here instead of popping its dialog. @EnvironmentObject var updates: GentleUpdateReminder // 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 { ScrollView { VStack(spacing: 10) { ForEach(railChats) { chat in icon(chat) } // Always present: besides the overflow it carries the home link // and Sign Out. moreMenu // The "+" means exactly one thing: create a new space (the // goes-to-eleven flow) — joining stays on the web/sign-in. if spacesOffered && signOut.sessionExists && !app.instances.isEmpty { createButton } // The unobtrusive update indicator (the Rogue Amoeba shape): // sits in the rail's flow like any other control — never a // dialog, never floating — until clicked or the update ships. if let version = updates.availableVersion { updateButton(version) } } // Top inset clears the floating traffic-light controls (title bar hidden). .padding(.top, 34) .padding(.bottom, 12) .frame(maxWidth: .infinity) // Catch a drop that lands in the rail's gaps (not on an icon) so the drag // state resets and the reordered list is saved. .onDrop(of: [.text], isTargeted: nil) { _ in if dragging != nil { app.persistChats(); dragging = nil } return true } } // 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() } // Sign-ins happen on other surfaces (first-run, Settings, the browser // handoff) — re-check whenever the rail shows so the item isn't stale. .onAppear { signOut.refreshSession() } // Whether home offers space creation (a paired provisioner) — checked // once per instance-set change; false hides the "+" entirely. .task(id: "\(app.instances.isEmpty)-\(signOut.sessionExists)") { guard signOut.sessionExists, !app.instances.isEmpty else { return } spacesOffered = await HomeClient().features() } // Re-check the features gate whenever the app comes forward, so one // flaky fetch can't hide the "+" for the whole session. .onReceive(NotificationCenter.default.publisher( for: NSApplication.didBecomeActiveNotification)) { _ in guard !spacesOffered, signOut.sessionExists, !app.instances.isEmpty else { return } Task { spacesOffered = await HomeClient().features() } } .confirmationDialog( "Sign out? Your spaces, messages and keys are removed from this Mac. Sign back in to get them back.", isPresented: $confirmSignOut, titleVisibility: .visible ) { Button("Sign Out & Clear This Mac", role: .destructive) { Task { await signOut.signOut(from: app) } } } } private func icon(_ chat: SavedChat) -> some View { ServerIcon( initial: app.initials()[chat.id] ?? "?", image: app.avatars[chat.id], hue: app.hue(for: chat.id), selected: app.selection == chat.id, tooltip: app.serverName(chat) ) { app.selection = chat.id } .opacity(dragging?.id == chat.id ? 0.5 : 1) .contextMenu { // 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) } } .onDrag { dragging = chat return NSItemProvider(object: "\(chat.id)" as NSString) } .onDrop(of: [.text], delegate: ServerDropDelegate(item: chat, dragging: $dragging, app: app)) } // The rail's "+": create a new space. Same quiet-circle chrome as the "…". private var createButton: some View { Button { app.requestCreateSpace() } label: { Image(systemName: "plus") .font(.callout.weight(.semibold)) .foregroundStyle(.secondary) .frame(width: 34, height: 34) .background(Circle().fill(Color.secondary.opacity(0.12))) .frame(maxWidth: .infinity) .contentShape(Circle()) } .buttonStyle(.plain) .railTooltip("Create a New Space") } // The rail's update indicator: same quiet-circle chrome as "+" and "…", // tinted so it reads as news rather than another verb — but the tint is // never the only signal (the arrow glyph + tooltip carry it too). Click // hands over to Sparkle's standard, now user-initiated, flow. private func updateButton(_ version: String) -> some View { Button { updates.install() } label: { Image(systemName: "arrow.down.circle.fill") .font(.callout.weight(.semibold)) .foregroundStyle(Color.accentColor) .frame(width: 34, height: 34) .background(Circle().fill(Color.accentColor.opacity(0.15))) .frame(maxWidth: .infinity) .contentShape(Circle()) } .buttonStyle(.plain) .railTooltip("Update Available — Eleven \(version)") .accessibilityLabel("Update available: Eleven \(version)") } // 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("Manage Spaces on Eleven…") { NSWorkspace.shared.open(HomeBase.url) } // Gated on a LIVE Keychain check, not the published flag: menu // content evaluates at open, and the flag went stale when sign-in // happened on another surface (the browser handoff) after the // rail's one onAppear refresh — hiding Sign Out entirely. if HomeSessionStore.load() != nil { Button("Sign Out…") { confirmSignOut = true } } } label: { Image(systemName: "ellipsis") .font(.callout.weight(.semibold)) .foregroundStyle(.secondary) .frame(width: 34, height: 34) .background(Circle().fill(Color.secondary.opacity(0.12))) .frame(maxWidth: .infinity) .contentShape(Circle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .railTooltip("More") } // A space as a menu row — its face beside its name. The row itself is // INERT (hover/click just reveals the submenu); Open is the only opener, // then star/archive (identity-gated, like the icon context menu). @ViewBuilder private func chatMenuItem(_ chat: SavedChat) -> some View { if app.canStar(chat) { Menu { Button("Open") { app.selection = chat.id } Divider() Button(app.isStarred(chat) ? "Unstar" : "Star") { app.toggleStar(chat) } Button(app.isArchived(chat) ? "Unarchive" : "Archive") { app.toggleArchive(chat) } } label: { menuRowLabel(chat) } } else { Button { app.selection = chat.id } label: { menuRowLabel(chat) } } } private func menuRowLabel(_ chat: SavedChat) -> some View { Label { Text(app.serverName(chat)) } icon: { if let icon = menuIcon(chat) { Image(nsImage: icon) } } // macOS menus render title-only by default — the face needs an // explicit titleAndIcon or it's silently dropped. .labelStyle(.titleAndIcon) } // A small circular face for the menu row: the server photo, else the same // monogram the notification faces use (MonogramImage — name-hashed colour, // first initial). Menus want a fixed-size template-ish image, so draw the // circle crop here rather than fighting SwiftUI clipping inside NSMenu. private func menuIcon(_ chat: SavedChat) -> NSImage? { let source: NSImage? if let photo = app.avatars[chat.id] { source = photo } else if let data = MonogramImage.pngData(name: app.serverName(chat), size: 64) { source = NSImage(data: data) } else { source = nil } guard let source else { return nil } let side: CGFloat = 18 let img = NSImage(size: NSSize(width: side, height: side)) img.lockFocus() NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: side, height: side)).addClip() source.draw(in: NSRect(x: 0, y: 0, width: side, height: side), from: .zero, operation: .sourceOver, fraction: 1) img.unlockFocus() return img } // 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 // new-format chats too (where `session` is nil). Falls back to the host until it // loads. Only fetches the photo bytes when /api/group says one exists, and skips // instances whose photo we already have. private func loadNames() async { for chat in app.chats { guard let base = chat.origin else { continue } let api = LChatAPI(baseURL: base, token: chat.session?.token ?? "") let info = await api.serverInfo() if !info.name.isEmpty { app.names[chat.id] = info.name } if info.hasAvatar { if app.avatars[chat.id] == nil, let data = await api.serverAvatar(), let image = NSImage(data: data) { app.avatars[chat.id] = image } } else { app.avatars[chat.id] = nil // photo removed on the server — drop back to the initial } } } } // One server-rail icon. Circle by default; on hover/selection it morphs toward a // squircle and grows a leading pill indicator, the way Slack/Discord rails do. private struct ServerIcon: View { let initial: String var image: NSImage? = nil let hue: Double let selected: Bool let tooltip: String let action: () -> Void @State private var hovered = false // Circle at rest, morphing toward a squircle on hover/selection — the icon and // (when present) the server photo share this shape so the photo clips to it. private var shape: RoundedRectangle { RoundedRectangle(cornerRadius: selected || hovered ? 15 : 23, style: .continuous) } var body: some View { // A plain tappable view — NOT a Button — so the rail's `.onDrag` actually // starts a drag. On macOS a Button's press gesture swallows the drag, which // is why drag-to-reorder didn't work while this was a Button. Group { if let image { Image(nsImage: image) .resizable() .interpolation(.high) .scaledToFill() .frame(width: 46, height: 46) .clipShape(shape) } else { Text(initial) .font(.title3.weight(.semibold)) .lineLimit(1) .minimumScaleFactor(0.6) .foregroundStyle(.white) .frame(width: 46, height: 46) .background(shape.fill(Color(hue: hue, saturation: 0.55, brightness: 0.78))) } } .frame(maxWidth: .infinity) .overlay(alignment: .leading) { // Discord-style selection pill: flush against the rail's left edge, // rounded on the right only (not a floating capsule). Hidden when // idle, a short nub on hover, tall when selected. UnevenRoundedRectangle(bottomTrailingRadius: 3, topTrailingRadius: 3, style: .continuous) .fill(Color.primary.opacity(0.85)) .frame(width: 4, height: selected ? 26 : (hovered ? 12 : 0)) } .contentShape(Rectangle()) .onTapGesture(perform: action) .onHover { hovered = $0 } .animation(.snappy(duration: 0.18), value: selected) .animation(.snappy(duration: 0.18), value: hovered) .background(RailTooltipEmitter(text: tooltip, active: hovered)) } } // Drag-to-reorder for the rail. The dragged instance is tracked in `dragging` (set // by onDrag); as it passes over another icon we live-reorder app.chats, and the new // order is persisted when the drop lands. private struct ServerDropDelegate: DropDelegate { let item: SavedChat @Binding var dragging: SavedChat? let app: MacAppState func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: .move) } func dropEntered(info: DropInfo) { guard let d = dragging, d.id != item.id else { return } withAnimation(.snappy(duration: 0.2)) { app.reorder(d, over: item) } } func performDrop(info: DropInfo) -> Bool { app.persistChats() dragging = nil return true } } // Publishes the hovered view's tooltip text + vertical midpoint (in the rail // coordinate space) so RootView can draw the flyout. Contributes nothing when the // view isn't hovered. private struct RailTooltipEmitter: View { let text: String let active: Bool var body: some View { GeometryReader { geo in Color.clear.preference( key: RailTooltipKey.self, value: active ? RailTooltipInfo(text: text, y: geo.frame(in: .named(RailTooltip.space)).midY) : nil ) } } } // Owns its own hover state for views (like the "+" button) that don't already // track one; mirrors ServerIcon's emitter otherwise. private struct RailTooltipHover: ViewModifier { let text: String @State private var hovered = false func body(content: Content) -> some View { content .onHover { hovered = $0 } .background(RailTooltipEmitter(text: text, active: hovered)) } } private extension View { func railTooltip(_ text: String) -> some View { modifier(RailTooltipHover(text: text)) } }