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 diff --git a/package-lock.json b/package-lock.json index b7bd5103..36f34864 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5827,7 +5827,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5841,7 +5840,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5855,7 +5853,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5869,7 +5866,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5883,7 +5879,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5897,7 +5892,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5911,7 +5905,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5925,7 +5918,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5939,7 +5931,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5953,7 +5944,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5967,7 +5957,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5981,7 +5970,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5995,7 +5983,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6009,7 +5996,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6023,7 +6009,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6037,7 +6022,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6051,7 +6035,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6065,7 +6048,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6079,7 +6061,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6093,7 +6074,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6107,7 +6087,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6121,7 +6100,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6135,7 +6113,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6149,7 +6126,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6163,7 +6139,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/src/App.tsx b/src/App.tsx index b95cb0bc..5bc0c6b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -106,6 +106,8 @@ const hardcodedConfig: AppConfig = { feedIncludePodcastTrailers: true, showDevelopment: true, feedIncludeDevelopment: true, + showCommunities: true, + feedIncludeCommunities: true, showBadges: true, showBadgeDefinitions: true, showProfileBadges: true, @@ -125,6 +127,7 @@ const hardcodedConfig: AppConfig = { "feed", "notifications", "messages", + "communities", "profile", "settings", ], diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index dad2e125..73c479a7 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -33,6 +33,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 }))); @@ -166,6 +167,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/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) => ( - - ))} -
-
- )}
); } diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx new file mode 100644 index 00000000..5bcbf12c --- /dev/null +++ b/src/components/CommunityDetailPage.tsx @@ -0,0 +1,414 @@ +import { useMemo, useCallback } 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 { 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 { 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(); + + // 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 ── */} +
+ {/* 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

+
+ ) : ( +
+ {communityEvents.map((ev) => ( + + ))} +
+ )} +
+ + {/* ── Comments tab ── */} + + + + {commentsLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : replyTree.length > 0 ? ( + + ) : ( +
+ No comments yet. Be the first to comment! +
+ )} +
+
+
+
+ ); +} 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 afd49acd..35ca315d 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/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/useCommunityMembers.ts b/src/hooks/useCommunityMembers.ts new file mode 100644 index 00000000..1c0060a1 --- /dev/null +++ b/src/hooks/useCommunityMembers.ts @@ -0,0 +1,49 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; + +import { + type ParsedCommunity, + type CommunityMembership, + BADGE_AWARD_KIND, + resolveMembership, +} from '@/lib/communityUtils'; + +/** + * Fetch and resolve the full membership tree for a community. + * + * 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(); + + 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, []); + } + + // 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)]) }, + ); + + // 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/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..1f8ead27 --- /dev/null +++ b/src/lib/communityUtils.ts @@ -0,0 +1,236 @@ +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; + +// TODO: kind 1984 (NIP-56 reports) and kind 5 (NIP-09 deletions) will be +// added when the moderation overlay is implemented. + +// ── 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. + * + * TODO: Step 3 (moderation overlay via kind 1984 bans + kind 5 deletions) + * is not yet implemented. + */ +export function resolveMembership( + community: ParsedCommunity, + awardEvents: 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 }); + } + } + + // Step 2: Iterative validation + let changed = true; + const processed = new Set(); + + while (changed) { + changed = false; + for (const award of awardEvents) { + 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); + } + } + + 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 b374bf07..78eb5529 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 8dd48760..50dade6a 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -30,6 +30,7 @@ import { Smile, SmilePlus, User, + Users, Vote, WalletMinimal, Zap, @@ -158,6 +159,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 }, ]; @@ -195,6 +197,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..84507182 --- /dev/null +++ b/src/pages/CommunitiesPage.tsx @@ -0,0 +1,232 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useSeoMeta } from '@unhead/react'; +import { 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 { useCommunityActivityFeed } from '@/hooks/useCommunityActivityFeed'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useFeedTab } from '@/hooks/useFeedTab'; +import { useMyCommunities } from '@/hooks/useMyCommunities'; + +// ─── Types ───────────────────────────────────────────────────────────────────── + +type CommunitiesTab = 'activities' | 'mine'; + +// ─── 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', [ + 'activities', + 'mine', + ]); + + useSeoMeta({ + title: `Communities | ${config.appName}`, + description: 'Discover and join hierarchical communities on Nostr', + }); + + return ( +
+ } /> + + {/* Activities / My Communities tabs */} + {user && ( + + setActiveTab('activities')} + /> + setActiveTab('mine')} + /> + + )} + + {/* Arc overhang spacer */} + {user &&
} + + {/* Tab content */} + {activeTab === 'mine' ? ( + + ) : ( + + queryClient.invalidateQueries({ + queryKey: ['community-activity-feed'], + exact: false, + }) + } + /> + )} +
+ ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 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) => ( + + ))} +
+ ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Activities Tab +// ═══════════════════════════════════════════════════════════════════════════════ + +function ActivitiesTab({ onRefresh }: { onRefresh: () => Promise }) { + const { user } = useCurrentUser(); + const { data: activityEvents, isLoading } = useCommunityActivityFeed(); + + if (!user) { + return ( +
+
+ +
+
+

Community activity

+

+ Log in to see activity from your communities. +

+
+ +
+ ); + } + + return ( + + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : activityEvents && activityEvents.length > 0 ? ( +
+ {activityEvents.map((event) => ( + + ))} +
+ ) : ( + + )} +
+ ); +} 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 ; diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 9c828898..7603f4d4 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') && (
- - {/* 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 - -
)} - {/* Active filter summary chips (posts tab only) */} - {activeTab === 'posts' && activeFilterLabels.length > 0 && ( + {/* Active filter summary chips (posts & communities tabs) */} + {(activeTab === 'posts' || activeTab === 'communities') && activeFilterLabels.length > 0 && (
{activeFilterLabels.map((label) => ( @@ -727,8 +734,8 @@ export function SearchPage() {
)} - {/* NIP-50 search query debug block (posts tab only) */} - {activeTab === 'posts' && debouncedSearchQuery.trim() && ( + {/* NIP-50 search query debug block (posts & communities tabs) */} + {(activeTab === 'posts' || activeTab === 'communities') && debouncedSearchQuery.trim() && ( @@ -748,6 +755,22 @@ export function SearchPage() { + {/* ─── Communities Tab ─── */} + {activeTab === 'communities' && ( + + )} + {/* ─── Posts Tab ─── */} {activeTab === 'posts' && ( <> @@ -1093,6 +1116,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, }: { 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,