eleven / messenger Public

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>
Paul Campbell 0e47152d123854d8e87979a5add077a21105cd6f parent ac7ec8a
5 files changed, +165 −0
  • ios/LChat/Views/NativeChatScreen.swift +26 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +20 −0
  • ios/LChatCore/Sources/LChatCore/SlashCommands.swift +60 −0
  • ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift +27 −0
  • ios/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) {