mac,ios: native slash commands — /me, /theme, and bot commands actually send (#324)
The native composers sent every slash command as literal text — /me posted "/me waves", and /theme reached the bot only to get back an action chip the native feed can't tap. Now sendDraft routes through a shared SlashCommand parser: /me broadcasts an action line (new ChatFeedModel.sendAction + an action flag on the send payload); /theme <name> applies a native theme directly and /themes opens Settings (the theme lives client-side — no bot round-trip); /help and bot-owned commands (/dark, /light, /profile) fall through to a normal send so a bot answers them, exactly like the web. The "/" typeahead already listed these; now sending one does the right thing. Parser unit-tested.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+165
−0
ios/LChat/Views/NativeChatScreen.swift+26 −0ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+20 −0ios/LChatCore/Sources/LChatCore/SlashCommands.swift+60 −0ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift+27 −0ios/LChatMac/NativeFeedView.swift+32 −0
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 970c703..e062471 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -217,6 +217,10 @@ struct NativeChatScreen: View { |
| VideoViewer(player: item.player) { dismissCover { playingVideo = nil } } |
| .presentationBackground(.clear) |
| } |
| + // /themes opens the per-server settings, where the theme picker lives. |
| + .sheet(isPresented: $showThemeSettings) { |
| + ServerSettingsSheet(store: store) |
| + } |
| } |
| // The viewers animate their own exits (the image flies off, the backdrop |
| @@ -709,6 +713,26 @@ struct NativeChatScreen: View { |
| return |
| } |
| guard canSend else { return } |
| + // Slash commands (bare text only): /me broadcasts an action line, |
| + // /theme applies a theme locally, /themes opens Settings; anything |
| + // else falls through to a normal send so a bot answers it. |
| + if staged.isEmpty, replyTo == nil, draft.first == "/" { |
| + switch SlashCommand.parse(draft, meName: model.myScreenName) { |
| + case .me(let line): |
| + if model.sendAction(line) { draft = "" } |
| + return |
| + case .setTheme(let id): |
| + themeName = id |
| + draft = "" |
| + return |
| + case .openThemes: |
| + draft = "" |
| + showThemeSettings = true |
| + return |
| + case .help, .sendAsMessage: |
| + break |
| + } |
| + } |
| if model.send(draft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) { |
| draft = "" |
| staged = [] |
| @@ -716,6 +740,8 @@ struct NativeChatScreen: View { |
| } |
| } |
| + @State private var showThemeSettings = false |
| + |
| private func startReply(_ m: ChatFeedModel.Message) { |
| editingID = nil |
| replyTo = ReplyRef(id: m.id, name: m.name, snippet: replySnippet(m.raw)) |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index 21c6e1c..9c8cb32 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -64,6 +64,8 @@ public final class ChatFeedModel: ObservableObject { |
| private var lastOlderLoad = Date.distantPast |
| private let instance: SavedInstance |
| + /// The sender's screen name — the composer needs it to build a /me line. |
| + public var myScreenName: String { instance.screenName } |
| private let threadID: String |
| private let threadKeyB64: String |
| private let myUserId: String |
| @@ -325,10 +327,28 @@ public final class ChatFeedModel: ObservableObject { |
| let name: String |
| let ts: Int64 |
| var uid: String? |
| + var action: Bool? // /me-style centred line, not a bubble |
| var attachments: [Attachment]? |
| var replyTo: ReplyRef? |
| } |
| + /// Broadcast a /me-style action line ("Paul waves") — the composer builds |
| + /// the full text; here it rides with action:true, like the web's ctx.emit. |
| + @discardableResult |
| + public func sendAction(_ text: String) -> Bool { |
| + guard !text.isEmpty, let key, let socket else { return false } |
| + var payload = Outgoing(text: text, name: instance.screenName, |
| + ts: Int64(Date().timeIntervalSince1970 * 1000)) |
| + if !myUserId.isEmpty { payload.uid = myUserId } |
| + payload.action = true |
| + guard let blob = try? key.encryptJSON(payload), |
| + let frame = try? JSONEncoder().encode(OutFrame(thread_id: threadID, blob: blob)), |
| + let json = String(data: frame, encoding: .utf8) else { return false } |
| + let sent = socket.send(json) |
| + if sent { typingBeat(active: false) } |
| + return sent |
| + } |
| + |
| // One hub action frame (react/unreact/delete/edit) — nil fields are |
| // omitted, matching the web's hand-built JSON. |
| private struct ActionFrame: Encodable { |
diff --git a/ios/LChatCore/Sources/LChatCore/SlashCommands.swift b/ios/LChatCore/Sources/LChatCore/SlashCommands.swift| new file mode 100644 |
| index 0000000..3189312 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/SlashCommands.swift |
| @@ -0,0 +1,60 @@ |
| +import Foundation |
| + |
| +// 🤖 Native slash-command routing, mirroring the web (apps.js runCommand + |
| +// apps/chat.js). The native feed can't run the mini-app JS or render the bot's |
| +// action chips, so the client-answerable commands are handled DIRECTLY here — |
| +// `/me` broadcasts an action line, `/theme`/`/themes` drive the native theme, |
| +// and anything else is sent to the thread as-is so a bot answers it (the same |
| +// fallthrough the web uses for bot-owned commands). |
| +public enum SlashCommand: Equatable { |
| + case me(String) // broadcast "<name> <action>" as an action line |
| + case setTheme(String) // apply a native theme id |
| + case openThemes // open the theme picker (unknown/blank /theme) |
| + case help // list available commands (private notice) |
| + case sendAsMessage // not a client command — send the text as-is |
| + |
| + /// Classify composer text. `meName` is the sender's screen name (for /me). |
| + public static func parse(_ text: String, meName: String) -> SlashCommand { |
| + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard trimmed.first == "/" else { return .sendAsMessage } |
| + |
| + // /me waves → an action line (mirrors parseMeCommand). A bare /me sends |
| + // nothing meaningful; treat it as a normal (empty-ish) message. |
| + if let action = parseMe(trimmed) { |
| + return action.isEmpty ? .sendAsMessage : .me("\(meName) \(action)") |
| + } |
| + |
| + // First token → command name. |
| + let name = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init) ?? trimmed |
| + let args = trimmed.dropFirst(name.count).trimmingCharacters(in: .whitespaces) |
| + |
| + switch name.lowercased() { |
| + case "/theme": |
| + let id = themeID(for: args) |
| + return id.map(SlashCommand.setTheme) ?? .openThemes |
| + case "/themes": |
| + return .openThemes |
| + case "/help": |
| + return .help |
| + default: |
| + // Unknown or bot-owned (/dark, /light, /profile, …) → send it; a bot |
| + // answers where present, exactly like the web's bot-command path. |
| + return .sendAsMessage |
| + } |
| + } |
| + |
| + /// "/me waves" → "waves" (or "" for a bare /me); nil when it isn't a /me line. |
| + static func parseMe(_ text: String) -> String? { |
| + let t = text.trimmingCharacters(in: .whitespaces) |
| + guard t.lowercased() == "/me" || t.lowercased().hasPrefix("/me ") else { return nil } |
| + return String(t.dropFirst(3)).trimmingCharacters(in: .whitespaces) |
| + } |
| + |
| + /// Map a /theme argument to a native theme id (ChatTheme.presets), tolerating |
| + /// "lit room" / "Lit Room" → "litroom". nil = unknown → open the picker. |
| + static func themeID(for arg: String) -> String? { |
| + let a = arg.lowercased().replacingOccurrences(of: " ", with: "") |
| + guard !a.isEmpty else { return nil } |
| + return ChatTheme.presets.first { $0.id == a }?.id |
| + } |
| +} |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift| new file mode 100644 |
| index 0000000..eb43c59 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift |
| @@ -0,0 +1,27 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// 🤖 The native slash router — /me, /theme, /themes, /help, and fallthrough. |
| +final class SlashCommandsTests: XCTestCase { |
| + func testMe() { |
| + XCTAssertEqual(SlashCommand.parse("/me waves", meName: "Paul"), .me("Paul waves")) |
| + XCTAssertEqual(SlashCommand.parse("/me", meName: "Paul"), .sendAsMessage) // bare /me |
| + } |
| + func testTheme() { |
| + XCTAssertEqual(SlashCommand.parse("/theme lagoon", meName: "P"), .setTheme("lagoon")) |
| + XCTAssertEqual(SlashCommand.parse("/theme Lit Room", meName: "P"), .setTheme("litroom")) |
| + XCTAssertEqual(SlashCommand.parse("/theme nonsense", meName: "P"), .openThemes) |
| + XCTAssertEqual(SlashCommand.parse("/theme", meName: "P"), .openThemes) |
| + XCTAssertEqual(SlashCommand.parse("/themes", meName: "P"), .openThemes) |
| + } |
| + func testHelpAndFallthrough() { |
| + XCTAssertEqual(SlashCommand.parse("/help", meName: "P"), .help) |
| + XCTAssertEqual(SlashCommand.parse("/dark", meName: "P"), .sendAsMessage) // bot-owned |
| + XCTAssertEqual(SlashCommand.parse("/profile", meName: "P"), .sendAsMessage) |
| + XCTAssertEqual(SlashCommand.parse("hello", meName: "P"), .sendAsMessage) |
| + XCTAssertEqual(SlashCommand.parse("/", meName: "P"), .sendAsMessage) // lone slash |
| + } |
| + func testLoneSlashIsMessage() { |
| + XCTAssertEqual(SlashCommand.parse("/ hey", meName: "P"), .sendAsMessage) |
| + } |
| +} |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 262ac6c..1588390 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -839,6 +839,27 @@ struct NativeFeedView: View { |
| return |
| } |
| guard hasContent else { return } |
| + // Slash commands (only a bare text command — attachments/reply stage a |
| + // normal message). /me broadcasts an action line, /theme applies a |
| + // theme, everything else falls through to a normal send so a bot |
| + // answers it (matching the web). |
| + if staged.isEmpty, replyTo == nil, draft.first == "/" { |
| + switch SlashCommand.parse(draft, meName: model.myScreenName) { |
| + case .me(let line): |
| + if model.sendAction(line) { draft = "" } |
| + return |
| + case .setTheme(let id): |
| + themeName = id |
| + draft = "" |
| + return |
| + case .openThemes: |
| + draft = "" |
| + openThemeSettings() |
| + return |
| + case .help, .sendAsMessage: |
| + break // fall through — a bot answers /help and bot commands |
| + } |
| + } |
| // Mid-upload, the send queues instead of blocking (web PR #269): the |
| // button spins and fireQueuedSend releases it when the batch resolves. |
| guard !uploadsInFlight else { |
| @@ -855,6 +876,17 @@ struct NativeFeedView: View { |
| } |
| } |
| + // /themes (or a bare/unknown /theme) opens the Settings window, where the |
| + // theme picker lives — the native equivalent of the web's picker sheet. |
| + private func openThemeSettings() { |
| + NSApp.activate(ignoringOtherApps: true) |
| + if NSApp.responds(to: Selector(("showSettingsWindow:"))) { |
| + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) |
| + } else { |
| + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) |
| + } |
| + } |
| + |
| // ---- reply / edit staging (the composer banners) ---------------------- |
| private func startReply(_ m: ChatFeedModel.Message) { |