eleven / messenger Public

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

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
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.
261private 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.
319private 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.
341private 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.
357private 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
368private extension View {
369 func railTooltip(_ text: String) -> some View { modifier(RailTooltipHover(text: text)) }
370}
371