Add actions support to chat adapter protocol (#67)

* 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 <user>, /ban <user>) 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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-13 11:49:32 +01:00
committed by GitHub
parent 9657ec635f
commit 4ee385ea6c
7 changed files with 589 additions and 1 deletions

View File

@@ -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);

View File

@@ -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<ProfileSearchResult[]>;
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
searchCommands?: (query: string) => Promise<ChatAction[]>;
onCommandExecute?: (action: ChatAction) => Promise<void>;
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<SuggestionOptions, "editor"> | null =
useMemo(
() =>
searchCommands
? {
char: "/",
allowSpaces: false,
items: async ({ query }) => {
return await searchCommands(query);
},
render: () => {
let component: ReactRenderer<SlashCommandSuggestionListHandle>;
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,

View File

@@ -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<HTMLDivElement>(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 (
<div className="border border-border/50 bg-popover p-4 text-sm text-muted-foreground shadow-md">
No commands available
</div>
);
}
return (
<div
ref={listRef}
role="listbox"
className="max-h-[300px] w-[320px] overflow-y-auto border border-border/50 bg-popover shadow-md"
>
{items.map((item, index) => (
<button
key={item.name}
role="option"
aria-selected={index === selectedIndex}
onClick={() => command(item)}
onMouseEnter={() => setSelectedIndex(index)}
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
}`}
>
<Terminal className="size-4 flex-shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium font-mono">
/{item.name}
</div>
<div className="truncate text-xs text-muted-foreground">
{item.description}
</div>
</div>
</button>
))}
</div>
);
});
SlashCommandSuggestionList.displayName = "SlashCommandSuggestionList";

View File

@@ -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<void>;
/**
* 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<ChatActionResult> {
// 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);
}
}

View File

@@ -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

View File

@@ -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,
};
}

48
src/types/chat-actions.ts Normal file
View File

@@ -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<ChatActionResult>;
}
/**
* Options for filtering available actions
*/
export interface GetActionsOptions {
/** Current conversation */
conversation?: Conversation;
/** Active user's pubkey */
activePubkey?: string;
}