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
This commit is contained in:
Claude
2026-01-13 10:27:43 +00:00
parent c6e041eeb0
commit feb95cf4d7
4 changed files with 113 additions and 17 deletions

View File

@@ -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 () => {

View File

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

View File

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

View File

@@ -35,3 +35,14 @@ export interface ChatAction {
/** 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;
}