From efe5d3db1ce76aec99d16697d92805f1731f81a3 Mon Sep 17 00:00:00 2001 From: lemon Date: Sun, 3 May 2026 20:53:35 -0700 Subject: [PATCH] Show bookmarked communities in My Communities via NIP-51 kind 10004 Bookmarking a kind 34550 community now writes to the NIP-51 Communities list (kind 10004) keyed by the addressable coordinate, so the reference stays valid across community updates. My Communities merges bookmarked communities as a third discovery source alongside founded and member-of, with Founder/Member/Bookmarked badges on each card. Bookmark toasts live on the mutation itself so they survive the more-menu dialog unmounting between .mutate() and publish resolution. --- NIP.md | 17 ++++ src/components/CommunityCard.tsx | 28 +++++- src/components/NoteMoreMenu.tsx | 40 +++++++- src/hooks/useCommunityBookmarks.ts | 128 ++++++++++++++++++++++++ src/hooks/useMyCommunities.ts | 150 +++++++++++++++++++++++++---- src/pages/CommunitiesPage.tsx | 2 + 6 files changed, 340 insertions(+), 25 deletions(-) create mode 100644 src/hooks/useCommunityBookmarks.ts diff --git a/NIP.md b/NIP.md index 84ccd864..fb92c996 100644 --- a/NIP.md +++ b/NIP.md @@ -791,6 +791,22 @@ Both kind `34550` and kind `30009` are addressable events. To add or remove rank 2. Extract badge `a` tags from results. 3. `{ "kinds": [34550], "#a": ["30009:...", "..."] }` +**Communities a user has bookmarked:** + +Agora uses [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) kind `10004` ("Communities") to let users save communities they want quick access to without requiring membership. Bookmarked communities are surfaced in the "My Communities" view alongside founded and member-of communities. + +1. `{ "kinds": [10004], "authors": [""], "limit": 1 }` +2. Extract `a` tags whose value begins with `34550:` from the result. +3. For each coordinate `34550::`, query the community definition with both `authors` and `#d` filters to prevent spoofing: + + ```jsonc + { "kinds": [34550], "authors": [""], "#d": [""], "limit": 1 } + ``` + +Clients toggling a bookmark MUST perform a read-modify-write cycle on the replaceable kind `10004` event: fetch the freshest version from relays, add or remove the matching `["a", "34550::"]` tag, and republish the full tag list. Appending new entries to the end preserves chronological bookmark order per NIP-51. + +When the same community appears in multiple discovery sources, clients SHOULD display a single card but MAY indicate all applicable relationships (e.g. a member who has also bookmarked a community). + ### Security Considerations - **Author filtering**: Clients MUST filter community definitions by `authors` to prevent impersonation. @@ -804,6 +820,7 @@ Both kind `34550` and kind `30009` are addressable events. To add or remove rank - [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md) -- Comment - [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) -- Unknown Event Kinds (`alt` tag) - [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) -- Labeling (moderation `ban` label) +- [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) -- Lists (kind `10004` Communities list for bookmarks) - [NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md) -- Reporting - [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) -- Badges - [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) -- Moderated Communities diff --git a/src/components/CommunityCard.tsx b/src/components/CommunityCard.tsx index 5b38b620..8c511b9a 100644 --- a/src/components/CommunityCard.tsx +++ b/src/components/CommunityCard.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { Link } from 'react-router-dom'; -import { Crown, Shield, Users } from 'lucide-react'; +import { Bookmark, Crown, Shield, Users } from 'lucide-react'; import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -18,6 +18,10 @@ interface CommunityCardProps { event: NostrEvent; /** Whether the current user founded this community. */ isFounded?: boolean; + /** Whether the current user is a validated member (rank > 0 via badge chain). */ + isMember?: boolean; + /** Whether the current user has bookmarked this community (NIP-51 kind 10004). */ + isBookmarked?: boolean; className?: string; } @@ -25,7 +29,13 @@ interface CommunityCardProps { * Compact card for displaying a community in a list. * Shows image, name, description snippet, founder info, and moderator count. */ -export function CommunityCard({ event, isFounded, className }: CommunityCardProps) { +export function CommunityCard({ + event, + isFounded, + isMember, + isBookmarked, + className, +}: CommunityCardProps) { const community = useMemo(() => parseCommunityEvent(event), [event]); const founderAuthor = useAuthor(event.pubkey); @@ -75,12 +85,22 @@ export function CommunityCard({ event, isFounded, className }: CommunityCardProp

