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.
This commit is contained in:
lemon
2026-05-03 20:53:35 -07:00
parent 9ac379b259
commit efe5d3db1c
6 changed files with 340 additions and 25 deletions
+17
View File
@@ -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": ["<user-pubkey>"], "limit": 1 }`
2. Extract `a` tags whose value begins with `34550:` from the result.
3. For each coordinate `34550:<author-pubkey>:<d-tag>`, query the community definition with both `authors` and `#d` filters to prevent spoofing:
```jsonc
{ "kinds": [34550], "authors": ["<author-pubkey>"], "#d": ["<d-tag>"], "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:<pubkey>:<d-tag>"]` 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
+24 -4
View File
@@ -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
<h3 className="text-sm font-semibold truncate flex-1 group-hover:text-primary transition-colors">
{community.name}
</h3>
{isFounded && (
{isFounded ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
<Crown className="size-2.5" />
Founder
</Badge>
)}
) : isMember ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
<Shield className="size-2.5" />
Member
</Badge>
) : isBookmarked ? (
<Badge variant="secondary" className="text-[10px] gap-1 shrink-0 bg-primary/10 text-primary border-primary/20">
<Bookmark className="size-2.5 fill-current" />
Bookmarked
</Badge>
) : null}
</div>
{/* Description */}
+36 -4
View File
@@ -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
/>
<MenuItem
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
label={bookmarked ? 'Remove Bookmark' : 'Bookmark'}
label={
isCommunityEvent
? (bookmarked ? 'Remove community bookmark' : 'Bookmark community')
: (bookmarked ? 'Remove Bookmark' : 'Bookmark')
}
onClick={handleBookmark}
/>
{user && (
+128
View File
@@ -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:<pubkey>:<d-tag>` 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,
};
}
+133 -17
View File
@@ -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: [<user-pubkey>] }`
* 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<string>();
@@ -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:<pubkey>:<d-tag>`. 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<d-tag>
const coordsByAuthor = new Map<string, Set<string>>();
for (const coord of bookmarkedCoords) {
// Format: "34550:<pubkey>:<d-tag>" -- 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<string, MyCommunityEntry>();
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;
});
},
+2
View File
@@ -215,6 +215,8 @@ function MyCommunitiesContent() {
key={entry.community.aTag}
event={entry.event}
isFounded={entry.isFounded}
isMember={entry.isMember}
isBookmarked={entry.isBookmarked}
/>
))}
</div>