mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-08 21:59:15 +02:00
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:
@@ -18,6 +18,8 @@ import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
|
|||||||
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
|
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
|
||||||
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
||||||
import type { Message } from "@/types/chat";
|
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 { UserName } from "./nostr/UserName";
|
||||||
import { RichText } from "./nostr/RichText";
|
import { RichText } from "./nostr/RichText";
|
||||||
import Timestamp from "./Timestamp";
|
import Timestamp from "./Timestamp";
|
||||||
@@ -363,6 +365,22 @@ export function ChatViewer({
|
|||||||
? conversationResult.conversation
|
? conversationResult.conversation
|
||||||
: null;
|
: 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
|
// Cleanup subscriptions when conversation changes or component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -437,6 +455,35 @@ export function ChatViewer({
|
|||||||
) => {
|
) => {
|
||||||
if (!conversation || !hasActiveAccount || isSending) return;
|
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);
|
setIsSending(true);
|
||||||
try {
|
try {
|
||||||
await adapter.sendMessage(conversation, content, {
|
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
|
// Handle reply button click
|
||||||
const handleReply = useCallback((messageId: string) => {
|
const handleReply = useCallback((messageId: string) => {
|
||||||
setReplyTo(messageId);
|
setReplyTo(messageId);
|
||||||
@@ -770,6 +847,8 @@ export function ChatViewer({
|
|||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
searchProfiles={searchProfiles}
|
searchProfiles={searchProfiles}
|
||||||
searchEmojis={searchEmojis}
|
searchEmojis={searchEmojis}
|
||||||
|
searchCommands={searchCommands}
|
||||||
|
onCommandExecute={handleCommandExecute}
|
||||||
onSubmit={(content, emojiTags) => {
|
onSubmit={(content, emojiTags) => {
|
||||||
if (content.trim()) {
|
if (content.trim()) {
|
||||||
handleSend(content, replyTo, emojiTags);
|
handleSend(content, replyTo, emojiTags);
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ import {
|
|||||||
EmojiSuggestionList,
|
EmojiSuggestionList,
|
||||||
type EmojiSuggestionListHandle,
|
type EmojiSuggestionListHandle,
|
||||||
} from "./EmojiSuggestionList";
|
} from "./EmojiSuggestionList";
|
||||||
|
import {
|
||||||
|
SlashCommandSuggestionList,
|
||||||
|
type SlashCommandSuggestionListHandle,
|
||||||
|
} from "./SlashCommandSuggestionList";
|
||||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||||
|
import type { ChatAction } from "@/types/chat-actions";
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +55,8 @@ export interface MentionEditorProps {
|
|||||||
onSubmit?: (content: string, emojiTags: EmojiTag[]) => void;
|
onSubmit?: (content: string, emojiTags: EmojiTag[]) => void;
|
||||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
||||||
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
||||||
|
searchCommands?: (query: string) => Promise<ChatAction[]>;
|
||||||
|
onCommandExecute?: (action: ChatAction) => Promise<void>;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -152,6 +159,8 @@ export const MentionEditor = forwardRef<
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
searchProfiles,
|
searchProfiles,
|
||||||
searchEmojis,
|
searchEmojis,
|
||||||
|
searchCommands,
|
||||||
|
onCommandExecute,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
className = "",
|
className = "",
|
||||||
},
|
},
|
||||||
@@ -335,6 +344,101 @@ export const MentionEditor = forwardRef<
|
|||||||
[searchEmojis],
|
[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
|
// Helper function to serialize editor content with mentions and emojis
|
||||||
const serializeContent = useCallback(
|
const serializeContent = useCallback(
|
||||||
(editorInstance: any): SerializedContent => {
|
(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;
|
return exts;
|
||||||
}, [mentionSuggestion, emojiSuggestion, placeholder]);
|
}, [
|
||||||
|
mentionSuggestion,
|
||||||
|
emojiSuggestion,
|
||||||
|
slashCommandSuggestion,
|
||||||
|
onCommandExecute,
|
||||||
|
placeholder,
|
||||||
|
]);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions,
|
extensions,
|
||||||
|
|||||||
112
src/components/editor/SlashCommandSuggestionList.tsx
Normal file
112
src/components/editor/SlashCommandSuggestionList.tsx
Normal 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";
|
||||||
@@ -10,6 +10,12 @@ import type {
|
|||||||
CreateConversationParams,
|
CreateConversationParams,
|
||||||
} from "@/types/chat";
|
} from "@/types/chat";
|
||||||
import type { NostrEvent } from "@/types/nostr";
|
import type { NostrEvent } from "@/types/nostr";
|
||||||
|
import type {
|
||||||
|
ChatAction,
|
||||||
|
ChatActionContext,
|
||||||
|
ChatActionResult,
|
||||||
|
GetActionsOptions,
|
||||||
|
} from "@/types/chat-actions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for sending a message
|
* Options for sending a message
|
||||||
@@ -141,4 +147,38 @@ export abstract class ChatProtocolAdapter {
|
|||||||
* Optional - only for protocols with leave semantics (groups)
|
* Optional - only for protocols with leave semantics (groups)
|
||||||
*/
|
*/
|
||||||
leaveConversation?(conversation: Conversation): Promise<void>;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
ParticipantRole,
|
ParticipantRole,
|
||||||
} from "@/types/chat";
|
} from "@/types/chat";
|
||||||
import type { NostrEvent } from "@/types/nostr";
|
import type { NostrEvent } from "@/types/nostr";
|
||||||
|
import type { ChatAction, GetActionsOptions } from "@/types/chat-actions";
|
||||||
import eventStore from "@/services/event-store";
|
import eventStore from "@/services/event-store";
|
||||||
import pool from "@/services/relay-pool";
|
import pool from "@/services/relay-pool";
|
||||||
import { publishEventToRelays } from "@/services/hub";
|
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
|
* Load a replied-to message
|
||||||
* First checks EventStore, then fetches from group relay if needed
|
* First checks EventStore, then fetches from group relay if needed
|
||||||
|
|||||||
39
src/lib/chat/slash-command-parser.ts
Normal file
39
src/lib/chat/slash-command-parser.ts
Normal 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
48
src/types/chat-actions.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user