From 4ee385ea6ca6d6998f72ddc2a2b46b3299d4c410 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Tue, 13 Jan 2026 11:49:32 +0100 Subject: [PATCH] Add actions support to chat adapter protocol (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add protocol-specific actions to chat adapters Extends the chat adapter system with support for slash commands and protocol-specific actions without parameters. New features: - ChatAction type system for defining simple commands - Base adapter getActions() and executeAction() methods - NIP-29 /join and /leave slash commands - Slash command parser for detecting /commands - ChatViewer integration with toast notifications Example usage in NIP-29 groups: /join - Request to join the group /leave - Leave the group The action system is extensible and can be enhanced with parameterized actions (e.g., /kick , /ban ) in future iterations. Tests: All 804 tests passing Build: Successful Lint: No errors * feat: add autocomplete for slash commands Extends the chat system with autocomplete UI for slash commands, making actions discoverable and easier to execute. New features: - SlashCommandSuggestionList component with keyboard navigation - Slash command search integrated into MentionEditor - Autocomplete popup shows command name and description - Arrow keys for navigation, Enter to select, Escape to close - Filters available commands based on typed text When typing "/" in chat, users now see: /join Request to join the group /leave Leave the group The autocomplete uses the same TipTap suggestion system as @mentions and :emoji:, providing a consistent UX across all autocomplete features. Tests: All 804 tests passing Build: Successful Lint: 1 warning fixed (unused parameter) * feat: execute slash commands immediately on autocomplete selection When selecting a slash command from the autocomplete menu, the action now executes immediately and clears the input, providing a more streamlined UX. Changes: - Added onCommandExecute prop to MentionEditor - Modified slash command suggestion to call onCommandExecute on selection - Clears editor content immediately after selection - Added handleCommandExecute callback in ChatViewer - Executes action and shows toast notifications Before: Type "/" → select "/join" → press Enter → executes After: Type "/" → select "/join" → executes immediately This matches the expected behavior for command selection - when you choose a command from autocomplete, you want to execute it, not just insert it into the text field. Tests: All 804 tests passing Build: Successful Lint: No new errors * feat: make chat actions context-aware and filter by membership status Actions are now intelligently filtered based on the user's membership in the conversation, providing a cleaner and more intuitive UX. Changes: - Added GetActionsOptions type with conversation and activePubkey - Updated base adapter getActions() to accept optional context - Modified NIP-29 adapter to filter actions based on membership: - /join: only shown when user is NOT a member/admin - /leave: only shown when user IS a member - Updated ChatViewer to pass conversation and user context to searchCommands - Moved searchCommands callback after conversation is defined This prevents showing irrelevant commands like "/join" when you're already a member, or "/leave" when you haven't joined yet. The autocomplete menu now only displays actions that are actually executable in the current context. Implementation notes: - NIP-29 uses getAllActions() fallback when context unavailable - Membership determined by checking conversation.participants array - Other protocols return empty array by default (no actions yet) Tests: All 804 tests passing Build: Successful Lint: No new errors --------- Co-authored-by: Claude --- src/components/ChatViewer.tsx | 79 ++++++++++ src/components/editor/MentionEditor.tsx | 147 +++++++++++++++++- .../editor/SlashCommandSuggestionList.tsx | 112 +++++++++++++ src/lib/chat/adapters/base-adapter.ts | 40 +++++ src/lib/chat/adapters/nip-29-adapter.ts | 125 +++++++++++++++ src/lib/chat/slash-command-parser.ts | 39 +++++ src/types/chat-actions.ts | 48 ++++++ 7 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 src/components/editor/SlashCommandSuggestionList.tsx create mode 100644 src/lib/chat/slash-command-parser.ts create mode 100644 src/types/chat-actions.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index c5710ac..3de6a5b 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -18,6 +18,8 @@ import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter"; import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; import type { Message } from "@/types/chat"; +import type { ChatAction } from "@/types/chat-actions"; +import { parseSlashCommand } from "@/lib/chat/slash-command-parser"; import { UserName } from "./nostr/UserName"; import { RichText } from "./nostr/RichText"; import Timestamp from "./Timestamp"; @@ -363,6 +365,22 @@ export function ChatViewer({ ? conversationResult.conversation : null; + // Slash command search for action autocomplete + // Context-aware: only shows relevant actions based on membership status + const searchCommands = useCallback( + async (query: string) => { + const availableActions = adapter.getActions({ + conversation: conversation || undefined, + activePubkey: activeAccount?.pubkey, + }); + const lowerQuery = query.toLowerCase(); + return availableActions.filter((action) => + action.name.toLowerCase().includes(lowerQuery), + ); + }, + [adapter, conversation, activeAccount], + ); + // Cleanup subscriptions when conversation changes or component unmounts useEffect(() => { return () => { @@ -437,6 +455,35 @@ export function ChatViewer({ ) => { if (!conversation || !hasActiveAccount || isSending) return; + // Check if this is a slash command + const slashCmd = parseSlashCommand(content); + if (slashCmd) { + // Execute action instead of sending message + setIsSending(true); + try { + const result = await adapter.executeAction(slashCmd.command, { + activePubkey: activeAccount.pubkey, + activeSigner: activeAccount.signer, + conversation, + }); + + if (result.success) { + toast.success(result.message || "Action completed"); + } else { + toast.error(result.message || "Action failed"); + } + } catch (error) { + console.error("[Chat] Failed to execute action:", error); + const errorMessage = + error instanceof Error ? error.message : "Action failed"; + toast.error(errorMessage); + } finally { + setIsSending(false); + } + return; + } + + // Regular message sending setIsSending(true); try { await adapter.sendMessage(conversation, content, { @@ -455,6 +502,36 @@ export function ChatViewer({ } }; + // Handle command execution from autocomplete + const handleCommandExecute = useCallback( + async (action: ChatAction) => { + if (!conversation || !hasActiveAccount || isSending) return; + + setIsSending(true); + try { + const result = await adapter.executeAction(action.name, { + activePubkey: activeAccount.pubkey, + activeSigner: activeAccount.signer, + conversation, + }); + + if (result.success) { + toast.success(result.message || "Action completed"); + } else { + toast.error(result.message || "Action failed"); + } + } catch (error) { + console.error("[Chat] Failed to execute action:", error); + const errorMessage = + error instanceof Error ? error.message : "Action failed"; + toast.error(errorMessage); + } finally { + setIsSending(false); + } + }, + [conversation, hasActiveAccount, isSending, adapter, activeAccount], + ); + // Handle reply button click const handleReply = useCallback((messageId: string) => { setReplyTo(messageId); @@ -770,6 +847,8 @@ export function ChatViewer({ placeholder="Type a message..." searchProfiles={searchProfiles} searchEmojis={searchEmojis} + searchCommands={searchCommands} + onCommandExecute={handleCommandExecute} onSubmit={(content, emojiTags) => { if (content.trim()) { handleSend(content, replyTo, emojiTags); diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index 1d592af..dfb5cee 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -23,8 +23,13 @@ import { EmojiSuggestionList, type EmojiSuggestionListHandle, } from "./EmojiSuggestionList"; +import { + SlashCommandSuggestionList, + type SlashCommandSuggestionListHandle, +} from "./SlashCommandSuggestionList"; import type { ProfileSearchResult } from "@/services/profile-search"; import type { EmojiSearchResult } from "@/services/emoji-search"; +import type { ChatAction } from "@/types/chat-actions"; import { nip19 } from "nostr-tools"; /** @@ -50,6 +55,8 @@ export interface MentionEditorProps { onSubmit?: (content: string, emojiTags: EmojiTag[]) => void; searchProfiles: (query: string) => Promise; searchEmojis?: (query: string) => Promise; + searchCommands?: (query: string) => Promise; + onCommandExecute?: (action: ChatAction) => Promise; autoFocus?: boolean; className?: string; } @@ -152,6 +159,8 @@ export const MentionEditor = forwardRef< onSubmit, searchProfiles, searchEmojis, + searchCommands, + onCommandExecute, autoFocus = false, className = "", }, @@ -335,6 +344,101 @@ export const MentionEditor = forwardRef< [searchEmojis], ); + // Create slash command suggestion configuration for / commands + const slashCommandSuggestion: Omit | null = + useMemo( + () => + searchCommands + ? { + char: "/", + allowSpaces: false, + items: async ({ query }) => { + return await searchCommands(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; + let editorRef: any; + + return { + onStart: (props) => { + editorRef = props.editor; + component = new ReactRenderer( + SlashCommandSuggestionList, + { + props: { + items: props.items, + command: props.command, + onClose: () => { + popup[0]?.hide(); + }, + }, + editor: props.editor, + }, + ); + + if (!props.clientRect) { + return; + } + + popup = tippy("body", { + getReferenceClientRect: + props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "top-start", + }); + }, + + onUpdate(props) { + component.updateProps({ + items: props.items, + command: props.command, + }); + + if (!props.clientRect) { + return; + } + + popup[0]?.setProps({ + getReferenceClientRect: + props.clientRect as () => DOMRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup[0]?.hide(); + return true; + } + + // Ctrl/Cmd+Enter submits the message + if ( + props.event.key === "Enter" && + (props.event.ctrlKey || props.event.metaKey) + ) { + popup[0]?.hide(); + handleSubmitRef.current(editorRef); + return true; + } + + return component.ref?.onKeyDown(props.event) ?? false; + }, + + onExit() { + popup[0]?.destroy(); + component.destroy(); + }, + }; + }, + } + : null, + [searchCommands], + ); + // Helper function to serialize editor content with mentions and emojis const serializeContent = useCallback( (editorInstance: any): SerializedContent => { @@ -503,8 +607,49 @@ export const MentionEditor = forwardRef< ); } + // Add slash command extension if search is provided + if (slashCommandSuggestion) { + const SlashCommand = Mention.extend({ + name: "slashCommand", + }); + + exts.push( + SlashCommand.configure({ + HTMLAttributes: { + class: "slash-command", + }, + suggestion: { + ...slashCommandSuggestion, + command: ({ editor, props }: any) => { + // props is the ChatAction + // Execute the command immediately and clear the editor + editor.commands.clearContent(); + if (onCommandExecute) { + // Execute action asynchronously + onCommandExecute(props).catch((error) => { + console.error( + "[MentionEditor] Command execution failed:", + error, + ); + }); + } + }, + }, + renderLabel({ node }) { + return `/${node.attrs.label}`; + }, + }), + ); + } + return exts; - }, [mentionSuggestion, emojiSuggestion, placeholder]); + }, [ + mentionSuggestion, + emojiSuggestion, + slashCommandSuggestion, + onCommandExecute, + placeholder, + ]); const editor = useEditor({ extensions, diff --git a/src/components/editor/SlashCommandSuggestionList.tsx b/src/components/editor/SlashCommandSuggestionList.tsx new file mode 100644 index 0000000..3f383c5 --- /dev/null +++ b/src/components/editor/SlashCommandSuggestionList.tsx @@ -0,0 +1,112 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import type { ChatAction } from "@/types/chat-actions"; +import { Terminal } from "lucide-react"; + +export interface SlashCommandSuggestionListProps { + items: ChatAction[]; + command: (item: ChatAction) => void; + onClose?: () => void; +} + +export interface SlashCommandSuggestionListHandle { + onKeyDown: (event: KeyboardEvent) => boolean; +} + +export const SlashCommandSuggestionList = forwardRef< + SlashCommandSuggestionListHandle, + SlashCommandSuggestionListProps +>(({ items, command, onClose }, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const listRef = useRef(null); + + // Keyboard navigation + useImperativeHandle(ref, () => ({ + onKeyDown: (event: KeyboardEvent) => { + if (event.key === "ArrowUp") { + setSelectedIndex((prev) => (prev + items.length - 1) % items.length); + return true; + } + + if (event.key === "ArrowDown") { + setSelectedIndex((prev) => (prev + 1) % items.length); + return true; + } + + if (event.key === "Enter" && !event.ctrlKey && !event.metaKey) { + if (items[selectedIndex]) { + command(items[selectedIndex]); + } + return true; + } + + if (event.key === "Escape") { + onClose?.(); + return true; + } + + return false; + }, + })); + + // Scroll selected item into view + useEffect(() => { + const selectedElement = listRef.current?.children[selectedIndex]; + if (selectedElement) { + selectedElement.scrollIntoView({ + block: "nearest", + }); + } + }, [selectedIndex]); + + // Reset selected index when items change + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + if (items.length === 0) { + return ( +
+ No commands available +
+ ); + } + + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ); +}); + +SlashCommandSuggestionList.displayName = "SlashCommandSuggestionList"; diff --git a/src/lib/chat/adapters/base-adapter.ts b/src/lib/chat/adapters/base-adapter.ts index badea44..48dbdf7 100644 --- a/src/lib/chat/adapters/base-adapter.ts +++ b/src/lib/chat/adapters/base-adapter.ts @@ -10,6 +10,12 @@ import type { CreateConversationParams, } from "@/types/chat"; import type { NostrEvent } from "@/types/nostr"; +import type { + ChatAction, + ChatActionContext, + ChatActionResult, + GetActionsOptions, +} from "@/types/chat-actions"; /** * Options for sending a message @@ -141,4 +147,38 @@ export abstract class ChatProtocolAdapter { * Optional - only for protocols with leave semantics (groups) */ leaveConversation?(conversation: Conversation): Promise; + + /** + * Get available actions for this protocol + * Actions are protocol-specific slash commands like /join, /leave, etc. + * Can be filtered based on conversation and user context + * Returns empty array by default + */ + getActions(_options?: GetActionsOptions): ChatAction[] { + return []; + } + + /** + * Execute a chat action by name + * Returns error if action not found + */ + async executeAction( + actionName: string, + context: ChatActionContext, + ): Promise { + // Get actions with context for validation + const action = this.getActions({ + conversation: context.conversation, + activePubkey: context.activePubkey, + }).find((a) => a.name === actionName); + + if (!action) { + return { + success: false, + message: `Unknown action: /${actionName}`, + }; + } + + return action.handler(context); + } } diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 1f1bc80..35433c2 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -13,6 +13,7 @@ import type { ParticipantRole, } from "@/types/chat"; import type { NostrEvent } from "@/types/nostr"; +import type { ChatAction, GetActionsOptions } from "@/types/chat-actions"; import eventStore from "@/services/event-store"; import pool from "@/services/relay-pool"; import { publishEventToRelays } from "@/services/hub"; @@ -490,6 +491,130 @@ export class Nip29Adapter extends ChatProtocolAdapter { }; } + /** + * Get available actions for NIP-29 groups + * Filters actions based on user's membership status: + * - /join: only shown when user is NOT a member/admin + * - /leave: only shown when user IS a member + */ + getActions(options?: GetActionsOptions): ChatAction[] { + const actions: ChatAction[] = []; + + // Check if we have context to filter actions + if (!options?.conversation || !options?.activePubkey) { + // No context - return all actions + return this.getAllActions(); + } + + const { conversation, activePubkey } = options; + + // Find user's participant info + const userParticipant = conversation.participants.find( + (p) => p.pubkey === activePubkey, + ); + + const isMember = !!userParticipant; + + // Add /join if user is NOT a member + if (!isMember) { + actions.push({ + name: "join", + description: "Request to join the group", + handler: async (context) => { + try { + await this.joinConversation(context.conversation); + return { + success: true, + message: "Join request sent", + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error ? error.message : "Failed to join group", + }; + } + }, + }); + } + + // Add /leave if user IS a member + if (isMember) { + actions.push({ + name: "leave", + description: "Leave the group", + handler: async (context) => { + try { + await this.leaveConversation(context.conversation); + return { + success: true, + message: "You left the group", + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error + ? error.message + : "Failed to leave group", + }; + } + }, + }); + } + + return actions; + } + + /** + * Get all possible actions (used when no context available) + * @private + */ + private getAllActions(): ChatAction[] { + return [ + { + name: "join", + description: "Request to join the group", + handler: async (context) => { + try { + await this.joinConversation(context.conversation); + return { + success: true, + message: "Join request sent", + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error ? error.message : "Failed to join group", + }; + } + }, + }, + { + name: "leave", + description: "Leave the group", + handler: async (context) => { + try { + await this.leaveConversation(context.conversation); + return { + success: true, + message: "You left the group", + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error + ? error.message + : "Failed to leave group", + }; + } + }, + }, + ]; + } + /** * Load a replied-to message * First checks EventStore, then fetches from group relay if needed diff --git a/src/lib/chat/slash-command-parser.ts b/src/lib/chat/slash-command-parser.ts new file mode 100644 index 0000000..d11d2d0 --- /dev/null +++ b/src/lib/chat/slash-command-parser.ts @@ -0,0 +1,39 @@ +/** + * Parsed slash command result + */ +export interface ParsedSlashCommand { + /** Command name (without the leading slash) */ + command: string; +} + +/** + * Parse a slash command from message text + * Returns null if text is not a slash command + * + * Examples: + * "/join" -> { command: "join" } + * "/leave" -> { command: "leave" } + * "hello" -> null + * "not a /command" -> null + */ +export function parseSlashCommand(text: string): ParsedSlashCommand | null { + // Trim whitespace + const trimmed = text.trim(); + + // Must start with slash + if (!trimmed.startsWith("/")) { + return null; + } + + // Extract command (everything after the slash) + const command = trimmed.slice(1).trim(); + + // Must have a command name + if (!command) { + return null; + } + + return { + command, + }; +} diff --git a/src/types/chat-actions.ts b/src/types/chat-actions.ts new file mode 100644 index 0000000..b6adf75 --- /dev/null +++ b/src/types/chat-actions.ts @@ -0,0 +1,48 @@ +import type { Conversation } from "./chat"; + +/** + * Context passed to action handlers + */ +export interface ChatActionContext { + /** Active user's pubkey */ + activePubkey: string; + + /** Active user's signer */ + activeSigner: any; + + /** Conversation being acted upon */ + conversation: Conversation; +} + +/** + * Result from executing an action + */ +export interface ChatActionResult { + success: boolean; + message?: string; +} + +/** + * Simple chat action without parameters + */ +export interface ChatAction { + /** Command name (e.g., "join", "leave") */ + name: string; + + /** Human-readable description */ + description: string; + + /** Handler function */ + handler: (context: ChatActionContext) => Promise; +} + +/** + * Options for filtering available actions + */ +export interface GetActionsOptions { + /** Current conversation */ + conversation?: Conversation; + + /** Active user's pubkey */ + activePubkey?: string; +}