{community.name}

- {isFounded && ( + {isFounded ? ( Founder - )} + ) : isMember ? ( + + + Member + + ) : isBookmarked ? ( + + + Bookmarked + + ) : null} {/* Description */} diff --git a/src/components/NoteMoreMenu.tsx b/src/components/NoteMoreMenu.tsx index 8fd2476b..0df3ded4 100644 --- a/src/components/NoteMoreMenu.tsx +++ b/src/components/NoteMoreMenu.tsx @@ -47,6 +47,7 @@ import { CommunityReportDialog } from '@/components/CommunityReportDialog'; import { AddToListDialog } from '@/components/AddToListDialog'; import { useNostr } from '@nostrify/react'; import { useBookmarks } from '@/hooks/useBookmarks'; +import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks'; import { usePinnedNotes } from '@/hooks/usePinnedNotes'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; @@ -57,7 +58,7 @@ import { useOrganizers } from '@/hooks/useOrganizers'; import { usePinnedPosts } from '@/hooks/usePinnedPosts'; import { useCountryFeed } from '@/contexts/CountryFeedContext'; import { useCommunityModerationContext } from '@/contexts/CommunityModerationContext'; -import { type CommunityMenuContext, canBanTarget, getViewerAuthority } from '@/lib/communityUtils'; +import { type CommunityMenuContext, canBanTarget, getViewerAuthority, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils'; // NOTE: `CommunityMenuContext` is derived automatically from // `useCommunityModerationContext()`. Parents install a // `CommunityModerationContext.Provider` to enable community-aware menu items. @@ -368,7 +369,26 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe const navigate = useNavigate(); const { user } = useCurrentUser(); const { isBookmarked, toggleBookmark } = useBookmarks(); - const bookmarked = isBookmarked(event.id); + const { + isBookmarked: isCommunityBookmarked, + toggleBookmark: toggleCommunityBookmark, + } = useCommunityBookmarks(); + + // Community events (kind 34550) are bookmarked via NIP-51 kind 10004 + // using their addressable `a` tag coordinate, so the reference stays + // valid across community updates. Non-community events use the standard + // kind 10003 bookmark list keyed by event id. + const isCommunityEvent = event.kind === COMMUNITY_DEFINITION_KIND; + const communityATag = useMemo(() => { + if (!isCommunityEvent) return undefined; + const dTag = event.tags.find(([n]) => n === 'd')?.[1]; + if (!dTag) return undefined; + return `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`; + }, [isCommunityEvent, event.pubkey, event.tags]); + + const bookmarked = isCommunityEvent + ? !!communityATag && isCommunityBookmarked(communityATag) + : isBookmarked(event.id); const { isPinned, togglePin } = usePinnedNotes(user?.pubkey); const pinned = isPinned(event.id); const isOwnPost = user?.pubkey === event.pubkey; @@ -411,7 +431,15 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe const handleBookmark = () => { impactLight(); - toggleBookmark.mutate(event.id); + if (isCommunityEvent) { + if (!communityATag) return; + // Success/error toasts are fired from the mutation itself in + // useCommunityBookmarks so they survive this dialog unmounting + // before the publish resolves. + toggleCommunityBookmark.mutate({ aTag: communityATag }); + } else { + toggleBookmark.mutate(event.id); + } close(); }; @@ -548,7 +576,11 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe /> } - label={bookmarked ? 'Remove Bookmark' : 'Bookmark'} + label={ + isCommunityEvent + ? (bookmarked ? 'Remove community bookmark' : 'Bookmark community') + : (bookmarked ? 'Remove Bookmark' : 'Bookmark') + } onClick={handleBookmark} /> {user && ( diff --git a/src/hooks/useCommunityBookmarks.ts b/src/hooks/useCommunityBookmarks.ts new file mode 100644 index 00000000..c0d18a7e --- /dev/null +++ b/src/hooks/useCommunityBookmarks.ts @@ -0,0 +1,128 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useCurrentUser } from './useCurrentUser'; +import { useNostrPublish } from './useNostrPublish'; +import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; +import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils'; +import { toast } from '@/hooks/useToast'; + +/** NIP-51 Communities list — kind 10004. */ +export const COMMUNITIES_LIST_KIND = 10004; + +/** + * Hook to manage the user's NIP-51 Communities list (kind 10004). + * + * This list stores `a` tag coordinates for kind 34550 community definitions + * that the user has bookmarked / "saved". Unlike `useBookmarks` (kind 10003) + * which targets event IDs, this list targets addressable coordinates so the + * reference remains stable across community updates. + */ +export function useCommunityBookmarks() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + const { mutateAsync: publishEvent } = useNostrPublish(); + + // Query the user's communities list (kind 10004 — replaceable event) + const listQuery = useQuery({ + queryKey: ['community-bookmarks', user?.pubkey], + queryFn: async () => { + if (!user) return null; + const events = await nostr.query([{ + kinds: [COMMUNITIES_LIST_KIND], + authors: [user.pubkey], + limit: 1, + }]); + return events[0] ?? null; + }, + enabled: !!user, + }); + + // Extract bookmarked community a-tags (only `34550:` coordinates) + const bookmarkedATags: string[] = (listQuery.data?.tags ?? []) + .filter(([name, value]) => + name === 'a' && typeof value === 'string' && value.startsWith(`${COMMUNITY_DEFINITION_KIND}:`), + ) + .map(([, value]) => value); + + /** Check if a community `a` tag coordinate is bookmarked. */ + function isBookmarked(aTag: string): boolean { + return bookmarkedATags.includes(aTag); + } + + /** + * Toggle bookmark for a given community coordinate. + * `aTag` is expected to be a `34550::` string. + * `relayHint` is optional — appended to the tag per NIP-51 when provided. + */ + const toggleBookmark = useMutation({ + mutationFn: async ({ aTag, relayHint }: { aTag: string; relayHint?: string }) => { + if (!user) throw new Error('User is not logged in'); + + // Fetch the freshest kind 10004 from relays before mutating + const prev = await fetchFreshEvent(nostr, { + kinds: [COMMUNITIES_LIST_KIND], + authors: [user.pubkey], + }); + + const currentTags = prev?.tags ?? []; + const currentlyBookmarked = currentTags.some( + ([name, value]) => name === 'a' && value === aTag, + ); + + let newTags: string[][]; + + if (currentlyBookmarked) { + // Remove all matching a-tags for this coordinate + newTags = currentTags.filter( + ([name, value]) => !(name === 'a' && value === aTag), + ); + } else { + // Append the new bookmark per NIP-51 recommendation + const newTag: string[] = relayHint ? ['a', aTag, relayHint] : ['a', aTag]; + newTags = [...currentTags, newTag]; + } + + await publishEvent({ + kind: COMMUNITIES_LIST_KIND, + content: prev?.content ?? '', + tags: newTags, + created_at: Math.floor(Date.now() / 1000), + prev: prev ?? undefined, + }); + + // Return whether this was a remove or add so onSuccess can pick the + // right toast wording. Callbacks live on the mutation (not per-call) + // so they still fire when the triggering UI (e.g. a dialog) unmounts + // before the publish resolves. + return { removed: currentlyBookmarked }; + }, + onSuccess: ({ removed }) => { + queryClient.invalidateQueries({ queryKey: ['community-bookmarks', user?.pubkey] }); + queryClient.invalidateQueries({ queryKey: ['my-communities'] }); + toast({ + title: removed ? 'Community removed from bookmarks' : 'Community bookmarked', + }); + }, + onError: () => { + toast({ + title: 'Failed to update bookmark', + variant: 'destructive', + }); + }, + }); + + return { + /** The kind 10004 list event itself. */ + listEvent: listQuery.data, + /** Array of bookmarked community `a` tag coordinates. */ + bookmarkedATags, + /** Whether the list query is still loading. */ + isLoading: listQuery.isLoading, + /** Check whether a given `a` tag coordinate is bookmarked. */ + isBookmarked, + /** Toggle a community bookmark on/off. */ + toggleBookmark, + }; +} diff --git a/src/hooks/useMyCommunities.ts b/src/hooks/useMyCommunities.ts index 17178848..22430196 100644 --- a/src/hooks/useMyCommunities.ts +++ b/src/hooks/useMyCommunities.ts @@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; import { useCurrentUser } from './useCurrentUser'; +import { COMMUNITIES_LIST_KIND } from './useCommunityBookmarks'; import { COMMUNITY_DEFINITION_KIND, BADGE_AWARD_KIND, @@ -17,15 +18,26 @@ export interface MyCommunityEntry { event: NostrEvent; /** Whether the current user is the founder. */ isFounded: boolean; + /** Whether the current user is a validated (chain-derived) member. */ + isMember: boolean; + /** Whether the current user has bookmarked the community via kind 10004. */ + isBookmarked: boolean; } /** - * Fetch communities the logged-in user has founded or been recruited into. + * Fetch communities the logged-in user has founded, been recruited into, + * or bookmarked via their NIP-51 Communities list (kind 10004). * - * Discovery follows the NIP: - * 1. Founded: `{ kinds: [34550], authors: [] }` - * 2. Member-of: query kind 8 awards targeting the user, extract badge `a` tags, + * Discovery: + * + * 1. Founded -- `{ kinds: [34550], authors: [user.pubkey] }` + * 2. Member-of -- kind 8 awards targeting the user, extract badge `a` tags, * then find the community definitions referencing those badges. + * 3. Bookmarked -- read kind 10004 authored by user, extract `a` tags + * pointing at kind 34550 events, and fetch those community definitions. + * + * Priority when the same community appears in multiple sources: + * founded > member > bookmarked. */ export function useMyCommunities() { const { nostr } = useNostr(); @@ -39,17 +51,27 @@ export function useMyCommunities() { const timeout = AbortSignal.timeout(10_000); const combinedSignal = AbortSignal.any([signal, timeout]); - // Step 1: Communities founded by the user + // ── Step 1: Communities founded by the user ─────────────────────────── const foundedEvents = await nostr.query( [{ kinds: [COMMUNITY_DEFINITION_KIND], authors: [user.pubkey], limit: 50 }], { signal: combinedSignal }, ); - // Step 2: Badge awards targeting the user - const awards = await nostr.query( - [{ kinds: [BADGE_AWARD_KIND], '#p': [user.pubkey], limit: 200 }], - { signal: combinedSignal }, - ); + // ── Step 2: Badge awards targeting the user + Bookmarks list ────────── + // + // Batched into a single relay round-trip. The kind 10004 list is a + // replaceable event, so pulling it here keeps the read path tight and + // reuses the same connection. + const [awards, bookmarkListEvents] = await Promise.all([ + nostr.query( + [{ kinds: [BADGE_AWARD_KIND], '#p': [user.pubkey], limit: 200 }], + { signal: combinedSignal }, + ), + nostr.query( + [{ kinds: [COMMUNITIES_LIST_KIND], authors: [user.pubkey], limit: 1 }], + { signal: combinedSignal }, + ), + ]); // Extract badge a-tag coordinates from awards const badgeATags = new Set(); @@ -70,26 +92,120 @@ export function useMyCommunities() { ); } - // Merge and deduplicate (founded takes priority) + // ── Step 4: Resolve bookmarked community coordinates ────────────────── + // + // NIP-51 kind 10004 stores community definitions as `a` tags like + // `34550::`. For each bookmarked coordinate we query + // with both `authors` and `#d` so relays return a single authentic + // event per bookmark (per AGENTS.md security guidance on addressable + // events). + // + // Multiple coordinates with the same author are grouped to minimise + // the number of relay queries while keeping the author filter intact. + + const bookmarkListEvent = bookmarkListEvents[0]; + const bookmarkedCoords: string[] = (bookmarkListEvent?.tags ?? []) + .filter(([n, v]) => + n === 'a' + && typeof v === 'string' + && v.startsWith(`${COMMUNITY_DEFINITION_KIND}:`), + ) + .map(([, v]) => v); + + // Group bookmarked coords by author pubkey: author -> Set + const coordsByAuthor = new Map>(); + for (const coord of bookmarkedCoords) { + // Format: "34550::" -- d-tag may itself contain ":" + const firstColon = coord.indexOf(':'); + const secondColon = coord.indexOf(':', firstColon + 1); + if (firstColon === -1 || secondColon === -1) continue; + const authorPubkey = coord.slice(firstColon + 1, secondColon); + const dTag = coord.slice(secondColon + 1); + if (!authorPubkey || !dTag) continue; + const existing = coordsByAuthor.get(authorPubkey); + if (existing) { + existing.add(dTag); + } else { + coordsByAuthor.set(authorPubkey, new Set([dTag])); + } + } + + let bookmarkedCommunityEvents: NostrEvent[] = []; + if (coordsByAuthor.size > 0) { + const bookmarkQueries = await Promise.all( + Array.from(coordsByAuthor.entries()).map(([authorPubkey, dTags]) => + nostr.query( + [{ + kinds: [COMMUNITY_DEFINITION_KIND], + authors: [authorPubkey], + '#d': [...dTags], + limit: dTags.size, + }], + { signal: combinedSignal }, + ), + ), + ); + bookmarkedCommunityEvents = bookmarkQueries.flat(); + } + + const bookmarkedATagSet = new Set(bookmarkedCoords); + + // ── Merge & deduplicate ────────────────────────────────────────────── + // Priority: founded > member > bookmarked. `isBookmarked` is resolved + // from the bookmark list irrespective of which bucket produced the + // entry, so founders/members who have also bookmarked see both flags. const seen = new Map(); for (const event of foundedEvents) { const community = parseCommunityEvent(event); if (!community) continue; - seen.set(community.aTag, { community, event, isFounded: true }); + seen.set(community.aTag, { + community, + event, + isFounded: true, + isMember: false, + isBookmarked: bookmarkedATagSet.has(community.aTag), + }); } for (const event of memberCommunityEvents) { const community = parseCommunityEvent(event); if (!community) continue; - if (!seen.has(community.aTag)) { - seen.set(community.aTag, { community, event, isFounded: false }); - } + if (seen.has(community.aTag)) continue; + seen.set(community.aTag, { + community, + event, + isFounded: false, + isMember: true, + isBookmarked: bookmarkedATagSet.has(community.aTag), + }); } - // Sort: founded first, then by created_at descending + for (const event of bookmarkedCommunityEvents) { + const community = parseCommunityEvent(event); + if (!community) continue; + if (!bookmarkedATagSet.has(community.aTag)) continue; + if (seen.has(community.aTag)) continue; + seen.set(community.aTag, { + community, + event, + isFounded: false, + isMember: false, + isBookmarked: true, + }); + } + + // Sort: founded first, then member, then bookmarked-only; + // tie-break by created_at descending. + const sortRank = (entry: MyCommunityEntry): number => { + if (entry.isFounded) return 0; + if (entry.isMember) return 1; + return 2; + }; + return Array.from(seen.values()).sort((a, b) => { - if (a.isFounded !== b.isFounded) return a.isFounded ? -1 : 1; + const rankDiff = sortRank(a) - sortRank(b); + if (rankDiff !== 0) return rankDiff; return b.event.created_at - a.event.created_at; }); }, diff --git a/src/pages/CommunitiesPage.tsx b/src/pages/CommunitiesPage.tsx index c3da94a9..9de4e460 100644 --- a/src/pages/CommunitiesPage.tsx +++ b/src/pages/CommunitiesPage.tsx @@ -215,6 +215,8 @@ function MyCommunitiesContent() { key={entry.community.aTag} event={entry.event} isFounded={entry.isFounded} + isMember={entry.isMember} + isBookmarked={entry.isBookmarked} /> ))}