| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | import AppKit |
| 4 | import UniformTypeIdentifiers |
| 5 | |
| 6 | // The native sidebar as a Discord/Slack-style server rail: one circular icon per |
| 7 | // saved instance — its server photo when set, else a coloured initial — the |
| 8 | // selected icon morphs to a squircle with a leading pill indicator, and a trailing |
| 9 | // "…" menu holds everything else (overflow spaces, archived, add, home). |
| 10 | // Threads *within* an instance are still handled by the web UI — this rail is the |
| 11 | // native "which instance" switch. Hovering an icon publishes its name + position |
| 12 | // via RailTooltipKey, which RootView draws as an instant flyout to the right. |
| 13 | struct Sidebar: View { |
| 14 | @EnvironmentObject var app: MacAppState |
| 15 | @State private var dragging: SavedChat? |
| 16 | // Sign out from the rail's "…" menu — same full-reset semantics as the |
| 17 | // Settings button (MacHomeSignIn.signOut: final push, wipe, relaunch). |
| 18 | @StateObject private var signOut = MacHomeSignIn() |
| 19 | @State private var confirmSignOut = false |
| 20 | // Create-a-space: the rail's "+" (shown when home offers provisioning and |
| 21 | // this Mac is signed in with at least one space — the web's gate). The |
| 22 | // takeover itself is RootView's (app.requestCreateSpace bumps the tick). |
| 23 | @State private var spacesOffered = false |
| 24 | |
| 25 | // The rail shows favourites ONLY when any exist; otherwise up to nine |
| 26 | // spaces. Everything else lives in the "…" menu — archived spaces at its |
| 27 | // bottom — matching the home page's starred-first model. |
| 28 | static let railCap = 9 |
| 29 | |
| 30 | private var activeChats: [SavedChat] { app.chats.filter { !app.isArchived($0) } } |
| 31 | private var starredChats: [SavedChat] { activeChats.filter { app.isStarred($0) } } |
| 32 | private var railChats: [SavedChat] { |
| 33 | starredChats.isEmpty ? Array(activeChats.prefix(Self.railCap)) : starredChats |
| 34 | } |
| 35 | private var overflowChats: [SavedChat] { |
| 36 | starredChats.isEmpty ? Array(activeChats.dropFirst(Self.railCap)) |
| 37 | : activeChats.filter { !app.isStarred($0) } |
| 38 | } |
| 39 | private var archivedChats: [SavedChat] { app.chats.filter { app.isArchived($0) } } |
| 40 | |
| 41 | var body: some View { |
| 42 | ScrollView { |
| 43 | VStack(spacing: 10) { |
| 44 | ForEach(railChats) { chat in |
| 45 | icon(chat) |
| 46 | } |
| 47 | // Always present: besides the overflow it carries the home link |
| 48 | // and Sign Out. |
| 49 | moreMenu |
| 50 | // The "+" means exactly one thing: create a new space (the |
| 51 | // goes-to-eleven flow) — joining stays on the web/sign-in. |
| 52 | if spacesOffered && signOut.sessionExists && !app.instances.isEmpty { |
| 53 | createButton |
| 54 | } |
| 55 | } |
| 56 | // Top inset clears the floating traffic-light controls (title bar hidden). |
| 57 | .padding(.top, 34) |
| 58 | .padding(.bottom, 12) |
| 59 | .frame(maxWidth: .infinity) |
| 60 | // Catch a drop that lands in the rail's gaps (not on an icon) so the drag |
| 61 | // state resets and the reordered list is saved. |
| 62 | .onDrop(of: [.text], isTargeted: nil) { _ in |
| 63 | if dragging != nil { app.persistChats(); dragging = nil } |
| 64 | return true |
| 65 | } |
| 66 | } |
| 67 | // Keyed by the SET of chat ids (not the array) so reordering doesn't re-run |
| 68 | // the name fetch — only adding/removing an instance does. |
| 69 | .task(id: Set(app.chats.map(\.id))) { await loadNames() } |
| 70 | // Sign-ins happen on other surfaces (first-run, Settings, the browser |
| 71 | // handoff) — re-check whenever the rail shows so the item isn't stale. |
| 72 | .onAppear { signOut.refreshSession() } |
| 73 | // Whether home offers space creation (a paired provisioner) — checked |
| 74 | // once per instance-set change; false hides the "+" entirely. |
| 75 | .task(id: "\(app.instances.isEmpty)-\(signOut.sessionExists)") { |
| 76 | guard signOut.sessionExists, !app.instances.isEmpty else { return } |
| 77 | spacesOffered = await HomeClient().features() |
| 78 | } |
| 79 | // Re-check the features gate whenever the app comes forward, so one |
| 80 | // flaky fetch can't hide the "+" for the whole session. |
| 81 | .onReceive(NotificationCenter.default.publisher( |
| 82 | for: NSApplication.didBecomeActiveNotification)) { _ in |
| 83 | guard !spacesOffered, signOut.sessionExists, !app.instances.isEmpty else { return } |
| 84 | Task { spacesOffered = await HomeClient().features() } |
| 85 | } |
| 86 | .confirmationDialog( |
| 87 | "Sign out? Your spaces, messages and keys are removed from this Mac. Sign back in to get them back.", |
| 88 | isPresented: $confirmSignOut, titleVisibility: .visible |
| 89 | ) { |
| 90 | Button("Sign Out & Clear This Mac", role: .destructive) { |
| 91 | Task { await signOut.signOut(from: app) } |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | private func icon(_ chat: SavedChat) -> some View { |
| 97 | ServerIcon( |
| 98 | initial: app.initials()[chat.id] ?? "?", |
| 99 | image: app.avatars[chat.id], |
| 100 | hue: app.hue(for: chat.id), |
| 101 | selected: app.selection == chat.id, |
| 102 | tooltip: app.serverName(chat) |
| 103 | ) { app.selection = chat.id } |
| 104 | .opacity(dragging?.id == chat.id ? 0.5 : 1) |
| 105 | .contextMenu { |
| 106 | // Star/Archive ride the native identity; a never-opened chat has |
| 107 | // none yet, so the items only appear once there's something to pin. |
| 108 | if app.canStar(chat) { |
| 109 | Button(app.isStarred(chat) ? "Unstar" : "Star") { app.toggleStar(chat) } |
| 110 | Button(app.isArchived(chat) ? "Unarchive" : "Archive") { app.toggleArchive(chat) } |
| 111 | } |
| 112 | Button("Remove", role: .destructive) { app.remove(chat) } |
| 113 | } |
| 114 | .onDrag { |
| 115 | dragging = chat |
| 116 | return NSItemProvider(object: "\(chat.id)" as NSString) |
| 117 | } |
| 118 | .onDrop(of: [.text], delegate: ServerDropDelegate(item: chat, dragging: $dragging, app: app)) |
| 119 | } |
| 120 | |
| 121 | // The rail's "+": create a new space. Same quiet-circle chrome as the "…". |
| 122 | private var createButton: some View { |
| 123 | Button { |
| 124 | app.requestCreateSpace() |
| 125 | } label: { |
| 126 | Image(systemName: "plus") |
| 127 | .font(.callout.weight(.semibold)) |
| 128 | .foregroundStyle(.secondary) |
| 129 | .frame(width: 34, height: 34) |
| 130 | .background(Circle().fill(Color.secondary.opacity(0.12))) |
| 131 | .frame(maxWidth: .infinity) |
| 132 | .contentShape(Circle()) |
| 133 | } |
| 134 | .buttonStyle(.plain) |
| 135 | .railTooltip("Create a New Space") |
| 136 | } |
| 137 | |
| 138 | // Everything not on the rail, behind a quiet ellipsis circle (Discord's |
| 139 | // "more servers" idiom) — as a MENU, not an inline fold: click a space to |
| 140 | // open it, or use its submenu to star/archive; archived spaces group at |
| 141 | // the bottom, and the account itself (home) is one click away. |
| 142 | private var moreMenu: some View { |
| 143 | Menu { |
| 144 | ForEach(overflowChats) { chat in |
| 145 | chatMenuItem(chat) |
| 146 | } |
| 147 | if !archivedChats.isEmpty { |
| 148 | Divider() |
| 149 | Menu("Archived") { |
| 150 | ForEach(archivedChats) { chat in |
| 151 | chatMenuItem(chat) |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | Divider() |
| 156 | Button("Manage Spaces on Eleven…") { |
| 157 | NSWorkspace.shared.open(HomeBase.url) |
| 158 | } |
| 159 | // Gated on a LIVE Keychain check, not the published flag: menu |
| 160 | // content evaluates at open, and the flag went stale when sign-in |
| 161 | // happened on another surface (the browser handoff) after the |
| 162 | // rail's one onAppear refresh — hiding Sign Out entirely. |
| 163 | if HomeSessionStore.load() != nil { |
| 164 | Button("Sign Out…") { confirmSignOut = true } |
| 165 | } |
| 166 | } label: { |
| 167 | Image(systemName: "ellipsis") |
| 168 | .font(.callout.weight(.semibold)) |
| 169 | .foregroundStyle(.secondary) |
| 170 | .frame(width: 34, height: 34) |
| 171 | .background(Circle().fill(Color.secondary.opacity(0.12))) |
| 172 | .frame(maxWidth: .infinity) |
| 173 | .contentShape(Circle()) |
| 174 | } |
| 175 | .menuStyle(.button) |
| 176 | .buttonStyle(.plain) |
| 177 | .menuIndicator(.hidden) |
| 178 | .railTooltip("More") |
| 179 | } |
| 180 | |
| 181 | // A space as a menu row — its face beside its name. The row itself is |
| 182 | // INERT (hover/click just reveals the submenu); Open is the only opener, |
| 183 | // then star/archive (identity-gated, like the icon context menu). |
| 184 | @ViewBuilder private func chatMenuItem(_ chat: SavedChat) -> some View { |
| 185 | if app.canStar(chat) { |
| 186 | Menu { |
| 187 | Button("Open") { app.selection = chat.id } |
| 188 | Divider() |
| 189 | Button(app.isStarred(chat) ? "Unstar" : "Star") { app.toggleStar(chat) } |
| 190 | Button(app.isArchived(chat) ? "Unarchive" : "Archive") { app.toggleArchive(chat) } |
| 191 | } label: { |
| 192 | menuRowLabel(chat) |
| 193 | } |
| 194 | } else { |
| 195 | Button { app.selection = chat.id } label: { menuRowLabel(chat) } |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | private func menuRowLabel(_ chat: SavedChat) -> some View { |
| 200 | Label { |
| 201 | Text(app.serverName(chat)) |
| 202 | } icon: { |
| 203 | if let icon = menuIcon(chat) { Image(nsImage: icon) } |
| 204 | } |
| 205 | // macOS menus render title-only by default — the face needs an |
| 206 | // explicit titleAndIcon or it's silently dropped. |
| 207 | .labelStyle(.titleAndIcon) |
| 208 | } |
| 209 | |
| 210 | // A small circular face for the menu row: the server photo, else the same |
| 211 | // monogram the notification faces use (MonogramImage — name-hashed colour, |
| 212 | // first initial). Menus want a fixed-size template-ish image, so draw the |
| 213 | // circle crop here rather than fighting SwiftUI clipping inside NSMenu. |
| 214 | private func menuIcon(_ chat: SavedChat) -> NSImage? { |
| 215 | let source: NSImage? |
| 216 | if let photo = app.avatars[chat.id] { |
| 217 | source = photo |
| 218 | } else if let data = MonogramImage.pngData(name: app.serverName(chat), size: 64) { |
| 219 | source = NSImage(data: data) |
| 220 | } else { |
| 221 | source = nil |
| 222 | } |
| 223 | guard let source else { return nil } |
| 224 | let side: CGFloat = 18 |
| 225 | let img = NSImage(size: NSSize(width: side, height: side)) |
| 226 | img.lockFocus() |
| 227 | NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: side, height: side)).addClip() |
| 228 | source.draw(in: NSRect(x: 0, y: 0, width: side, height: side), |
| 229 | from: .zero, operation: .sourceOver, fraction: 1) |
| 230 | img.unlockFocus() |
| 231 | return img |
| 232 | } |
| 233 | |
| 234 | |
| 235 | // Per-instance branding for the rail: the friendly name (icon initial + tooltip) |
| 236 | // and the server photo. /api/group is public (no token) — name + whether a photo |
| 237 | // is set are server-known metadata, not E2EE content — so this works for |
| 238 | // new-format chats too (where `session` is nil). Falls back to the host until it |
| 239 | // loads. Only fetches the photo bytes when /api/group says one exists, and skips |
| 240 | // instances whose photo we already have. |
| 241 | private func loadNames() async { |
| 242 | for chat in app.chats { |
| 243 | guard let base = chat.origin else { continue } |
| 244 | let api = LChatAPI(baseURL: base, token: chat.session?.token ?? "") |
| 245 | let info = await api.serverInfo() |
| 246 | if !info.name.isEmpty { app.names[chat.id] = info.name } |
| 247 | if info.hasAvatar { |
| 248 | if app.avatars[chat.id] == nil, let data = await api.serverAvatar(), |
| 249 | let image = NSImage(data: data) { |
| 250 | app.avatars[chat.id] = image |
| 251 | } |
| 252 | } else { |
| 253 | app.avatars[chat.id] = nil // photo removed on the server — drop back to the initial |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | // One server-rail icon. Circle by default; on hover/selection it morphs toward a |
| 260 | // squircle and grows a leading pill indicator, the way Slack/Discord rails do. |
| 261 | private struct ServerIcon: View { |
| 262 | let initial: String |
| 263 | var image: NSImage? = nil |
| 264 | let hue: Double |
| 265 | let selected: Bool |
| 266 | let tooltip: String |
| 267 | let action: () -> Void |
| 268 | @State private var hovered = false |
| 269 | |
| 270 | // Circle at rest, morphing toward a squircle on hover/selection — the icon and |
| 271 | // (when present) the server photo share this shape so the photo clips to it. |
| 272 | private var shape: RoundedRectangle { |
| 273 | RoundedRectangle(cornerRadius: selected || hovered ? 15 : 23, style: .continuous) |
| 274 | } |
| 275 | |
| 276 | var body: some View { |
| 277 | // A plain tappable view — NOT a Button — so the rail's `.onDrag` actually |
| 278 | // starts a drag. On macOS a Button's press gesture swallows the drag, which |
| 279 | // is why drag-to-reorder didn't work while this was a Button. |
| 280 | Group { |
| 281 | if let image { |
| 282 | Image(nsImage: image) |
| 283 | .resizable() |
| 284 | .interpolation(.high) |
| 285 | .scaledToFill() |
| 286 | .frame(width: 46, height: 46) |
| 287 | .clipShape(shape) |
| 288 | } else { |
| 289 | Text(initial) |
| 290 | .font(.title3.weight(.semibold)) |
| 291 | .lineLimit(1) |
| 292 | .minimumScaleFactor(0.6) |
| 293 | .foregroundStyle(.white) |
| 294 | .frame(width: 46, height: 46) |
| 295 | .background(shape.fill(Color(hue: hue, saturation: 0.55, brightness: 0.78))) |
| 296 | } |
| 297 | } |
| 298 | .frame(maxWidth: .infinity) |
| 299 | .overlay(alignment: .leading) { |
| 300 | // Discord-style selection pill: flush against the rail's left edge, |
| 301 | // rounded on the right only (not a floating capsule). Hidden when |
| 302 | // idle, a short nub on hover, tall when selected. |
| 303 | UnevenRoundedRectangle(bottomTrailingRadius: 3, topTrailingRadius: 3, style: .continuous) |
| 304 | .fill(Color.primary.opacity(0.85)) |
| 305 | .frame(width: 4, height: selected ? 26 : (hovered ? 12 : 0)) |
| 306 | } |
| 307 | .contentShape(Rectangle()) |
| 308 | .onTapGesture(perform: action) |
| 309 | .onHover { hovered = $0 } |
| 310 | .animation(.snappy(duration: 0.18), value: selected) |
| 311 | .animation(.snappy(duration: 0.18), value: hovered) |
| 312 | .background(RailTooltipEmitter(text: tooltip, active: hovered)) |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | // Drag-to-reorder for the rail. The dragged instance is tracked in `dragging` (set |
| 317 | // by onDrag); as it passes over another icon we live-reorder app.chats, and the new |
| 318 | // order is persisted when the drop lands. |
| 319 | private struct ServerDropDelegate: DropDelegate { |
| 320 | let item: SavedChat |
| 321 | @Binding var dragging: SavedChat? |
| 322 | let app: MacAppState |
| 323 | |
| 324 | func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: .move) } |
| 325 | |
| 326 | func dropEntered(info: DropInfo) { |
| 327 | guard let d = dragging, d.id != item.id else { return } |
| 328 | withAnimation(.snappy(duration: 0.2)) { app.reorder(d, over: item) } |
| 329 | } |
| 330 | |
| 331 | func performDrop(info: DropInfo) -> Bool { |
| 332 | app.persistChats() |
| 333 | dragging = nil |
| 334 | return true |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | // Publishes the hovered view's tooltip text + vertical midpoint (in the rail |
| 339 | // coordinate space) so RootView can draw the flyout. Contributes nothing when the |
| 340 | // view isn't hovered. |
| 341 | private struct RailTooltipEmitter: View { |
| 342 | let text: String |
| 343 | let active: Bool |
| 344 | |
| 345 | var body: some View { |
| 346 | GeometryReader { geo in |
| 347 | Color.clear.preference( |
| 348 | key: RailTooltipKey.self, |
| 349 | value: active ? RailTooltipInfo(text: text, y: geo.frame(in: .named(RailTooltip.space)).midY) : nil |
| 350 | ) |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | // Owns its own hover state for views (like the "+" button) that don't already |
| 356 | // track one; mirrors ServerIcon's emitter otherwise. |
| 357 | private struct RailTooltipHover: ViewModifier { |
| 358 | let text: String |
| 359 | @State private var hovered = false |
| 360 | |
| 361 | func body(content: Content) -> some View { |
| 362 | content |
| 363 | .onHover { hovered = $0 } |
| 364 | .background(RailTooltipEmitter(text: text, active: hovered)) |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | private extension View { |
| 369 | func railTooltip(_ text: String) -> some View { modifier(RailTooltipHover(text: text)) } |
| 370 | } |
| 371 | |