From feb95cf4d79e573223a1fd9c6c910e42b98300b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Jan 2026 10:27:43 +0000 Subject: [PATCH] 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 --- src/components/ChatViewer.tsx | 28 +++++---- src/lib/chat/adapters/base-adapter.ts | 10 ++- src/lib/chat/adapters/nip-29-adapter.ts | 81 ++++++++++++++++++++++++- src/types/chat-actions.ts | 11 ++++ 4 files changed, 113 insertions(+), 17 deletions(-) diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index a2ab769..3de6a5b 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -333,18 +333,6 @@ export function ChatViewer({ // Get the appropriate adapter for this protocol const adapter = useMemo(() => getAdapter(protocol), [protocol]); - // Slash command search for action autocomplete - const searchCommands = useCallback( - async (query: string) => { - const availableActions = adapter.getActions(); - const lowerQuery = query.toLowerCase(); - return availableActions.filter((action) => - action.name.toLowerCase().includes(lowerQuery), - ); - }, - [adapter], - ); - // State for retry trigger const [retryCount, setRetryCount] = useState(0); @@ -377,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 () => { diff --git a/src/lib/chat/adapters/base-adapter.ts b/src/lib/chat/adapters/base-adapter.ts index b949061..48dbdf7 100644 --- a/src/lib/chat/adapters/base-adapter.ts +++ b/src/lib/chat/adapters/base-adapter.ts @@ -14,6 +14,7 @@ import type { ChatAction, ChatActionContext, ChatActionResult, + GetActionsOptions, } from "@/types/chat-actions"; /** @@ -150,9 +151,10 @@ export abstract class ChatProtocolAdapter { /** * 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(): ChatAction[] { + getActions(_options?: GetActionsOptions): ChatAction[] { return []; } @@ -164,7 +166,11 @@ export abstract class ChatProtocolAdapter { actionName: string, context: ChatActionContext, ): Promise { - const action = this.getActions().find((a) => a.name === actionName); + // Get actions with context for validation + const action = this.getActions({ + conversation: context.conversation, + activePubkey: context.activePubkey, + }).find((a) => a.name === actionName); if (!action) { return { diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 445a1d4..35433c2 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -13,7 +13,7 @@ import type { ParticipantRole, } from "@/types/chat"; import type { NostrEvent } from "@/types/nostr"; -import type { ChatAction } from "@/types/chat-actions"; +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"; @@ -493,9 +493,84 @@ export class Nip29Adapter extends ChatProtocolAdapter { /** * Get available actions for NIP-29 groups - * Returns simple join/leave commands without parameters + * 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(): ChatAction[] { + 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", diff --git a/src/types/chat-actions.ts b/src/types/chat-actions.ts index 53a55b0..b6adf75 100644 --- a/src/types/chat-actions.ts +++ b/src/types/chat-actions.ts @@ -35,3 +35,14 @@ export interface ChatAction { /** Handler function */ handler: (context: ChatActionContext) => Promise; } + +/** + * Options for filtering available actions + */ +export interface GetActionsOptions { + /** Current conversation */ + conversation?: Conversation; + + /** Active user's pubkey */ + activePubkey?: string; +}