From c82c6f41797f477ed357542c48dc2a132d333f72 Mon Sep 17 00:00:00 2001 From: lemon Date: Sat, 18 Apr 2026 14:58:49 -0700 Subject: [PATCH 01/13] add communities page with NIP-72 hierarchical community support --- src/App.tsx | 3 + src/AppRouter.tsx | 2 + src/components/CommunityCard.tsx | 129 +++++++++++++ src/components/InitialSyncGate.tsx | 2 + src/components/NoteCard.tsx | 11 ++ src/contexts/AppContext.ts | 4 + src/hooks/useCommunityFeed.ts | 91 +++++++++ src/hooks/useCommunityMembers.ts | 56 ++++++ src/hooks/useFeedSettings.ts | 1 + src/hooks/useMyCommunities.ts | 99 ++++++++++ src/lib/communityUtils.ts | 293 +++++++++++++++++++++++++++++ src/lib/extraKinds.ts | 12 ++ src/lib/schemas.ts | 2 + src/lib/sidebarItems.tsx | 3 + src/pages/CommunitiesPage.tsx | 252 +++++++++++++++++++++++++ src/test/TestApp.tsx | 2 + 16 files changed, 962 insertions(+) create mode 100644 src/components/CommunityCard.tsx create mode 100644 src/hooks/useCommunityFeed.ts create mode 100644 src/hooks/useCommunityMembers.ts create mode 100644 src/hooks/useMyCommunities.ts create mode 100644 src/lib/communityUtils.ts create mode 100644 src/pages/CommunitiesPage.tsx diff --git a/src/App.tsx b/src/App.tsx index a54db4af..b6f44071 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -112,6 +112,8 @@ const hardcodedConfig: AppConfig = { feedIncludePodcastTrailers: true, showDevelopment: true, feedIncludeDevelopment: true, + showCommunities: true, + feedIncludeCommunities: true, showBadges: true, showBadgeDefinitions: true, showProfileBadges: true, @@ -130,6 +132,7 @@ const hardcodedConfig: AppConfig = { "badges", "feed", "notifications", + "communities", "profile", "settings", ], diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 1e4011f8..72ec2bac 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -32,6 +32,7 @@ const ActionsPage = lazy(() => import("./pages/ActionsPage")); const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage }))); const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage }))); const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage }))); +const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage }))); const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage }))); const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage }))); const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage }))); @@ -164,6 +165,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/CommunityCard.tsx b/src/components/CommunityCard.tsx new file mode 100644 index 00000000..5b38b620 --- /dev/null +++ b/src/components/CommunityCard.tsx @@ -0,0 +1,129 @@ +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { Crown, Shield, Users } from 'lucide-react'; +import { nip19 } from 'nostr-tools'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useProfileUrl } from '@/hooks/useProfileUrl'; +import { genUserName } from '@/lib/genUserName'; +import { getAvatarShape } from '@/lib/avatarShape'; +import { parseCommunityEvent, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils'; +import { cn } from '@/lib/utils'; + +interface CommunityCardProps { + /** The kind 34550 community definition event. */ + event: NostrEvent; + /** Whether the current user founded this community. */ + isFounded?: boolean; + className?: string; +} + +/** + * 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) { + const community = useMemo(() => parseCommunityEvent(event), [event]); + + const founderAuthor = useAuthor(event.pubkey); + const founderMeta = founderAuthor.data?.metadata; + const founderAvatarShape = getAvatarShape(founderMeta); + const founderName = founderMeta?.display_name || founderMeta?.name || genUserName(event.pubkey); + const founderProfileUrl = useProfileUrl(event.pubkey, founderMeta); + + if (!community) return null; + + const naddr = nip19.naddrEncode({ + kind: COMMUNITY_DEFINITION_KIND, + pubkey: event.pubkey, + identifier: community.dTag, + }); + + const rankCount = community.ranks.length - 1; // Exclude rank 0 + + return ( + + {/* Image banner */} + {community.image ? ( +
+ {community.name} +
+
+ ) : ( +
+ +
+ )} + + {/* Content */} +
+ {/* Name + founder badge */} +
+

+ {community.name} +

