From 797510107b7383eb658a18c99c989f8be8dd3c15 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Tue, 13 Jan 2026 12:39:22 +0100 Subject: [PATCH] Fix slash command autocomplete and add bookmark commands (#73) * fix: slash autocomplete only at input start + add bookmark commands - Fix slash command autocomplete to only trigger when / is at the beginning of input text (position 1 in TipTap), not in the middle of messages - Add /bookmark command to add NIP-29 group to user's kind 10009 list - Add /unbookmark command to remove group from user's kind 10009 list * fix: normalize relay URLs when checking group list bookmarks Use normalizeRelayURL for comparing relay URLs in bookmark/unbookmark commands to handle differences in trailing slashes, casing, and protocol prefixes between stored tags and conversation metadata. --------- Co-authored-by: Claude --- src/components/editor/MentionEditor.tsx | 3 + src/lib/chat/adapters/nip-29-adapter.ts | 242 +++++++++++++++++++++++- 2 files changed, 244 insertions(+), 1 deletion(-) diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index dfb5cee..a7f6a3b 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -345,6 +345,7 @@ export const MentionEditor = forwardRef< ); // Create slash command suggestion configuration for / commands + // Only triggers when / is at the very beginning of the input const slashCommandSuggestion: Omit | null = useMemo( () => @@ -352,6 +353,8 @@ export const MentionEditor = forwardRef< ? { char: "/", allowSpaces: false, + // Only allow slash commands at the start of input (position 1 in TipTap = first char) + allow: ({ range }) => range.from === 1, items: async ({ query }) => { return await searchCommands(query); }, diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 35433c2..87b698e 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -16,9 +16,10 @@ 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"; +import { publishEventToRelays, publishEvent } from "@/services/hub"; import accountManager from "@/services/accounts"; import { getTagValues } from "@/lib/nostr-utils"; +import { normalizeRelayURL } from "@/lib/relay-url"; import { EventFactory } from "applesauce-core/event-factory"; /** @@ -496,6 +497,8 @@ export class Nip29Adapter extends ChatProtocolAdapter { * 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 + * - /bookmark: only shown when group is NOT in user's kind 10009 list + * - /unbookmark: only shown when group IS in user's kind 10009 list */ getActions(options?: GetActionsOptions): ChatAction[] { const actions: ChatAction[] = []; @@ -563,6 +566,55 @@ export class Nip29Adapter extends ChatProtocolAdapter { }); } + // Add bookmark/unbookmark actions + // These are always available - the handler checks current state + actions.push({ + name: "bookmark", + description: "Add group to your group list", + handler: async (context) => { + try { + await this.bookmarkGroup(context.conversation, context.activePubkey); + return { + success: true, + message: "Group added to your list", + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error + ? error.message + : "Failed to bookmark group", + }; + } + }, + }); + + actions.push({ + name: "unbookmark", + description: "Remove group from your group list", + handler: async (context) => { + try { + await this.unbookmarkGroup( + context.conversation, + context.activePubkey, + ); + return { + success: true, + message: "Group removed from your list", + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error + ? error.message + : "Failed to unbookmark group", + }; + } + }, + }); + return actions; } @@ -612,6 +664,54 @@ export class Nip29Adapter extends ChatProtocolAdapter { } }, }, + { + name: "bookmark", + description: "Add group to your group list", + handler: async (context) => { + try { + await this.bookmarkGroup( + context.conversation, + context.activePubkey, + ); + return { + success: true, + message: "Group added to your list", + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error + ? error.message + : "Failed to bookmark group", + }; + } + }, + }, + { + name: "unbookmark", + description: "Remove group from your group list", + handler: async (context) => { + try { + await this.unbookmarkGroup( + context.conversation, + context.activePubkey, + ); + return { + success: true, + message: "Group removed from your list", + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error + ? error.message + : "Failed to unbookmark group", + }; + } + }, + }, ]; } @@ -755,6 +855,146 @@ export class Nip29Adapter extends ChatProtocolAdapter { await publishEventToRelays(event, [relayUrl]); } + /** + * Helper: Check if a tag matches a group by ID and relay URL (normalized comparison) + */ + private isMatchingGroupTag( + tag: string[], + groupId: string, + normalizedRelayUrl: string, + ): boolean { + if (tag[0] !== "group" || tag[1] !== groupId) { + return false; + } + // Normalize the tag's relay URL for comparison + try { + const tagRelayUrl = tag[2]; + if (!tagRelayUrl) return false; + return normalizeRelayURL(tagRelayUrl) === normalizedRelayUrl; + } catch { + // If normalization fails, try exact match as fallback + return tag[2] === normalizedRelayUrl; + } + } + + /** + * Add a group to the user's group list (kind 10009) + */ + async bookmarkGroup( + conversation: Conversation, + activePubkey: string, + ): Promise { + const activeSigner = accountManager.active$.value?.signer; + + if (!activeSigner) { + throw new Error("No active signer"); + } + + const groupId = conversation.metadata?.groupId; + const relayUrl = conversation.metadata?.relayUrl; + + if (!groupId || !relayUrl) { + throw new Error("Group ID and relay URL required"); + } + + // Normalize the relay URL for comparison + const normalizedRelayUrl = normalizeRelayURL(relayUrl); + + // Fetch current kind 10009 event (group list) + const currentEvent = await firstValueFrom( + eventStore.replaceable(10009, activePubkey, ""), + { defaultValue: undefined }, + ); + + // Build new tags array + let tags: string[][] = []; + + if (currentEvent) { + // Copy existing tags + tags = [...currentEvent.tags]; + + // Check if group is already in the list (using normalized URL comparison) + const existingGroup = tags.find((t) => + this.isMatchingGroupTag(t, groupId, normalizedRelayUrl), + ); + + if (existingGroup) { + throw new Error("Group is already in your list"); + } + } + + // Add the new group tag (use normalized URL for consistency) + tags.push(["group", groupId, normalizedRelayUrl]); + + // Create and publish the updated event + const factory = new EventFactory(); + factory.setSigner(activeSigner); + + const draft = await factory.build({ + kind: 10009, + content: "", + tags, + }); + const event = await factory.sign(draft); + await publishEvent(event); + } + + /** + * Remove a group from the user's group list (kind 10009) + */ + async unbookmarkGroup( + conversation: Conversation, + activePubkey: string, + ): Promise { + const activeSigner = accountManager.active$.value?.signer; + + if (!activeSigner) { + throw new Error("No active signer"); + } + + const groupId = conversation.metadata?.groupId; + const relayUrl = conversation.metadata?.relayUrl; + + if (!groupId || !relayUrl) { + throw new Error("Group ID and relay URL required"); + } + + // Normalize the relay URL for comparison + const normalizedRelayUrl = normalizeRelayURL(relayUrl); + + // Fetch current kind 10009 event (group list) + const currentEvent = await firstValueFrom( + eventStore.replaceable(10009, activePubkey, ""), + { defaultValue: undefined }, + ); + + if (!currentEvent) { + throw new Error("No group list found"); + } + + // Find and remove the group tag (using normalized URL comparison) + const originalLength = currentEvent.tags.length; + const tags = currentEvent.tags.filter( + (t) => !this.isMatchingGroupTag(t, groupId, normalizedRelayUrl), + ); + + if (tags.length === originalLength) { + throw new Error("Group is not in your list"); + } + + // Create and publish the updated event + const factory = new EventFactory(); + factory.setSigner(activeSigner); + + const draft = await factory.build({ + kind: 10009, + content: "", + tags, + }); + const event = await factory.sign(draft); + await publishEvent(event); + } + /** * Helper: Convert Nostr event to Message */