| index a000afe..2ceb306 100644 |
| --- a/ios/LChatMac/RootView.swift |
| +++ b/ios/LChatMac/RootView.swift |
| @@ -117,28 +117,70 @@ struct LeftArrow: Shape { |
| } |
| } |
| |
| -// ⌘K command palette (Tier 1): fuzzy-jump between servers, plus New Chat. Servers |
| -// are native state, so this needs no webview bridge — chats/threads live in the web |
| -// layer and are a later tier. Drawn as an overlay (like the rail tooltip) so it sits |
| -// above the WKWebView feed; type to filter, ↑/↓ to move, ↩ to open, esc to dismiss. |
| +// What the web layer offers the palette (window.lchatPaletteItems in shell.js): |
| +// the current server's chats plus the actions available to this member right now. |
| +private struct PaletteChat: Decodable, Identifiable { |
| + let id: String |
| + let name: String |
| + let glyph: String? // set for an app-thread (To-do, …); nil = person/group |
| + let active: Bool |
| +} |
| + |
| +private struct PaletteAction: Decodable, Identifiable { |
| + let id: String |
| + let label: String |
| + var danger: Bool? |
| +} |
| + |
| +private struct PaletteWebItems: Decodable { |
| + var chats: [PaletteChat] |
| + var actions: [PaletteAction] |
| +} |
| + |
| +// One flat, keyboard-navigable list mixing all four row kinds. |
| +private enum PaletteRow: Identifiable { |
| + case chat(PaletteChat) |
| + case server(SavedChat) |
| + case action(PaletteAction) |
| + case connect |
| + var id: String { |
| + switch self { |
| + case .chat(let c): return "chat:" + c.id |
| + case .server(let s): return "server:" + s.id |
| + case .action(let a): return "action:" + a.id |
| + case .connect: return "connect" |
| + } |
| + } |
| +} |
| + |
| +// ⌘K command palette: jump to any chat on the current server, run an action |
| +// (new chat, invite someone, theme, …), or switch to another server. Servers are |
| +// native state; the chats and actions come from the web layer over the |
| +// lchatPaletteItems / lchatPaletteRun bridge (shell.js) — labels and behaviour |
| +// keep one source of truth there, native only draws rows and posts the pick |
| +// back. Drawn as an overlay (like the rail tooltip) so it sits above the |
| +// WKWebView feed; type to filter, ↑/↓ to move, ↩ to open, esc to dismiss. |
| struct CommandPalette: View { |
| @EnvironmentObject var app: MacAppState |
| @State private var query = "" |
| @State private var index = 0 |
| + @State private var web = PaletteWebItems(chats: [], actions: []) |
| @FocusState private var focused: Bool |
| |
| - // Recency rank for a chat (0 = most recently opened); unopened sort last. |
| + // Recency rank for a server (0 = most recently opened); unopened sort last. |
| private func recencyRank(_ chat: SavedChat) -> Int { |
| app.recent.firstIndex(of: chat.id) ?? Int.max |
| } |
| |
| + // Other servers, phrased as a switch ("Switch to Paul's team") — the one |
| + // you're on is represented by its chats above, so it isn't offered again. |
| // With no query: most-recently-opened first, then any never-opened in saved |
| - // order. With a query: fuzzy subsequence match on the friendly name or host, |
| - // best score first, ties broken by recency — so "cfc" finds "Campbell family |
| - // chat", and among comparable matches the one used most recently wins. |
| - private var matches: [SavedChat] { |
| - let q = query.filter { !$0.isWhitespace } |
| - let ranked = app.chats.enumerated().map { |
| + // order. With a query: fuzzy subsequence match on the row label or host, best |
| + // score first, ties broken by recency — so "cfc" (or "switch") finds |
| + // "Switch to Campbell family chat", and among comparable matches the one |
| + // used most recently wins. |
| + private func serverMatches(_ q: String) -> [SavedChat] { |
| + let ranked = app.chats.filter { $0.id != app.selection }.enumerated().map { |
| (chat: $0.element, order: $0.offset, recency: recencyRank($0.element)) |
| } |
| if q.isEmpty { |
| @@ -148,7 +190,7 @@ struct CommandPalette: View { |
| } |
| return ranked |
| .compactMap { r -> (chat: SavedChat, score: Int, order: Int, recency: Int)? in |
| - let scores = [fuzzyScore(q, app.serverName(r.chat)), |
| + let scores = [fuzzyScore(q, "Switch to " + app.serverName(r.chat)), |
| fuzzyScore(q, r.chat.origin?.host ?? "")].compactMap { $0 } |
| guard let best = scores.max() else { return nil } |
| return (r.chat, best, r.order, r.recency) |
| @@ -161,6 +203,33 @@ struct CommandPalette: View { |
| .map(\.chat) |
| } |
| |
| + // The full list, in section order: chats on this server (the web layer already |
| + // sorted them pinned-then-recent, capped when idle so servers and actions stay |
| + // in reach), other servers, actions, and "Connect to a new space…" pinned last. |
| + private var rows: [PaletteRow] { |
| + let q = query.filter { !$0.isWhitespace } |
| + var out: [PaletteRow] = [] |
| + if q.isEmpty { |
| + out += web.chats.prefix(6).map { .chat($0) } |
| + } else { |
| + out += web.chats |
| + .compactMap { c in fuzzyScore(q, c.name).map { (chat: c, score: $0) } } |
| + .sorted { $0.score > $1.score } |
| + .map { .chat($0.chat) } |
| + } |
| + out += serverMatches(q).map { .server($0) } |
| + if q.isEmpty { |
| + out += web.actions.map { .action($0) } |
| + } else { |
| + out += web.actions |
| + .compactMap { a in fuzzyScore(q, a.label).map { (action: a, score: $0) } } |
| + .sorted { $0.score > $1.score } |
| + .map { .action($0.action) } |
| + } |
| + out.append(.connect) |
| + return out |
| + } |
| + |
| // Query chars must appear in `text` in order (case-insensitive). Word-boundary |
| // and contiguous matches score higher; nil when it isn't a subsequence at all. |
| private func fuzzyScore(_ query: String, _ text: String) -> Int? { |
| @@ -180,10 +249,10 @@ struct CommandPalette: View { |
| } |
| return qi == q.count ? score : nil |
| } |
| - private var rowCount: Int { matches.count + 1 } // servers + "New Chat…" |
| |
| var body: some View { |
| let inits = app.initials() |
| + let rows = self.rows |
| return ZStack(alignment: .top) { |
| Color.black.opacity(0.2).ignoresSafeArea() |
| .onTapGesture { close() } |
| @@ -193,7 +262,7 @@ struct CommandPalette: View { |
| Image(systemName: "magnifyingglass") |
| .font(.system(size: 18, weight: .medium)) |
| .foregroundStyle(.secondary) |
| - TextField("Jump to a chat…", text: $query) |
| + TextField("Jump to…", text: $query) |
| .textFieldStyle(.plain) |
| .font(.system(size: 21)) |
| .focused($focused) |
| @@ -202,20 +271,28 @@ struct CommandPalette: View { |
| .padding(.horizontal, 20) |
| .padding(.vertical, 17) |
| Divider().opacity(0.5) |
| - ScrollView { |
| - VStack(spacing: 4) { |
| - ForEach(Array(matches.enumerated()), id: \.element.id) { i, chat in |
| - row(chat: chat, initial: inits[chat.id] ?? "?", highlighted: i == index) { |
| - app.selection = chat.id; close() |
| + ScrollViewReader { proxy in |
| + ScrollView { |
| + VStack(spacing: 4) { |
| + ForEach(Array(rows.enumerated()), id: \.element.id) { i, row in |
| + paletteRow(row, inits: inits, highlighted: i == index) |
| + .id(row.id) |
| } |
| } |
| - newChatRow(highlighted: index == matches.count) { |
| - close(); app.showAdd = true |
| + .padding(8) |
| + } |
| + .frame(maxHeight: 340) |
| + // Keep the keyboard highlight in view as ↑/↓ walk the list. An |
| + // explicit directional anchor (not nil, which macOS sometimes |
| + // ignores): moving down pins the row to the bottom edge, moving |
| + // up to the top — the ScrollView clamps, so nothing moves until |
| + // the highlight actually reaches an edge. |
| + .onChange(of: index) { old, new in |
| + if new < rows.count { |
| + proxy.scrollTo(rows[new].id, anchor: new > old ? .bottom : .top) |
| } |
| } |
| - .padding(8) |
| } |
| - .frame(maxHeight: 340) |
| Divider().opacity(0.5) |
| // Footer hint bar, à la TinyStart's status line. |
| HStack(spacing: 16) { |
| @@ -233,18 +310,74 @@ struct CommandPalette: View { |
| .shadow(color: .black.opacity(0.35), radius: 30, y: 12) |
| .padding(.top, 80) |
| } |
| - .onAppear { index = 0; DispatchQueue.main.async { focused = true } } |
| + .onAppear { |
| + index = 0 |
| + loadWebItems() |
| + DispatchQueue.main.async { focused = true } |
| + } |
| + // While the palette is up it owns the keyboard. If anything else grabs |
| + // first responder (switching away and back re-focuses the web composer; |
| + // see ChatFeedWebView.focusComposer, which also guards on showPalette), |
| + // take it straight back — and re-assert on app re-activation too, since |
| + // a focus lost to another app comes back to whoever asks. |
| + .onChange(of: focused) { _, isFocused in |
| + if !isFocused && app.showPalette { |
| + DispatchQueue.main.async { focused = true } |
| + } |
| + } |
| + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in |
| + DispatchQueue.main.async { focused = true } |
| + } |
| .onChange(of: query) { _, _ in index = 0 } |
| - .onKeyPress(.downArrow) { index = min(index + 1, rowCount - 1); return .handled } |
| + .onKeyPress(.downArrow) { index = min(index + 1, rows.count - 1); return .handled } |
| .onKeyPress(.upArrow) { index = max(index - 1, 0); return .handled } |
| .onExitCommand { close() } |
| } |
| |
| + // Ask the selected instance's web layer what it can offer. Fails soft: with |
| + // no webview (or a pre-join page, which answers null) the palette still |
| + // switches servers and connects new ones — just no chat/action rows. |
| + private func loadWebItems() { |
| + guard let webView = app.activeWebView else { return } |
| + webView.evaluateJavaScript( |
| + "JSON.stringify(window.lchatPaletteItems ? window.lchatPaletteItems() : null)" |
| + ) { result, _ in |
| + guard let json = result as? String, |
| + let data = json.data(using: .utf8), |
| + let items = try? JSONDecoder().decode(PaletteWebItems.self, from: data) else { return } |
| + web = items |
| + } |
| + } |
| + |
| + // Post an activation back to the web layer, which runs the real handler. |
| + private func runWeb(_ kind: String, _ id: String) { |
| + guard let webView = app.activeWebView else { return } |
| + let safe = id |
| + .replacingOccurrences(of: "\\", with: "\\\\") |
| + .replacingOccurrences(of: "'", with: "\\'") |
| + webView.evaluateJavaScript( |
| + "window.lchatPaletteRun && window.lchatPaletteRun('\(kind)', '\(safe)')", |
| + completionHandler: nil) |
| + } |
| + |
| private func activate() { |
| - if index < matches.count { |
| - app.selection = matches[index].id |
| + let rows = self.rows |
| + guard index < rows.count else { return } |
| + pick(rows[index]) |
| + } |
| + |
| + private func pick(_ row: PaletteRow) { |
| + switch row { |
| + case .chat(let c): |
| + runWeb("chat", c.id) |
| close() |
| - } else { |
| + case .server(let s): |
| + app.selection = s.id |
| + close() |
| + case .action(let a): |
| + runWeb("action", a.id) |
| + close() |
| + case .connect: |
| close() |
| app.showAdd = true |
| } |
| @@ -252,12 +385,45 @@ struct CommandPalette: View { |
| |
| private func close() { app.showPalette = false; query = "" } |
| |
| - // A server row, TinyStart-style: leading status dot, colour badge with the |
| - // instance initial, name + host, a highlight and a ↩ on the active row. |
| @ViewBuilder |
| - private func row(chat: SavedChat, initial: String, highlighted: Bool, action: @escaping () -> Void) -> some View { |
| - rowShell(highlighted: highlighted, action: action) { |
| - statusDot(current: app.selection == chat.id) |
| + private func paletteRow(_ row: PaletteRow, inits: [String: String], highlighted: Bool) -> some View { |
| + switch row { |
| + case .chat(let c): |
| + chatRow(c, highlighted: highlighted) |
| + case .server(let s): |
| + serverRow(s, initial: inits[s.id] ?? "?", highlighted: highlighted) |
| + case .action(let a): |
| + actionRow(a, highlighted: highlighted) |
| + case .connect: |
| + connectRow(highlighted: highlighted) |
| + } |
| + } |
| + |
| + // A chat on the current server: initials badge coloured by thread id (or the |
| + // app's glyph for an app-thread), accent dot on the one that's open. |
| + @ViewBuilder |
| + private func chatRow(_ c: PaletteChat, highlighted: Bool) -> some View { |
| + rowShell(highlighted: highlighted, action: { pick(.chat(c)) }) { |
| + statusDot(current: c.active) |
| + Text(c.glyph ?? chatInitials(c.name)) |
| + .font(.system(size: 16, weight: .semibold)) |
| + .lineLimit(1).minimumScaleFactor(0.6) |
| + .foregroundStyle(c.glyph == nil ? .white : .primary) |
| + .frame(width: 38, height: 38) |
| + .background(RoundedRectangle(cornerRadius: 10, style: .continuous) |
| + .fill(c.glyph == nil |
| + ? Color(hue: app.hue(for: c.id), saturation: 0.55, brightness: 0.78) |
| + : Color.secondary.opacity(0.18))) |
| + Text(c.name).font(.system(size: 16, weight: .semibold)) |
| + } |
| + } |
| + |
| + // Another server, TinyStart-style: colour badge with the instance initial, |
| + // "Switch to <name>" + host. |
| + @ViewBuilder |
| + private func serverRow(_ chat: SavedChat, initial: String, highlighted: Bool) -> some View { |
| + rowShell(highlighted: highlighted, action: { pick(.server(chat)) }) { |
| + statusDot(current: false) |
| Text(initial) |
| .font(.system(size: 16, weight: .semibold)) |
| .lineLimit(1).minimumScaleFactor(0.6) |
| @@ -266,7 +432,7 @@ struct CommandPalette: View { |
| .background(RoundedRectangle(cornerRadius: 10, style: .continuous) |
| .fill(Color(hue: app.hue(for: chat.id), saturation: 0.55, brightness: 0.78))) |
| VStack(alignment: .leading, spacing: 1) { |
| - Text(app.serverName(chat)).font(.system(size: 16, weight: .semibold)) |
| + Text("Switch to \(app.serverName(chat))").font(.system(size: 16, weight: .semibold)) |
| if let host = chat.origin?.host, host != app.serverName(chat) { |
| Text(host).font(.system(size: 12)).foregroundStyle(.secondary) |
| } |
| @@ -275,15 +441,55 @@ struct CommandPalette: View { |
| } |
| |
| @ViewBuilder |
| - private func newChatRow(highlighted: Bool, action: @escaping () -> Void) -> some View { |
| - rowShell(highlighted: highlighted, action: action) { |
| + private func actionRow(_ a: PaletteAction, highlighted: Bool) -> some View { |
| + rowShell(highlighted: highlighted, action: { pick(.action(a)) }) { |
| + statusDot(current: false) |
| + ZStack { |
| + RoundedRectangle(cornerRadius: 10, style: .continuous).fill(.secondary.opacity(0.18)) |
| + Image(systemName: actionSymbol(a.id)) |
| + .font(.system(size: 15, weight: .semibold)) |
| + .foregroundStyle(a.danger == true ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) |
| + } |
| + .frame(width: 38, height: 38) |
| + Text(a.label) |
| + .font(.system(size: 16, weight: .semibold)) |
| + .foregroundStyle(a.danger == true ? AnyShapeStyle(.red) : AnyShapeStyle(.primary)) |
| + } |
| + } |
| + |
| + @ViewBuilder |
| + private func connectRow(highlighted: Bool) -> some View { |
| + rowShell(highlighted: highlighted, action: { pick(.connect) }) { |
| statusDot(current: false) |
| ZStack { |
| RoundedRectangle(cornerRadius: 10, style: .continuous).fill(.secondary.opacity(0.18)) |
| Image(systemName: "plus").font(.system(size: 16, weight: .semibold)).foregroundStyle(.secondary) |
| } |
| .frame(width: 38, height: 38) |
| - Text("New Chat…").font(.system(size: 16, weight: .semibold)).foregroundStyle(.secondary) |
| + Text("Connect to a new space…").font(.system(size: 16, weight: .semibold)).foregroundStyle(.secondary) |
| + } |
| + } |
| + |
| + // Chat-name initials, mirroring MacAppState.initials(): one letter for a |
| + // single-word name, first letters of the first two words otherwise. |
| + private func chatInitials(_ name: String) -> String { |
| + let ws = name.split { !($0.isLetter || $0.isNumber) } |
| + func head(_ w: Substring?) -> String? { w?.first.map { String($0).uppercased() } } |
| + guard let first = head(ws.first) else { return "?" } |
| + return ws.count >= 2 ? first + (head(ws[1]) ?? "") : first |
| + } |
| + |
| + private func actionSymbol(_ id: String) -> String { |
| + switch id { |
| + case "new-chat": return "square.and.pencil" |
| + case "new-group": return "person.2" |
| + case "invite": return "person.badge.plus" |
| + case "apps": return "square.grid.2x2" |
| + case "add-device": return "laptopcomputer.and.iphone" |
| + case "theme": return "paintpalette" |
| + case "edit-profile": return "person.crop.circle" |
| + case "leave": return "rectangle.portrait.and.arrow.right" |
| + default: return "bolt" |
| } |
| } |
| |