+ {isFounded && ( + + + Founder + + )} +
+ + {/* Description */} + {community.description && ( +

+ {community.description} +

+ )} + + {/* Footer: founder + stats */} +
+ e.stopPropagation()} + className="flex items-center gap-1.5 min-w-0" + > + + + + {founderName.charAt(0).toUpperCase()} + + + + {founderName} + + + +
+ {community.moderatorPubkeys.length > 0 && ( + + + {community.moderatorPubkeys.length} + + )} + {rankCount > 0 && ( + + + {rankCount} rank{rankCount !== 1 ? 's' : ''} + + )} +
+
+
+ + ); +} diff --git a/src/components/InitialSyncGate.tsx b/src/components/InitialSyncGate.tsx index 478b6ae1..9d3a3496 100644 --- a/src/components/InitialSyncGate.tsx +++ b/src/components/InitialSyncGate.tsx @@ -399,6 +399,8 @@ function SetupQuestionnaire({ feedIncludePodcastTrailers: false, showDevelopment: false, feedIncludeDevelopment: false, + showCommunities: true, + feedIncludeCommunities: true, showBadges: false, showBadgeDefinitions: true, showProfileBadges: true, diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 329dbcb8..c886e0df 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -34,6 +34,7 @@ import { PodcastTrailerContent, } from "@/components/AudioKindContent"; import { BadgeContent } from "@/components/BadgeContent"; +import { CommunityContent } from "@/components/CommunityContent"; import { CalendarEventContent } from "@/components/CalendarEventContent"; import { ColorMomentContent, @@ -404,6 +405,7 @@ export const NoteCard = memo(function NoteCard({ const isBadgeDefinition = event.kind === 30009; const isProfileBadges = event.kind === 10008 || event.kind === 30008; const isBadge = isBadgeDefinition || isProfileBadges; + const isCommunity = event.kind === 34550; const isReaction = event.kind === 7; const isPollVote = event.kind === 1018; const isRepost = event.kind === 6 || event.kind === 16; @@ -449,6 +451,7 @@ export const NoteCard = memo(function NoteCard({ !isCalendarEvent && !isEmojiPack && !isBadge && + !isCommunity && !isReaction && !isPollVote && !isRepost && @@ -603,6 +606,8 @@ export const NoteCard = memo(function NoteCard({ ) : isProfileBadges ? ( + ) : isCommunity ? ( + ) : isTheme ? ( ) : isVoiceMessage ? ( @@ -1704,6 +1709,12 @@ const KIND_HEADER_MAP: Record = { noun: "emoji pack", nounRoute: "/emojis", }, + 34550: { + icon: Users, + action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }), + noun: "community", + nounRoute: "/communities", + }, 30009: { icon: Award, action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }), diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 699b5ab8..45d963fd 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -136,6 +136,10 @@ export interface FeedSettings { showDevelopment: boolean; /** Include Development content in the follows/global feed */ feedIncludeDevelopment: boolean; + /** Show Communities (NIP-72 kind 34550) link in sidebar */ + showCommunities: boolean; + /** Include community definitions (kind 34550) in the follows/global feed */ + feedIncludeCommunities: boolean; /** Show Badges (NIP-58 kind 30009) link in sidebar */ showBadges: boolean; /** Show badge definitions (kind 30009) on the Badges page */ diff --git a/src/hooks/useCommunityFeed.ts b/src/hooks/useCommunityFeed.ts new file mode 100644 index 00000000..171ccfdb --- /dev/null +++ b/src/hooks/useCommunityFeed.ts @@ -0,0 +1,91 @@ +import { useNostr } from '@nostrify/react'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { useCurrentUser } from './useCurrentUser'; +import { useFollowList } from './useFollowActions'; +import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils'; +import { TEAM_SOAPBOX_PACK } from '@/lib/helpContent'; + +const PAGE_SIZE = 20; + +/** + * Infinite-scroll feed of community definition events (kind 34550) + * from users the current user follows. + * + * When logged out, uses the Team Soapbox follow pack as the author list + * (same pattern as useBadgeFeed). + */ +export function useCommunityFeed() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { data: followData } = useFollowList(); + const followList = followData?.pubkeys; + + // When logged out, fetch the Team Soapbox follow pack for default authors + const { data: packPubkeys } = useQuery({ + queryKey: ['team-soapbox-pack-pubkeys'], + queryFn: async ({ signal }) => { + const events = await nostr.query( + [{ + kinds: [TEAM_SOAPBOX_PACK.kind], + authors: [TEAM_SOAPBOX_PACK.pubkey], + '#d': [TEAM_SOAPBOX_PACK.identifier], + limit: 1, + }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) }, + ); + if (events.length === 0) return []; + return events[0].tags.filter(([n]) => n === 'p').map(([, pk]) => pk); + }, + enabled: !user, + staleTime: 10 * 60_000, + }); + + const followsReady = user ? followList !== undefined : packPubkeys !== undefined; + + const authorsList = user ? followList : packPubkeys; + const authorsKey = authorsList ? [...authorsList].sort().join(',') : ''; + + return useInfiniteQuery({ + queryKey: ['community-feed', 'follows', user?.pubkey ?? '', authorsKey], + queryFn: async ({ pageParam, signal }) => { + const baseUntil = pageParam as number | undefined; + + let authors: string[] | undefined; + if (user && followList) { + authors = followList.length > 0 ? [...followList, user.pubkey] : [user.pubkey]; + } else if (!user && packPubkeys && packPubkeys.length > 0) { + authors = packPubkeys; + } + + const events = await nostr.query( + [{ + kinds: [COMMUNITY_DEFINITION_KIND], + ...(authors ? { authors } : {}), + ...(baseUntil ? { until: baseUntil } : {}), + limit: PAGE_SIZE, + }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, + ); + + // Deduplicate and sort + const seen = new Set(); + return events + .filter((event) => { + if (seen.has(event.id)) return false; + seen.add(event.id); + return true; + }) + .sort((a, b) => b.created_at - a.created_at) + .slice(0, PAGE_SIZE); + }, + getNextPageParam: (lastPage: NostrEvent[]) => { + if (lastPage.length === 0) return undefined; + return lastPage[lastPage.length - 1].created_at - 1; + }, + initialPageParam: undefined as number | undefined, + enabled: followsReady, + staleTime: 2 * 60_000, + }); +} diff --git a/src/hooks/useCommunityMembers.ts b/src/hooks/useCommunityMembers.ts new file mode 100644 index 00000000..527e510d --- /dev/null +++ b/src/hooks/useCommunityMembers.ts @@ -0,0 +1,56 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; + +import { + type ParsedCommunity, + type CommunityMembership, + BADGE_AWARD_KIND, + REPORT_KIND, + DELETION_KIND, + resolveMembership, +} from '@/lib/communityUtils'; + +/** + * Fetch and resolve the full membership tree for a community. + * + * Queries badge awards (kind 8), reports (kind 1984), and deletions (kind 5) + * then runs the chain validation algorithm from the community NIP. + */ +export function useCommunityMembers(community: ParsedCommunity | null | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['community-members', community?.aTag ?? ''], + queryFn: async ({ signal }) => { + if (!community) return { members: [], totalCount: 0 }; + + // Collect all badge a-tag coordinates from the community definition + const badgeATags = community.ranks + .filter((r) => r.badgeATag) + .map((r) => r.badgeATag!); + + if (badgeATags.length === 0) { + // No badge ranks defined — only founder + moderators + return resolveMembership(community, [], [], []); + } + + // Single combined query for awards, reports, and deletions + const events = await nostr.query( + [ + { kinds: [BADGE_AWARD_KIND], '#a': badgeATags, limit: 500 }, + { kinds: [REPORT_KIND], '#A': [community.aTag], limit: 200 }, + { kinds: [DELETION_KIND], '#k': ['8', '1984'], limit: 200 }, + ], + { signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]) }, + ); + + const awards = events.filter((e) => e.kind === BADGE_AWARD_KIND); + const reports = events.filter((e) => e.kind === REPORT_KIND); + const deletions = events.filter((e) => e.kind === DELETION_KIND); + + return resolveMembership(community, awards, reports, deletions); + }, + enabled: !!community, + staleTime: 2 * 60_000, + }); +} diff --git a/src/hooks/useFeedSettings.ts b/src/hooks/useFeedSettings.ts index cb4727e9..d35de7fc 100644 --- a/src/hooks/useFeedSettings.ts +++ b/src/hooks/useFeedSettings.ts @@ -20,6 +20,7 @@ const DEFAULT_SIDEBAR_ORDER: string[] = [ 'badges', 'feed', 'notifications', + 'communities', 'profile', 'settings', ]; diff --git a/src/hooks/useMyCommunities.ts b/src/hooks/useMyCommunities.ts new file mode 100644 index 00000000..17178848 --- /dev/null +++ b/src/hooks/useMyCommunities.ts @@ -0,0 +1,99 @@ +import type { NostrEvent } from '@nostrify/nostrify'; +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; + +import { useCurrentUser } from './useCurrentUser'; +import { + COMMUNITY_DEFINITION_KIND, + BADGE_AWARD_KIND, + parseCommunityEvent, + type ParsedCommunity, +} from '@/lib/communityUtils'; + +export interface MyCommunityEntry { + /** The parsed community data. */ + community: ParsedCommunity; + /** The raw kind 34550 event. */ + event: NostrEvent; + /** Whether the current user is the founder. */ + isFounded: boolean; +} + +/** + * Fetch communities the logged-in user has founded or been recruited into. + * + * Discovery follows the NIP: + * 1. Founded: `{ kinds: [34550], authors: [] }` + * 2. Member-of: query kind 8 awards targeting the user, extract badge `a` tags, + * then find the community definitions referencing those badges. + */ +export function useMyCommunities() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + + return useQuery({ + queryKey: ['my-communities', user?.pubkey ?? ''], + queryFn: async ({ signal }) => { + if (!user) return []; + + const timeout = AbortSignal.timeout(10_000); + const combinedSignal = AbortSignal.any([signal, timeout]); + + // 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 }, + ); + + // Extract badge a-tag coordinates from awards + const badgeATags = new Set(); + for (const award of awards) { + for (const tag of award.tags) { + if (tag[0] === 'a' && tag[1]?.startsWith('30009:')) { + badgeATags.add(tag[1]); + } + } + } + + // Step 3: Find community definitions that reference these badges + let memberCommunityEvents: NostrEvent[] = []; + if (badgeATags.size > 0) { + memberCommunityEvents = await nostr.query( + [{ kinds: [COMMUNITY_DEFINITION_KIND], '#a': [...badgeATags], limit: 100 }], + { signal: combinedSignal }, + ); + } + + // Merge and deduplicate (founded takes priority) + const seen = new Map(); + + for (const event of foundedEvents) { + const community = parseCommunityEvent(event); + if (!community) continue; + seen.set(community.aTag, { community, event, isFounded: true }); + } + + 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 }); + } + } + + // Sort: founded first, then by created_at descending + return Array.from(seen.values()).sort((a, b) => { + if (a.isFounded !== b.isFounded) return a.isFounded ? -1 : 1; + return b.event.created_at - a.event.created_at; + }); + }, + enabled: !!user, + staleTime: 2 * 60_000, + }); +} diff --git a/src/lib/communityUtils.ts b/src/lib/communityUtils.ts new file mode 100644 index 00000000..1a4e32e6 --- /dev/null +++ b/src/lib/communityUtils.ts @@ -0,0 +1,293 @@ +import type { NostrEvent } from '@nostrify/nostrify'; + +import { sanitizeUrl } from '@/lib/sanitizeUrl'; + +// ── Kind constants ──────────────────────────────────────────────────────────── + +/** NIP-72 community definition (addressable). */ +export const COMMUNITY_DEFINITION_KIND = 34550; + +/** NIP-58 badge definition. */ +export const BADGE_DEFINITION_KIND = 30009; + +/** NIP-58 badge award. */ +export const BADGE_AWARD_KIND = 8; + +/** NIP-56 report / moderation. */ +export const REPORT_KIND = 1984; + +/** NIP-09 deletion request. */ +export const DELETION_KIND = 5; + +// ── Rank tier metadata ──────────────────────────────────────────────────────── + +export interface RankTier { + /** Numeric rank index (0 = founder/moderator, 1+ = badge-based). */ + rank: number; + /** Badge `a` tag coordinate (e.g. `30009::`). Undefined for rank 0. */ + badgeATag?: string; + /** Optional relay hint from the community definition's `a` tag. */ + relayHint?: string; +} + +// ── Parsed community ────────────────────────────────────────────────────────── + +export interface ParsedCommunity { + /** The `d` tag value (community identifier). */ + dTag: string; + /** Human-readable name. */ + name: string; + /** Description text. */ + description: string; + /** Sanitized image URL. */ + image?: string; + /** Founder pubkey (the event publisher). */ + founderPubkey: string; + /** Moderator pubkeys (from `p` tags with role "moderator"). */ + moderatorPubkeys: string[]; + /** Ordered rank tiers (rank 0 first, then badge-based ranks). */ + ranks: RankTier[]; + /** Recommended relay URLs. */ + relays: string[]; + /** The `a` tag coordinate for the community: `34550::`. */ + aTag: string; +} + +/** + * Parse a kind 34550 community definition event into structured data. + * Returns `null` if the event is invalid or missing required tags. + */ +export function parseCommunityEvent(event: NostrEvent): ParsedCommunity | null { + if (event.kind !== COMMUNITY_DEFINITION_KIND) return null; + + const dTag = event.tags.find(([n]) => n === 'd')?.[1]; + if (!dTag) return null; + + const name = event.tags.find(([n]) => n === 'name')?.[1] || dTag; + const description = event.tags.find(([n]) => n === 'description')?.[1] || ''; + const rawImage = event.tags.find(([n]) => n === 'image')?.[1]; + const image = sanitizeUrl(rawImage); + + // Moderators: p tags with "moderator" role (4th element) + const moderatorPubkeys = event.tags + .filter(([n, , , role]) => n === 'p' && role === 'moderator') + .map(([, pubkey]) => pubkey) + .filter(Boolean); + + // Badge rank tiers: a tags pointing to kind 30009 with rank index in 4th element + const badgeRanks: RankTier[] = []; + for (const tag of event.tags) { + if (tag[0] !== 'a') continue; + const coord = tag[1]; + if (!coord || !coord.startsWith('30009:')) continue; + const rankStr = tag[3]; + const rank = parseInt(rankStr, 10); + if (isNaN(rank) || rank < 1) continue; + badgeRanks.push({ + rank, + badgeATag: coord, + relayHint: tag[2] || undefined, + }); + } + + // Sort badge ranks ascending + badgeRanks.sort((a, b) => a.rank - b.rank); + + // Build full rank list: rank 0 (founder/moderators) + badge ranks + const ranks: RankTier[] = [{ rank: 0 }, ...badgeRanks]; + + // Relay URLs + const relays = event.tags + .filter(([n]) => n === 'relay') + .map(([, url]) => url) + .filter(Boolean); + + return { + dTag, + name, + description, + image, + founderPubkey: event.pubkey, + moderatorPubkeys, + ranks, + relays, + aTag: `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`, + }; +} + +// ── Community member ────────────────────────────────────────────────────────── + +export interface CommunityMember { + /** Member's pubkey. */ + pubkey: string; + /** Their effective rank in this community. */ + rank: number; + /** The badge award event that established membership (undefined for rank 0). */ + awardEvent?: NostrEvent; + /** Pubkey of whoever awarded them (undefined for rank 0). */ + awardedBy?: string; +} + +export interface CommunityMembership { + /** All validated members grouped by rank. */ + members: CommunityMember[]; + /** Convenience: count of all members (including founder + moderators). */ + totalCount: number; +} + +/** + * Resolve community membership via the chain validation algorithm + * described in the community NIP. + * + * 1. Seed rank 0 from the community definition (founder + moderators). + * 2. Iteratively validate badge awards — awarder must be a validated + * member with rank strictly less than the awarded badge's rank. + * 3. Apply moderation overlays (kind 1984 bans). + */ +export function resolveMembership( + community: ParsedCommunity, + awardEvents: NostrEvent[], + reportEvents: NostrEvent[], + deletionEvents: NostrEvent[], +): CommunityMembership { + // Build badge-to-rank lookup + const badgeToRank = new Map(); + for (const tier of community.ranks) { + if (tier.badgeATag) { + badgeToRank.set(tier.badgeATag, tier.rank); + } + } + + // Track validated members: pubkey -> CommunityMember + const validated = new Map(); + + // Step 1: Seed rank 0 + validated.set(community.founderPubkey, { + pubkey: community.founderPubkey, + rank: 0, + }); + for (const modPk of community.moderatorPubkeys) { + if (!validated.has(modPk)) { + validated.set(modPk, { pubkey: modPk, rank: 0 }); + } + } + + // Build set of deleted event IDs (kind 5 targeting kind 8) + const deletedIds = new Set(); + for (const del of deletionEvents) { + const kTags = del.tags.filter(([n]) => n === 'k').map(([, v]) => v); + if (!kTags.includes('8')) continue; + for (const tag of del.tags) { + if (tag[0] === 'e') deletedIds.add(tag[1]); + } + } + + // Filter out deleted awards + const activeAwards = awardEvents.filter((e) => !deletedIds.has(e.id)); + + // Step 2: Iterative validation + let changed = true; + const processed = new Set(); + + while (changed) { + changed = false; + for (const award of activeAwards) { + if (processed.has(award.id)) continue; + + const awarderPubkey = award.pubkey; + const awarder = validated.get(awarderPubkey); + if (!awarder) continue; // Awarder not validated yet + + // Find which badge is being awarded + const badgeATag = award.tags.find( + ([n, v]) => n === 'a' && v?.startsWith('30009:'), + )?.[1]; + if (!badgeATag) continue; + + const awardedRank = badgeToRank.get(badgeATag); + if (awardedRank === undefined) continue; // Badge not in this community + + // Awarder must have strictly lower rank number + if (awarder.rank >= awardedRank) continue; + + // Find recipient(s) + const recipients = award.tags + .filter(([n]) => n === 'p') + .map(([, pk]) => pk) + .filter(Boolean); + + for (const recipientPk of recipients) { + const existing = validated.get(recipientPk); + // Only accept if it gives a better (lower) rank or first membership + if (!existing || awardedRank < existing.rank) { + validated.set(recipientPk, { + pubkey: recipientPk, + rank: awardedRank, + awardEvent: award, + awardedBy: awarderPubkey, + }); + changed = true; + } + } + + processed.add(award.id); + } + } + + // Step 3: Apply moderation (bans) + // Build set of deleted report IDs + const deletedReportIds = new Set(); + for (const del of deletionEvents) { + const kTags = del.tags.filter(([n]) => n === 'k').map(([, v]) => v); + if (!kTags.includes('1984')) continue; + for (const tag of del.tags) { + if (tag[0] === 'e') deletedReportIds.add(tag[1]); + } + } + + const communityATag = community.aTag; + + for (const report of reportEvents) { + if (deletedReportIds.has(report.id)) continue; + + // Must be scoped to this community + const scopedToThis = report.tags.some( + ([n, v]) => n === 'A' && v === communityATag, + ); + if (!scopedToThis) continue; + + const reporter = validated.get(report.pubkey); + if (!reporter) continue; // Non-member reports are ignored + + // Ban: p tag only (no e tag) + const hasPTag = report.tags.some(([n]) => n === 'p'); + const hasETag = report.tags.some(([n]) => n === 'e'); + + if (hasPTag && !hasETag) { + const targetPubkey = report.tags.find(([n]) => n === 'p')?.[1]; + if (!targetPubkey) continue; + const target = validated.get(targetPubkey); + if (!target) continue; + // Reporter must outrank target + if (reporter.rank < target.rank) { + validated.delete(targetPubkey); + } + } + } + + const members = Array.from(validated.values()); + members.sort((a, b) => a.rank - b.rank); + + return { + members, + totalCount: members.length, + }; +} + +/** + * Build the `a` tag coordinate string for a community event. + */ +export function getCommunityATag(event: NostrEvent): string { + const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? ''; + return `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`; +} diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index dfab5977..27501e75 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -351,6 +351,18 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ blurb: 'Curated lists of people to follow. Browse or create your own.', sites: [{ url: 'https://following.space', name: 'following.space' }, { url: 'https://following.party', name: 'following.party' }], }, + { + kind: 34550, + id: 'communities', + showKey: 'showCommunities', + feedKey: 'feedIncludeCommunities', + label: 'Communities', + description: 'Hierarchical communities with ranked membership (NIP-72)', + route: 'communities', + addressable: true, + section: 'social', + blurb: 'Hierarchical communities on Nostr with ranked membership, badge-based authority chains, and moderation. Founded and managed by community creators.', + }, { kind: 62, id: 'vanish', diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 1d2af396..5334ca1a 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -181,6 +181,8 @@ export const FeedSettingsSchema = z.looseObject({ showDevelopment: z.boolean().optional(), feedIncludeDevelopment: z.boolean().optional(), feedIncludeBlobbi: z.boolean().optional(), + showCommunities: z.boolean().optional(), + feedIncludeCommunities: z.boolean().optional(), }); /** Schema for a NIP-01 filter object (lenient — allows variable placeholder strings). */ diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index 528e64d2..bb8871fa 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -30,6 +30,7 @@ import { Smile, SmilePlus, User, + Users, Vote, WalletMinimal, Zap, @@ -151,6 +152,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [ { id: "articles", label: "Articles", path: "/articles", icon: Newspaper }, { id: "polls", label: "Polls", path: "/polls", icon: Vote }, { id: "badges", label: "Badges", path: "/badges", icon: Award }, + { id: "communities", label: "Communities", path: "/communities", icon: Users }, { id: "world", label: "World", path: "/world", icon: Earth }, ]; @@ -188,6 +190,7 @@ export const CONTENT_KIND_ICONS: Record = { emojis: SmilePlus, development: Code, badges: HelpCircle, + communities: Users, world: Earth, archive: HelpCircle, bluesky: HelpCircle, diff --git a/src/pages/CommunitiesPage.tsx b/src/pages/CommunitiesPage.tsx new file mode 100644 index 00000000..a7578a45 --- /dev/null +++ b/src/pages/CommunitiesPage.tsx @@ -0,0 +1,252 @@ +import type { NostrEvent } from '@nostrify/nostrify'; +import { useQueryClient } from '@tanstack/react-query'; +import { useSeoMeta } from '@unhead/react'; +import { Loader2, Users } from 'lucide-react'; + +import { CommunityCard } from '@/components/CommunityCard'; +import { FeedEmptyState } from '@/components/FeedEmptyState'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { NoteCard } from '@/components/NoteCard'; +import { PageHeader } from '@/components/PageHeader'; +import { PullToRefresh } from '@/components/PullToRefresh'; +import { SubHeaderBar } from '@/components/SubHeaderBar'; +import { TabButton } from '@/components/TabButton'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useLayoutOptions } from '@/contexts/LayoutContext'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useCommunityFeed } from '@/hooks/useCommunityFeed'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useFeedTab } from '@/hooks/useFeedTab'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; +import { useMyCommunities } from '@/hooks/useMyCommunities'; +import { deduplicateEvents } from '@/lib/deduplicateEvents'; + +// ─── Types ───────────────────────────────────────────────────────────────────── + +type CommunitiesTab = 'mine' | 'follows'; + +// ─── Skeletons ───────────────────────────────────────────────────────────────── + +function CommunityCardSkeleton() { + return ( +
+ +
+ + + +
+ + +
+
+
+ ); +} + +function NoteCardSkeleton() { + return ( +
+
+ +
+ + +
+
+
+ + +
+
+ + + + +
+
+ ); +} + +// ─── Page ────────────────────────────────────────────────────────────────────── + +export function CommunitiesPage() { + const { config } = useAppContext(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + + useLayoutOptions({ + hasSubHeader: !!user, + }); + + const [activeTab, setActiveTab] = useFeedTab('communities', [ + 'mine', + 'follows', + ]); + + useSeoMeta({ + title: `Communities | ${config.appName}`, + description: 'Discover and join hierarchical communities on Nostr', + }); + + return ( +
+ } /> + + {/* Follows / My Communities tabs */} + {user && ( + + setActiveTab('follows')} + /> + setActiveTab('mine')} + /> + + )} + + {/* Arc overhang spacer */} + {user &&
} + + {/* Tab content */} + {activeTab === 'mine' ? ( + + ) : ( + + queryClient.invalidateQueries({ + queryKey: ['community-feed', 'follows'], + }) + } + /> + )} +
+ ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// My Communities Tab +// ═══════════════════════════════════════════════════════════════════════════════ + +function MyCommunitiesTab() { + const { user } = useCurrentUser(); + + if (!user) { + return ( +
+
+ +
+
+

Your communities

+

+ Log in to see communities you've founded or joined. +

+
+ +
+ ); + } + + return ; +} + +function MyCommunitiesContent() { + const { data: myCommunities, isLoading } = useMyCommunities(); + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + + if (!myCommunities || myCommunities.length === 0) { + return ( + + ); + } + + return ( +
+ {myCommunities.map((entry) => ( + + ))} +
+ ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Follows Feed Tab +// ═══════════════════════════════════════════════════════════════════════════════ + +function FollowsFeedTab({ onRefresh }: { onRefresh: () => Promise }) { + const { user } = useCurrentUser(); + const feedQuery = useCommunityFeed(); + + const { + data: rawData, + isPending, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = feedQuery; + + const { scrollRef } = useInfiniteScroll({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, + pageCount: rawData?.pages?.length, + }); + + const feedEvents = deduplicateEvents(rawData?.pages as NostrEvent[][]); + + const showSkeleton = isPending || (isLoading && !rawData); + + return ( + + {showSkeleton ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : feedEvents.length > 0 ? ( +
+ {feedEvents.map((event) => ( + + ))} + {hasNextPage && ( +
+ {isFetchingNextPage && ( +
+ +
+ )} +
+ )} +
+ ) : ( + + )} +
+ ); +} diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index 4b5e059a..5df3ab0b 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -83,6 +83,8 @@ export function TestApp({ children }: TestAppProps) { feedIncludePodcastTrailers: false, showDevelopment: false, feedIncludeDevelopment: false, + showCommunities: false, + feedIncludeCommunities: false, showBadges: false, showBadgeDefinitions: true, showProfileBadges: true, From 52dae96a618bf0d35beab275db28119d356306b7 Mon Sep 17 00:00:00 2001 From: lemon Date: Sat, 18 Apr 2026 23:32:48 -0700 Subject: [PATCH 02/13] add dedicated community detail page with members, events, and comments tabs --- src/components/CommunityDetailPage.tsx | 466 +++++++++++++++++++++++++ src/pages/PostDetailPage.tsx | 7 + 2 files changed, 473 insertions(+) create mode 100644 src/components/CommunityDetailPage.tsx diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx new file mode 100644 index 00000000..e249196a --- /dev/null +++ b/src/components/CommunityDetailPage.tsx @@ -0,0 +1,466 @@ +import { useMemo, useCallback, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { + ArrowLeft, + CalendarDays, + Crown, + MessageCircle, + Shield, + Share2, + Users, +} from 'lucide-react'; +import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; + +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { getAvatarShape } from '@/lib/avatarShape'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { NoteCard } from '@/components/NoteCard'; +import { ReplyComposeModal } from '@/components/ReplyComposeModal'; +import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useAuthors } from '@/hooks/useAuthors'; +import { useComments } from '@/hooks/useComments'; +import { useCommunityMembers } from '@/hooks/useCommunityMembers'; +import { useProfileUrl } from '@/hooks/useProfileUrl'; +import { useToast } from '@/hooks/useToast'; +import { parseCommunityEvent, type CommunityMember } from '@/lib/communityUtils'; +import { genUserName } from '@/lib/genUserName'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { cn } from '@/lib/utils'; + +// ── Calendar event kinds (NIP-52) ───────────────────────────────────────────── +const CALENDAR_EVENT_KINDS = [31922, 31923]; + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: string; size?: 'sm' | 'md' }) { + const { data } = useAuthor(pubkey); + const metadata: NostrMetadata | undefined = data?.metadata; + const avatarShape = getAvatarShape(metadata); + const name = metadata?.display_name || metadata?.name || genUserName(pubkey); + const profileUrl = useProfileUrl(pubkey, metadata); + const avatarCls = size === 'sm' ? 'size-8' : 'size-10'; + const fallbackCls = size === 'sm' ? 'text-xs' : ''; + + return ( + + + + + {name.charAt(0).toUpperCase()} + + +
+

{name}

+ {metadata?.nip05 && ( +

{metadata.nip05}

+ )} +
+ {label && ( + {label} + )} + + ); +} + +function MembersSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ); +} + +function EventsSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + +
+
+ + +
+ ))} +
+ ); +} + +function ReplyCardSkeleton() { + return ( +
+
+ +
+ + + +
+
+
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function CommunityDetailPage({ event }: { event: NostrEvent }) { + const navigate = useNavigate(); + const { nostr } = useNostr(); + const { toast } = useToast(); + const [replyOpen, setReplyOpen] = useState(false); + + // Parse community definition + const community = useMemo(() => parseCommunityEvent(event), [event]); + const name = community?.name ?? 'Unnamed Community'; + const description = community?.description ?? ''; + const image = community?.image; + const communityATag = community?.aTag ?? ''; + + // Extract website URL from description + const descriptionUrl = useMemo(() => { + const urlMatch = description.match(/https?:\/\/[^\s]+/); + return sanitizeUrl(urlMatch?.[0]); + }, [description]); + + const descriptionText = useMemo(() => { + if (!descriptionUrl) return description; + return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim(); + }, [description, descriptionUrl]); + + // ── Members ───────────────────────────────────────────────────────────────── + const { data: membership, isLoading: membersLoading } = useCommunityMembers(community); + + // Batch-fetch profiles for all members + const allMemberPubkeys = useMemo( + () => membership?.members.map((m) => m.pubkey) ?? [], + [membership], + ); + useAuthors(allMemberPubkeys); + + // Group members by rank + const membersByRank = useMemo(() => { + if (!membership || !community) return []; + const groups = new Map(); + for (const m of membership.members) { + const list = groups.get(m.rank) ?? []; + list.push(m); + groups.set(m.rank, list); + } + // Build ordered groups with labels + const result: { rank: number; label: string; members: CommunityMember[] }[] = []; + const sortedRanks = Array.from(groups.keys()).sort((a, b) => a - b); + for (const rank of sortedRanks) { + const members = groups.get(rank)!; + let label: string; + if (rank === 0) { + label = 'Leadership'; + } else { + // Find the badge a-tag for this rank from community definition + const tier = community.ranks.find((r) => r.rank === rank); + // Use the badge d-tag suffix as a label hint, or fall back to "Rank N" + if (tier?.badgeATag) { + const parts = tier.badgeATag.split(':'); + const dTag = parts.slice(2).join(':'); + // Try to extract a human-readable name from the d-tag (after the UUID prefix) + const namePart = dTag.split('-').pop(); + label = namePart ? namePart.charAt(0).toUpperCase() + namePart.slice(1) : `Rank ${rank}`; + } else { + label = `Rank ${rank}`; + } + } + result.push({ rank, label, members }); + } + return result; + }, [membership, community]); + + // ── Events (calendar events tagging this community) ───────────────────────── + const { data: communityEvents, isLoading: eventsLoading } = useQuery({ + queryKey: ['community-events', communityATag], + queryFn: async ({ signal }) => { + if (!communityATag) return []; + const events = await nostr.query( + [{ kinds: CALENDAR_EVENT_KINDS, '#a': [communityATag], limit: 100 }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(8_000)]) }, + ); + // Sort by start date descending + return events.sort((a, b) => { + const aStart = parseInt(a.tags.find(([n]) => n === 'start')?.[1] ?? '0', 10); + const bStart = parseInt(b.tags.find(([n]) => n === 'start')?.[1] ?? '0', 10); + return bStart - aStart; + }); + }, + enabled: !!communityATag, + staleTime: 2 * 60_000, + }); + + // ── Comments (NIP-22 on the community event) ─────────────────────────────── + const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500); + + const replyTree = useMemo((): ReplyNode[] => { + if (!commentsData) return []; + const topLevel = commentsData.topLevelComments ?? []; + + const buildNode = (ev: NostrEvent): ReplyNode => { + const allChildren = commentsData.getDirectReplies(ev.id) ?? []; + if (allChildren.length <= 1) { + return { + event: ev, + children: allChildren.map((c) => buildNode(c)), + }; + } + const [first, ...rest] = allChildren; + return { + event: ev, + children: [buildNode(first)], + hiddenChildren: rest.map((c) => buildNode(c)), + }; + }; + + return [...topLevel].sort((a, b) => a.created_at - b.created_at).map((r) => buildNode(r)); + }, [commentsData]); + + // ── Share handler ─────────────────────────────────────────────────────────── + const handleShare = useCallback(async () => { + const d = event.tags.find(([n]) => n === 'd')?.[1] ?? ''; + const naddr = nip19.naddrEncode({ + kind: event.kind, + pubkey: event.pubkey, + identifier: d, + }); + const url = `${window.location.origin}/${naddr}`; + try { + await navigator.clipboard.writeText(url); + toast({ title: 'Link copied to clipboard' }); + } catch { + toast({ title: 'Failed to copy link', variant: 'destructive' }); + } + }, [event, toast]); + + // ── Render ────────────────────────────────────────────────────────────────── + return ( +
+ {/* ── Top bar ── */} +
+ +

Community

+ +
+ + {/* ── Hero image ── */} + {image ? ( +
+ {name} +
+
+

{name}

+
+
+ ) : ( +
+ +
+

{name}

+
+
+ )} + + {/* ── Community info ── */} +
+ {/* Stats row */} +
+ + + {membership?.totalCount ?? '...'} member{membership?.totalCount !== 1 ? 's' : ''} + + {(communityEvents?.length ?? 0) > 0 && ( + + + {communityEvents!.length} event{communityEvents!.length !== 1 ? 's' : ''} + + )} + {(commentsData?.allComments.length ?? 0) > 0 && ( + + + {commentsData!.allComments.length} comment{commentsData!.allComments.length !== 1 ? 's' : ''} + + )} +
+ + {/* Description */} + {descriptionText && ( +

{descriptionText}

+ )} + + {/* Founder */} +
+

Founded by

+ +
+ + + + {/* ── Tabs ── */} + + + + + Members + + + + Events + + + + Comments + + + + {/* ── Members tab ── */} + + {membersLoading ? ( + + ) : membersByRank.length === 0 ? ( +
+ No members found. +
+ ) : ( +
+ {membersByRank.map(({ rank, label, members }) => ( +
+

+ {rank === 0 ? : } + {label} + ({members.length}) +

+
+ {members.map((m) => { + let roleLabel: string | undefined; + if (rank === 0) { + roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator'; + } + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ + {/* ── Events tab ── */} + + {eventsLoading ? ( + + ) : !communityEvents || communityEvents.length === 0 ? ( +
+ + +
+ +

+ No events yet. When calendar events are tagged with this community, they'll appear here. +

+
+
+
+
+ ) : ( +
+ {communityEvents.map((ev) => ( + + ))} +
+ )} +
+ + {/* ── Comments tab ── */} + + {/* Compose button */} +
+ +
+ + {commentsLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : replyTree.length > 0 ? ( + + ) : ( +
+ No comments yet. Be the first to comment! +
+ )} + + +
+
+
+
+ ); +} diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 787f854d..6d5cd532 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -25,6 +25,7 @@ import { Link, useNavigate } from "react-router-dom"; const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent }))); import { BadgeDetailContent } from "@/components/BadgeDetailContent"; import { CalendarEventDetailPage } from "@/components/CalendarEventDetailPage"; +import { CommunityDetailPage } from "@/components/CommunityDetailPage"; import { ColorMomentContent, @@ -125,6 +126,7 @@ function shellTitleForKind(kind?: number): string { if (MUSIC_KINDS.has(kind)) return "Track Details"; if (PODCAST_KINDS.has(kind)) return "Episode Details"; if (CALENDAR_EVENT_KINDS.has(kind)) return "Event Details"; + if (kind === 34550) return "Community"; if (FOLLOW_PACK_KINDS.has(kind)) return "Follow Pack"; if (kind === LIVE_STREAM_KIND) return "Live Stream"; if (kind === 30617) return "Repository"; @@ -377,6 +379,11 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) { ); } + // Communities (NIP-72) get a dedicated detail page with members, events, and comments + if (resolvedEvent.kind === 34550) { + return ; + } + // Calendar events (NIP-52) get a dedicated detail page with RSVP if (CALENDAR_EVENT_KINDS.has(resolvedEvent.kind)) { return ; From 6929097466864622ce6636843476ab361bdeb135 Mon Sep 17 00:00:00 2001 From: lemon Date: Sat, 18 Apr 2026 23:38:48 -0700 Subject: [PATCH 03/13] replace comment button with ComposeBox in community detail page --- package-lock.json | 25 ------------------------- src/components/CommunityDetailPage.tsx | 25 +++---------------------- 2 files changed, 3 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0599d41..1465066b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5822,7 +5822,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5836,7 +5835,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5850,7 +5848,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5864,7 +5861,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5878,7 +5874,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5892,7 +5887,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5906,7 +5900,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5920,7 +5913,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5934,7 +5926,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5948,7 +5939,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5962,7 +5952,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5976,7 +5965,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5990,7 +5978,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6004,7 +5991,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6018,7 +6004,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6032,7 +6017,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6046,7 +6030,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6060,7 +6043,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6074,7 +6056,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6088,7 +6069,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6102,7 +6082,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6116,7 +6095,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6130,7 +6108,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6144,7 +6121,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6158,7 +6134,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index e249196a..6b9000a6 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useCallback, useState } from 'react'; +import { useMemo, useCallback } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { @@ -17,13 +17,12 @@ import { useQuery } from '@tanstack/react-query'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { ComposeBox } from '@/components/ComposeBox'; import { NoteCard } from '@/components/NoteCard'; -import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList'; import { useAuthor } from '@/hooks/useAuthor'; import { useAuthors } from '@/hooks/useAuthors'; @@ -129,7 +128,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { const navigate = useNavigate(); const { nostr } = useNostr(); const { toast } = useToast(); - const [replyOpen, setReplyOpen] = useState(false); // Parse community definition const community = useMemo(() => parseCommunityEvent(event), [event]); @@ -426,18 +424,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { {/* ── Comments tab ── */} - {/* Compose button */} -
- -
+ {commentsLoading ? (
@@ -452,12 +439,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { No comments yet. Be the first to comment!
)} - -
From 88d2fdd9044e143d0beb78dc4bc5a451ec4076ed Mon Sep 17 00:00:00 2001 From: lemon Date: Sat, 18 Apr 2026 23:45:11 -0700 Subject: [PATCH 04/13] remove stats badges from community detail page header --- src/components/CommunityDetailPage.tsx | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index 6b9000a6..afa2d340 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -298,26 +298,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { {/* ── Community info ── */}
- {/* Stats row */} -
- - - {membership?.totalCount ?? '...'} member{membership?.totalCount !== 1 ? 's' : ''} - - {(communityEvents?.length ?? 0) > 0 && ( - - - {communityEvents!.length} event{communityEvents!.length !== 1 ? 's' : ''} - - )} - {(commentsData?.allComments.length ?? 0) > 0 && ( - - - {commentsData!.allComments.length} comment{commentsData!.allComments.length !== 1 ? 's' : ''} - - )} -
- {/* Description */} {descriptionText && (

{descriptionText}

From e2d3a164a600ecb8236c4267117c32c79f6b5797 Mon Sep 17 00:00:00 2001 From: lemon Date: Sat, 18 Apr 2026 23:48:00 -0700 Subject: [PATCH 05/13] remove separator line between founder and tabs --- src/components/CommunityDetailPage.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index afa2d340..a59915ae 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -18,7 +18,6 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; -import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { ComposeBox } from '@/components/ComposeBox'; @@ -309,8 +308,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
- - {/* ── Tabs ── */} From c17be3d19187d9cbc972a638eabaec0c8a4faca1 Mon Sep 17 00:00:00 2001 From: lemon Date: Sat, 18 Apr 2026 23:52:38 -0700 Subject: [PATCH 06/13] simplify empty events state: remove icon, border, and card background --- src/components/CommunityDetailPage.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index a59915ae..5cb5b812 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -17,7 +17,6 @@ import { useQuery } from '@tanstack/react-query'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Badge } from '@/components/ui/badge'; -import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { ComposeBox } from '@/components/ComposeBox'; @@ -378,17 +377,10 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { {eventsLoading ? ( ) : !communityEvents || communityEvents.length === 0 ? ( -
- - -
- -

- No events yet. When calendar events are tagged with this community, they'll appear here. -

-
-
-
+
+

+ No events yet. When calendar events are tagged with this community, they'll appear here. +

) : (
From b7a128ad2897b375374cd7bb4802b9c33acaf9ac Mon Sep 17 00:00:00 2001 From: lemon Date: Sat, 18 Apr 2026 23:54:36 -0700 Subject: [PATCH 07/13] shorten empty events message to 'No events yet' --- src/components/CommunityDetailPage.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index 5cb5b812..5bcbf12c 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -377,10 +377,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { {eventsLoading ? ( ) : !communityEvents || communityEvents.length === 0 ? ( -
-

- No events yet. When calendar events are tagged with this community, they'll appear here. -

+
+

No events yet

) : (
From 556af013db7670aed021cbabe476d5b42d6f16dc Mon Sep 17 00:00:00 2001 From: lemon Date: Sun, 19 Apr 2026 17:06:55 -0700 Subject: [PATCH 08/13] add Communities tab to search page with global kind 34550 feed --- src/pages/SearchPage.tsx | 104 +++++++++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 10 deletions(-) diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 9c828898..3d6b6155 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -57,9 +57,9 @@ import { PageHeader } from '@/components/PageHeader'; import { isRepostKind, parseRepostContent } from '@/lib/feedUtils'; import { nip19 } from 'nostr-tools'; -type TabType = 'posts' | 'accounts'; +type TabType = 'communities' | 'posts' | 'accounts'; -const VALID_TABS: TabType[] = ['posts', 'accounts']; +const VALID_TABS: TabType[] = ['communities', 'posts', 'accounts']; function parseTab(value: string | null): TabType { return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'posts'; @@ -422,6 +422,7 @@ export function SearchPage() {
} /> + setActiveTab('communities')} /> setActiveTab('posts')} /> setActiveTab('accounts')} /> @@ -435,8 +436,8 @@ export function SearchPage() { onDebouncedChange={setDebouncedSearchQuery} /> - {/* Add to feed button (posts tab only) */} - {activeTab === 'posts' && user && ( + {/* Add to feed button (posts & communities tabs) */} + {(activeTab === 'posts' || activeTab === 'communities') && user && (
{ setSavePopoverOpen(o); @@ -508,8 +509,8 @@ export function SearchPage() {
)} - {/* Filter popover (posts tab only) */} - {activeTab === 'posts' && ( + {/* Filter popover (posts & communities tabs) */} + {(activeTab === 'posts' || activeTab === 'communities') && (
+ {/* ─── Communities Tab ─── */} + {activeTab === 'communities' && ( + + )} + {/* ─── Posts Tab ─── */} {activeTab === 'posts' && ( <> @@ -1093,6 +1110,73 @@ function SearchInput({ ); } +/** Communities tab — isolated component so useStreamPosts only subscribes when active. */ +function CommunitiesSearchTab({ + searchQuery, + includeReplies, + mediaType, + language, + protocols, + authorPubkeys, + sort, + activeFilterLabels, + hasActiveFilters, + resetFilters, +}: { + searchQuery: string; + includeReplies: boolean; + mediaType: 'all' | 'images' | 'videos' | 'vines' | 'none'; + language: string; + protocols: string[]; + authorPubkeys: string[] | undefined; + sort: 'recent' | 'hot' | 'trending'; + activeFilterLabels: string[]; + hasActiveFilters: boolean; + resetFilters: () => void; +}) { + const { posts, isLoading: postsLoading } = useStreamPosts(searchQuery, { + includeReplies, + mediaType, + language, + protocols, + kindsOverride: [34550], + authorPubkeys, + sort, + }); + + if (postsLoading && posts.length === 0) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); + } + + if (posts.length > 0) { + return ( +
+ {posts.map((event) => ( + + ))} +
+ ); + } + + if (searchQuery.trim()) { + return ( + + ); + } + + return ; +} + function SaveDestinationRow({ icon, label, description, onClick, disabled, loading, }: { From 70f74c6f9d9dee10a230f63fcd6264b2a1c47499 Mon Sep 17 00:00:00 2001 From: lemon Date: Sun, 19 Apr 2026 17:15:22 -0700 Subject: [PATCH 09/13] simplify community NoteCard: remove moderators list, separator, and stats badges --- src/components/CommunityContent.tsx | 68 ++--------------------------- 1 file changed, 4 insertions(+), 64 deletions(-) diff --git a/src/components/CommunityContent.tsx b/src/components/CommunityContent.tsx index ebb82460..b2b92709 100644 --- a/src/components/CommunityContent.tsx +++ b/src/components/CommunityContent.tsx @@ -2,15 +2,12 @@ import { useMemo, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { Users, Share2, Globe } from 'lucide-react'; import { nip19 } from 'nostr-tools'; -import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; +import type { NostrEvent } from '@nostrify/nostrify'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Separator } from '@/components/ui/separator'; import { useAuthor } from '@/hooks/useAuthor'; -import { useAuthors } from '@/hooks/useAuthors'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; @@ -43,46 +40,15 @@ function parseCommunityEvent(event: NostrEvent) { return { name, description, image, moderators, relays }; } -// --- Sub-components --- - -function ModeratorRow({ pubkey }: { pubkey: string }) { - const { data } = useAuthor(pubkey); - const metadata: NostrMetadata | undefined = data?.metadata; - const avatarShape = getAvatarShape(metadata); - const name = metadata?.display_name || metadata?.name || genUserName(pubkey); - const profileUrl = useProfileUrl(pubkey, metadata); - - return ( - - - - - {name.charAt(0).toUpperCase()} - - -
-

{name}

- {metadata?.nip05 && ( -

{metadata.nip05}

- )} -
- Moderator - - ); -} - // --- Main Component --- export function CommunityContent({ event }: { event: NostrEvent }) { const { toast } = useToast(); - const { name, description, image, moderators, relays } = useMemo( + const { name, description, image } = useMemo( () => parseCommunityEvent(event), [event], ); - // Batch-fetch moderator profiles - useAuthors(moderators); - // Owner const ownerAuthor = useAuthor(event.pubkey); const ownerMetadata = ownerAuthor.data?.metadata; @@ -144,18 +110,8 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
)} - {/* Quick stats + share */} -
- - - {moderators.length} moderator{moderators.length !== 1 ? 's' : ''} - - {relays.length > 0 && ( - - - {relays.length} relay{relays.length !== 1 ? 's' : ''} - - )} + {/* Share button */} +
@@ -198,22 +154,6 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
- - - {/* Moderators */} - {moderators.length > 0 && ( -
-

- - Moderators -

-
- {moderators.map((pk) => ( - - ))} -
-
- )}
); } From da1d872dd7ec5384beb771489e3f0a2d90467483 Mon Sep 17 00:00:00 2001 From: lemon Date: Sun, 19 Apr 2026 17:19:09 -0700 Subject: [PATCH 10/13] hide media, protocol, language, kind, and replies filters on communities search tab --- src/pages/SearchPage.tsx | 150 ++++++++++++++++++++------------------- 1 file changed, 78 insertions(+), 72 deletions(-) diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 3d6b6155..7603f4d4 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -630,82 +630,88 @@ export function SearchPage() { ))}
- - {/* Media + Protocol */} -
-
- Media - -
-
- Protocol - -
-
+ {/* Posts-only filters (hidden on communities tab) */} + {activeTab === 'posts' && ( + <> + - {/* Language + Kind */} -
-
- Language - -
-
- Kind - setKindFilter(v)} /> -
-
+ {/* Media + Protocol */} +
+
+ Media + +
+
+ Protocol + +
+
- {kindFilter === 'custom' && ( - setCustomKindText(e.target.value)} - className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8" - /> + {/* Language + Kind */} +
+
+ Language + +
+
+ Kind + setKindFilter(v)} /> +
+
+ + {kindFilter === 'custom' && ( + setCustomKindText(e.target.value)} + className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8" + /> + )} + + {/* Include replies toggle */} + +
+ Include replies + +
+ )} - - {/* Include replies toggle */} - -
- Include replies - -
)} From 8c83758461ac113b7904f81a5b8b9b5da21509ab Mon Sep 17 00:00:00 2001 From: lemon Date: Sun, 19 Apr 2026 17:24:25 -0700 Subject: [PATCH 11/13] replace Follows tab with Activities tab showing community events and comments --- src/hooks/useCommunityActivityFeed.ts | 72 +++++++++++++++++++++ src/hooks/useCommunityFeed.ts | 91 --------------------------- src/pages/CommunitiesPage.tsx | 88 ++++++++++---------------- 3 files changed, 106 insertions(+), 145 deletions(-) create mode 100644 src/hooks/useCommunityActivityFeed.ts delete mode 100644 src/hooks/useCommunityFeed.ts diff --git a/src/hooks/useCommunityActivityFeed.ts b/src/hooks/useCommunityActivityFeed.ts new file mode 100644 index 00000000..58f308c7 --- /dev/null +++ b/src/hooks/useCommunityActivityFeed.ts @@ -0,0 +1,72 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { useMyCommunities } from './useMyCommunities'; +import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils'; + +/** + * Fetches a chronological activity feed for communities the current user + * belongs to (founded or joined). + * + * The feed merges: + * 1. Kind 34550 community definition events for the user's communities + * 2. Kind 1111 NIP-22 comments scoped to those communities (via #A tag) + * + * Sorted by created_at descending. + */ +export function useCommunityActivityFeed() { + const { nostr } = useNostr(); + const { data: myCommunities, isLoading: communitiesLoading } = useMyCommunities(); + + const aTags = myCommunities?.map((c) => c.community.aTag).filter(Boolean) ?? []; + const aTagsKey = aTags.join(','); + + return useQuery({ + queryKey: ['community-activity-feed', aTagsKey], + queryFn: async ({ signal }) => { + if (aTags.length === 0) return []; + + const timeout = AbortSignal.timeout(8_000); + const combinedSignal = AbortSignal.any([signal, timeout]); + + // Fetch community definition events and scoped comments in parallel + const [definitionEvents, comments] = await Promise.all([ + // The community definitions themselves + nostr.query( + [{ + kinds: [COMMUNITY_DEFINITION_KIND], + authors: myCommunities!.map((c) => c.event.pubkey), + '#d': myCommunities!.map((c) => c.community.dTag), + limit: 50, + }], + { signal: combinedSignal }, + ), + // Kind 1111 comments scoped to these communities via uppercase A tag + nostr.query( + [{ + kinds: [1111], + '#A': aTags, + limit: 100, + }], + { signal: combinedSignal }, + ), + ]); + + // Merge and deduplicate + const seen = new Set(); + const merged: NostrEvent[] = []; + + for (const event of [...definitionEvents, ...comments]) { + if (seen.has(event.id)) continue; + seen.add(event.id); + merged.push(event); + } + + // Sort by created_at descending + return merged.sort((a, b) => b.created_at - a.created_at); + }, + enabled: !communitiesLoading && aTags.length > 0, + staleTime: 2 * 60_000, + }); +} diff --git a/src/hooks/useCommunityFeed.ts b/src/hooks/useCommunityFeed.ts deleted file mode 100644 index 171ccfdb..00000000 --- a/src/hooks/useCommunityFeed.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useNostr } from '@nostrify/react'; -import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import type { NostrEvent } from '@nostrify/nostrify'; - -import { useCurrentUser } from './useCurrentUser'; -import { useFollowList } from './useFollowActions'; -import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils'; -import { TEAM_SOAPBOX_PACK } from '@/lib/helpContent'; - -const PAGE_SIZE = 20; - -/** - * Infinite-scroll feed of community definition events (kind 34550) - * from users the current user follows. - * - * When logged out, uses the Team Soapbox follow pack as the author list - * (same pattern as useBadgeFeed). - */ -export function useCommunityFeed() { - const { nostr } = useNostr(); - const { user } = useCurrentUser(); - const { data: followData } = useFollowList(); - const followList = followData?.pubkeys; - - // When logged out, fetch the Team Soapbox follow pack for default authors - const { data: packPubkeys } = useQuery({ - queryKey: ['team-soapbox-pack-pubkeys'], - queryFn: async ({ signal }) => { - const events = await nostr.query( - [{ - kinds: [TEAM_SOAPBOX_PACK.kind], - authors: [TEAM_SOAPBOX_PACK.pubkey], - '#d': [TEAM_SOAPBOX_PACK.identifier], - limit: 1, - }], - { signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) }, - ); - if (events.length === 0) return []; - return events[0].tags.filter(([n]) => n === 'p').map(([, pk]) => pk); - }, - enabled: !user, - staleTime: 10 * 60_000, - }); - - const followsReady = user ? followList !== undefined : packPubkeys !== undefined; - - const authorsList = user ? followList : packPubkeys; - const authorsKey = authorsList ? [...authorsList].sort().join(',') : ''; - - return useInfiniteQuery({ - queryKey: ['community-feed', 'follows', user?.pubkey ?? '', authorsKey], - queryFn: async ({ pageParam, signal }) => { - const baseUntil = pageParam as number | undefined; - - let authors: string[] | undefined; - if (user && followList) { - authors = followList.length > 0 ? [...followList, user.pubkey] : [user.pubkey]; - } else if (!user && packPubkeys && packPubkeys.length > 0) { - authors = packPubkeys; - } - - const events = await nostr.query( - [{ - kinds: [COMMUNITY_DEFINITION_KIND], - ...(authors ? { authors } : {}), - ...(baseUntil ? { until: baseUntil } : {}), - limit: PAGE_SIZE, - }], - { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, - ); - - // Deduplicate and sort - const seen = new Set(); - return events - .filter((event) => { - if (seen.has(event.id)) return false; - seen.add(event.id); - return true; - }) - .sort((a, b) => b.created_at - a.created_at) - .slice(0, PAGE_SIZE); - }, - getNextPageParam: (lastPage: NostrEvent[]) => { - if (lastPage.length === 0) return undefined; - return lastPage[lastPage.length - 1].created_at - 1; - }, - initialPageParam: undefined as number | undefined, - enabled: followsReady, - staleTime: 2 * 60_000, - }); -} diff --git a/src/pages/CommunitiesPage.tsx b/src/pages/CommunitiesPage.tsx index a7578a45..84507182 100644 --- a/src/pages/CommunitiesPage.tsx +++ b/src/pages/CommunitiesPage.tsx @@ -1,7 +1,6 @@ -import type { NostrEvent } from '@nostrify/nostrify'; import { useQueryClient } from '@tanstack/react-query'; import { useSeoMeta } from '@unhead/react'; -import { Loader2, Users } from 'lucide-react'; +import { Users } from 'lucide-react'; import { CommunityCard } from '@/components/CommunityCard'; import { FeedEmptyState } from '@/components/FeedEmptyState'; @@ -14,16 +13,14 @@ import { TabButton } from '@/components/TabButton'; import { Skeleton } from '@/components/ui/skeleton'; import { useLayoutOptions } from '@/contexts/LayoutContext'; import { useAppContext } from '@/hooks/useAppContext'; -import { useCommunityFeed } from '@/hooks/useCommunityFeed'; +import { useCommunityActivityFeed } from '@/hooks/useCommunityActivityFeed'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useFeedTab } from '@/hooks/useFeedTab'; -import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; import { useMyCommunities } from '@/hooks/useMyCommunities'; -import { deduplicateEvents } from '@/lib/deduplicateEvents'; // ─── Types ───────────────────────────────────────────────────────────────────── -type CommunitiesTab = 'mine' | 'follows'; +type CommunitiesTab = 'activities' | 'mine'; // ─── Skeletons ───────────────────────────────────────────────────────────────── @@ -80,8 +77,8 @@ export function CommunitiesPage() { }); const [activeTab, setActiveTab] = useFeedTab('communities', [ + 'activities', 'mine', - 'follows', ]); useSeoMeta({ @@ -93,13 +90,13 @@ export function CommunitiesPage() {
} /> - {/* Follows / My Communities tabs */} + {/* Activities / My Communities tabs */} {user && ( setActiveTab('follows')} + label="Activities" + active={activeTab === 'activities'} + onClick={() => setActiveTab('activities')} /> ) : ( - queryClient.invalidateQueries({ - queryKey: ['community-feed', 'follows'], + queryKey: ['community-activity-feed'], + exact: false, }) } /> @@ -188,64 +186,46 @@ function MyCommunitiesContent() { } // ═══════════════════════════════════════════════════════════════════════════════ -// Follows Feed Tab +// Activities Tab // ═══════════════════════════════════════════════════════════════════════════════ -function FollowsFeedTab({ onRefresh }: { onRefresh: () => Promise }) { +function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise }) { const { user } = useCurrentUser(); - const feedQuery = useCommunityFeed(); + const { data: activityEvents, isLoading } = useCommunityActivityFeed(); - const { - data: rawData, - isPending, - isLoading, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = feedQuery; - - const { scrollRef } = useInfiniteScroll({ - hasNextPage, - isFetchingNextPage, - fetchNextPage, - pageCount: rawData?.pages?.length, - }); - - const feedEvents = deduplicateEvents(rawData?.pages as NostrEvent[][]); - - const showSkeleton = isPending || (isLoading && !rawData); + if (!user) { + return ( +
+
+ +
+
+

Community activity

+

+ Log in to see activity from your communities. +

+
+ +
+ ); + } return ( - {showSkeleton ? ( + {isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
- ) : feedEvents.length > 0 ? ( + ) : activityEvents && activityEvents.length > 0 ? (
- {feedEvents.map((event) => ( + {activityEvents.map((event) => ( ))} - {hasNextPage && ( -
- {isFetchingNextPage && ( -
- -
- )} -
- )}
) : ( - + )}
); From e1d4939c811cd0e120fae5303b83f135899ce2c9 Mon Sep 17 00:00:00 2001 From: lemon Date: Sun, 19 Apr 2026 17:55:20 -0700 Subject: [PATCH 12/13] add hierarchical communities protocol spec to NIP.md --- NIP.md | 311 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) diff --git a/NIP.md b/NIP.md index 02b6320f..5843b968 100644 --- a/NIP.md +++ b/NIP.md @@ -19,6 +19,12 @@ | 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards | | 36639 | Activist Action | Country-scoped activist challenge with a sats bounty | +### Agora Protocols + +| Protocol | Composed Kinds | Description | +|--------------------------|-----------------------------------------|-----------------------------------------------------------------| +| Hierarchical Communities | 34550, 30009, 8, 1111, 1984, 5 | Ranked community membership via badge award chains (NIP-72 ext) | + ### Community Kinds These event kinds were created by community contributors and are supported by Ditto. Full specifications are maintained by their respective authors. @@ -559,6 +565,311 @@ Clients SHOULD only surface events from the last hour (`since = now - 3600`). Ol --- +## Hierarchical Communities + +Hierarchical communities on Nostr, composed from existing event kinds. Communities have ranked membership where authority flows downward through a chain of badge awards. + +**No new event kinds are introduced.** The system composes: + +- **Kind 34550** ([NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)) -- Community Definition +- **Kind 30009** ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)) -- Badge Definition +- **Kind 8** ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)) -- Badge Award +- **Kind 1111** ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) -- Community Posts +- **Kind 1984** ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) -- Moderation +- **Kind 5** ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)) -- Deletion / Revocation + +### Overview + +A hierarchical community consists of: + +1. **Badge definitions** (kind `30009`), one per rank tier, published by the founder. +2. A **community definition** (kind `34550`) referencing those badges with rank indices. +3. **Badge awards** (kind `8`) forming a chain of trust -- each award grants a rank, validated by the awarder's rank. +4. **Posts** (kind `1111`) scoped to the community via NIP-22. +5. **Reports** (kind `1984`) scoped to the community for content removal or member bans. +6. **Deletion requests** (kind `5`) for revoking awards or rescinding moderation. + +### Membership Derivation + +Community membership is derived from three distinct sources, each resolved differently: + +- **Founder** -- the `pubkey` field on the kind `34550` event. One per community, immutable. Controls the community definition since only they can republish the addressable event. +- **Moderators** -- the `p` tags on the kind `34550` event (matching [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). Mutable (the founder can add/remove by republishing). Share rank 0 with the founder. +- **Members** -- derived from kind `8` badge awards forming the authority chain. A member's rank is determined by the badge they were awarded (rank 1 and below). + +The founder and moderators have no badge. Their rank 0 status comes from the community definition itself. Rank 0 cannot be awarded via kind `8` -- there is no rank 0 badge definition. Clients determine founder/moderator display from the community event directly. + +Authority is **rank-based, not badge-specific**. A member at rank N can award any badge at rank M where M > N. + +### Community Definition + +A kind `34550` event defines the community, extending [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md) with badge `a` tags that encode rank indices. + +#### Tags + +| Tag | Required | Description | +|-----|----------|-------------| +| `d` | Yes | Unique community identifier (UUID recommended). | +| `name` | Yes | Human-readable name. | +| `description` | No | Community description. | +| `image` | No | Image URL. | +| `a` | Yes (1+) | Badge definition reference with rank index (see format below). | +| `p` | Yes (1+) | Moderator pubkeys. Implicitly rank 0. The 4th element SHOULD be `"moderator"`. | +| `relay` | No | Recommended relay URL for community content (per [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72.md)). | +| `alt` | No | [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) description. | + +#### Badge `a` Tag Format + +``` +["a", "30009::", "", ""] +``` + +Rank `0` is reserved for the founder and moderators (derived from the community definition, not from badges). Badge `a` tags define awardable ranks starting from `1`. Higher numbers = lower authority. Indices MUST be contiguous starting from 1. + +#### Example + +```jsonc +{ + "kind": 34550, + "pubkey": "", + "content": "", + "tags": [ + ["d", "a1b2c3d4-e5f6-7890-abcd-ef1234567890"], + ["name", "The Arbiter's Guard"], + ["description", "Elite Halo 2 clan"], + ["image", "https://example.com/clan-banner.jpg"], + ["a", "30009::a1b2c3d4-...-staff", "", "1"], + ["a", "30009::a1b2c3d4-...-member", "", "2"], + ["a", "30009::a1b2c3d4-...-peon", "", "3"], + ["p", "", "", "moderator"], + ["p", "", "", "moderator"], + ["relay", "wss://relay.example.com"], + ["alt", "Community: The Arbiter's Guard"] + ] +} +``` + +### Badge Definitions + +Each rank tier is a standard [NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md) kind `30009` badge definition published by the founder. Badge definitions MUST be published **before** the community definition that references them. + +The `d` tag SHOULD use the format `-` for global uniqueness. + +```jsonc +{ + "kind": 30009, + "pubkey": "", + "content": "", + "tags": [ + ["d", "a1b2c3d4-...-staff"], + ["name", "Staff"], + ["description", "Trusted officers who manage clan operations."], + ["image", "https://example.com/staff-badge.png"], + ["alt", "Badge definition: Staff"] + ] +} +``` + +### Badge Awards + +Membership is established through kind `8` badge awards ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)). Each award forms a chain link. + +A badge award is **valid** if and only if: + +1. The `a` tag references a badge definition listed in the community definition. +2. The awarder is a validated member at a rank **strictly less than** the badge's rank index. +3. The awarder's chain can be walked upward to a founder or moderator. + +```jsonc +// Moderator (rank 0) awarding Staff (rank 1) +{ + "kind": 8, + "pubkey": "", + "content": "", + "tags": [ + ["a", "30009::a1b2c3d4-...-staff"], + ["p", ""], + ["alt", "Badge award: Staff in The Arbiter's Guard"] + ] +} +``` + +### Chain Validation + +Membership is **derived state**. Clients compute effective membership by resolving the authority graph from badge awards, then applying moderation overlays. + +#### Algorithm + +1. **Seed rank 0**: The event publisher (founder) and all `p` tags (moderators) in the community definition are rank 0 members. +2. **Query awards**: `{ kinds: [8], #a: [] }` +3. **Iteratively validate**: For each award, check if the awarder is a validated member with rank strictly less than the awarded rank. If valid, add the recipient. Repeat until no new members are discovered. +4. **Apply moderation**: Query `{ kinds: [1984], #A: [] }`. Remove banned members (but not their downstream subtrees -- the chain remains intact). Hide reported posts. + +Clients MUST NOT trust kind `8` events at face value. An attacker can publish awards for themselves, but these fail chain validation without a path to a founder or moderator. + +### Community Posts + +Community discussion uses kind `1111` ([NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md)) scoped to the community definition as the root event. + +#### Top-Level Post + +```jsonc +{ + "kind": 1111, + "content": "Hello clan!", + "tags": [ + ["A", "34550::", ""], + ["K", "34550"], + ["P", "", ""], + ["a", "34550::", ""], + ["k", "34550"], + ["p", "", ""] + ] +} +``` + +#### Reply + +Replies keep the community as root scope and point to the parent comment: + +```jsonc +{ + "kind": 1111, + "content": "Great point!", + "tags": [ + ["A", "34550::", ""], + ["K", "34550"], + ["P", "", ""], + ["e", "", "", ""], + ["k", "1111"], + ["p", "", ""] + ] +} +``` + +#### Querying + +Fetch all community-scoped posts and moderation data in a single request: + +```jsonc +{ + "kinds": [1111, 1984], + "#A": ["34550::"] +} +``` + +Clients then filter client-side: discard kind `1111` posts from non-members, and apply authoritative kind `1984` reports per the moderation rules below. + +### Moderation + +Moderation uses kind `1984` ([NIP-56](https://github.com/nostr-protocol/nips/blob/master/56.md)) scoped to the community via the uppercase `A` tag. Reports from higher-ranked members are treated as **authoritative moderation actions**. + +#### Authority Rules + +A report is **authoritative** if: + +1. The reporter is a validated community member. +2. The reporter's rank is strictly less than the target's rank (or the target is a non-member). + +Reports from non-members or insufficiently-ranked members are ignored. + +#### Content Removal + +Hide a post by publishing kind `1984` with both `e` and `p` tags: + +```jsonc +{ + "kind": 1984, + "pubkey": "", + "content": "Spam", + "tags": [ + ["e", ""], + ["p", ""], + ["A", "34550::"] + ] +} +``` + +#### Member Ban + +Ban a member by publishing kind `1984` with `p` tag only (no `e` tag). This is **non-cascading** -- only the targeted member is banned. Their kind `8` awards remain on relays, so downstream members whose chain passes through the banned member are still valid. For cascading removal, use badge revocation (kind `5`) instead. + +```jsonc +{ + "kind": 1984, + "pubkey": "", + "content": "Violated guidelines", + "tags": [ + ["p", ""], + ["A", "34550::"] + ] +} +``` + +Clients distinguish content removal (`e` + `p` + `A`) from bans (`p` + `A`, no `e`). + +#### Reinstatement + +Delete the kind `1984` event via kind `5` ([NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md)). Per NIP-09, only the original author can delete it. + +```jsonc +{ + "kind": 5, + "tags": [["e", ""], ["k", "1984"]] +} +``` + +### Revocation + +A badge awarder can revoke their own award via kind `5`: + +```jsonc +{ + "kind": 5, + "tags": [["e", ""], ["k", "8"]] +} +``` + +This is **cascading** -- the chain link is destroyed, so the revoked member and all downstream members whose chain depended on it lose validated status. Per NIP-09, only the original publisher of the kind `8` event can delete it. + +**Ban vs revocation**: Use kind `1984` to ban a single member without affecting their downstream recruits. Use kind `5` revocation to remove a member and cascade to their entire subtree. + +### Community Updates + +Both kind `34550` and kind `30009` are addressable events. To add or remove ranks, republish the community definition with updated `a` tags. To update moderators, republish with updated `p` tags. Removing a moderator cascades to members they recruited (unless those members have another valid chain path). Only the founder (event publisher) can republish the community definition. + +### Discovery + +**Communities founded by a user:** + +```jsonc +{ "kinds": [34550], "authors": [""] } +``` + +**Communities a user belongs to:** + +1. `{ "kinds": [8], "#p": [""] }` +2. Extract badge `a` tags from results. +3. `{ "kinds": [34550], "#a": ["30009:...", "..."] }` + +### Security Considerations + +- **Author filtering**: Clients MUST filter community definitions by `authors` to prevent impersonation. +- **Chain validation is required**: Never trust kind `8` events without walking the authority chain. +- **Badge d-tag uniqueness**: Use `-` to prevent cross-community collisions. +- **Badge acceptance is cosmetic**: NIP-58 kind `10008`/`30008` events have no effect on chain validation. + +### Dependencies + +- [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) -- Event Deletion Request +- [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-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 + +--- + ## Kind 0 Extension: Avatar Shape ### Summary From 5fa021329e11635c75d21021034b1b8fd4db4723 Mon Sep 17 00:00:00 2001 From: lemon Date: Mon, 20 Apr 2026 11:57:03 -0700 Subject: [PATCH 13/13] remove kind 5/1984 moderation from community membership resolution The deletion and report queries were unscoped (fetching globally) and the moderation overlay needs more design work. Strip it out for now and leave TODOs for a follow-up. --- src/hooks/useCommunityMembers.ts | 27 +++++-------- src/lib/communityUtils.ts | 69 +++----------------------------- 2 files changed, 16 insertions(+), 80 deletions(-) diff --git a/src/hooks/useCommunityMembers.ts b/src/hooks/useCommunityMembers.ts index 527e510d..1c0060a1 100644 --- a/src/hooks/useCommunityMembers.ts +++ b/src/hooks/useCommunityMembers.ts @@ -5,16 +5,16 @@ import { type ParsedCommunity, type CommunityMembership, BADGE_AWARD_KIND, - REPORT_KIND, - DELETION_KIND, resolveMembership, } from '@/lib/communityUtils'; /** * Fetch and resolve the full membership tree for a community. * - * Queries badge awards (kind 8), reports (kind 1984), and deletions (kind 5) - * then runs the chain validation algorithm from the community NIP. + * Queries badge awards (kind 8) and runs the chain validation algorithm + * from the community NIP. + * + * TODO: add kind 1984 (reports) and kind 5 (deletions) for moderation overlay. */ export function useCommunityMembers(community: ParsedCommunity | null | undefined) { const { nostr } = useNostr(); @@ -31,24 +31,17 @@ export function useCommunityMembers(community: ParsedCommunity | null | undefine if (badgeATags.length === 0) { // No badge ranks defined — only founder + moderators - return resolveMembership(community, [], [], []); + return resolveMembership(community, []); } - // Single combined query for awards, reports, and deletions - const events = await nostr.query( - [ - { kinds: [BADGE_AWARD_KIND], '#a': badgeATags, limit: 500 }, - { kinds: [REPORT_KIND], '#A': [community.aTag], limit: 200 }, - { kinds: [DELETION_KIND], '#k': ['8', '1984'], limit: 200 }, - ], + // Fetch badge awards scoped to this community's badge definitions + const awards = await nostr.query( + [{ kinds: [BADGE_AWARD_KIND], '#a': badgeATags, limit: 500 }], { signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]) }, ); - const awards = events.filter((e) => e.kind === BADGE_AWARD_KIND); - const reports = events.filter((e) => e.kind === REPORT_KIND); - const deletions = events.filter((e) => e.kind === DELETION_KIND); - - return resolveMembership(community, awards, reports, deletions); + // TODO: query kind 1984 reports and kind 5 deletions for moderation overlay + return resolveMembership(community, awards); }, enabled: !!community, staleTime: 2 * 60_000, diff --git a/src/lib/communityUtils.ts b/src/lib/communityUtils.ts index 1a4e32e6..1f8ead27 100644 --- a/src/lib/communityUtils.ts +++ b/src/lib/communityUtils.ts @@ -13,11 +13,8 @@ export const BADGE_DEFINITION_KIND = 30009; /** NIP-58 badge award. */ export const BADGE_AWARD_KIND = 8; -/** NIP-56 report / moderation. */ -export const REPORT_KIND = 1984; - -/** NIP-09 deletion request. */ -export const DELETION_KIND = 5; +// TODO: kind 1984 (NIP-56 reports) and kind 5 (NIP-09 deletions) will be +// added when the moderation overlay is implemented. // ── Rank tier metadata ──────────────────────────────────────────────────────── @@ -142,13 +139,13 @@ export interface CommunityMembership { * 1. Seed rank 0 from the community definition (founder + moderators). * 2. Iteratively validate badge awards — awarder must be a validated * member with rank strictly less than the awarded badge's rank. - * 3. Apply moderation overlays (kind 1984 bans). + * + * TODO: Step 3 (moderation overlay via kind 1984 bans + kind 5 deletions) + * is not yet implemented. */ export function resolveMembership( community: ParsedCommunity, awardEvents: NostrEvent[], - reportEvents: NostrEvent[], - deletionEvents: NostrEvent[], ): CommunityMembership { // Build badge-to-rank lookup const badgeToRank = new Map(); @@ -172,26 +169,13 @@ export function resolveMembership( } } - // Build set of deleted event IDs (kind 5 targeting kind 8) - const deletedIds = new Set(); - for (const del of deletionEvents) { - const kTags = del.tags.filter(([n]) => n === 'k').map(([, v]) => v); - if (!kTags.includes('8')) continue; - for (const tag of del.tags) { - if (tag[0] === 'e') deletedIds.add(tag[1]); - } - } - - // Filter out deleted awards - const activeAwards = awardEvents.filter((e) => !deletedIds.has(e.id)); - // Step 2: Iterative validation let changed = true; const processed = new Set(); while (changed) { changed = false; - for (const award of activeAwards) { + for (const award of awardEvents) { if (processed.has(award.id)) continue; const awarderPubkey = award.pubkey; @@ -234,47 +218,6 @@ export function resolveMembership( } } - // Step 3: Apply moderation (bans) - // Build set of deleted report IDs - const deletedReportIds = new Set(); - for (const del of deletionEvents) { - const kTags = del.tags.filter(([n]) => n === 'k').map(([, v]) => v); - if (!kTags.includes('1984')) continue; - for (const tag of del.tags) { - if (tag[0] === 'e') deletedReportIds.add(tag[1]); - } - } - - const communityATag = community.aTag; - - for (const report of reportEvents) { - if (deletedReportIds.has(report.id)) continue; - - // Must be scoped to this community - const scopedToThis = report.tags.some( - ([n, v]) => n === 'A' && v === communityATag, - ); - if (!scopedToThis) continue; - - const reporter = validated.get(report.pubkey); - if (!reporter) continue; // Non-member reports are ignored - - // Ban: p tag only (no e tag) - const hasPTag = report.tags.some(([n]) => n === 'p'); - const hasETag = report.tags.some(([n]) => n === 'e'); - - if (hasPTag && !hasETag) { - const targetPubkey = report.tags.find(([n]) => n === 'p')?.[1]; - if (!targetPubkey) continue; - const target = validated.get(targetPubkey); - if (!target) continue; - // Reporter must outrank target - if (reporter.rank < target.rank) { - validated.delete(targetPubkey); - } - } - } - const members = Array.from(validated.values()); members.sort((a, b) => a.rank - b.rank);