eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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