From 9fd585ebdd973b2fdc4934f2ae62700ccce161bd Mon Sep 17 00:00:00 2001 From: mkfain Date: Thu, 21 May 2026 21:12:33 -0500 Subject: [PATCH] Moderator-curated featured organizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded FEATURED_ORGANIZATION_AUTHORS allowlist with the same NIP-32 label flow that curates featured campaigns: Team Soapbox pack members publish kind 1985 labels in the agora.moderation namespace tagging an organization's 34550:: coordinate as featured, hidden, or approved, and useFeaturedOrganizations folds those labels into the /communities Featured shelf. The campaign and organization label streams share a single namespace and a single moderator pack — they're separated purely by which kind prefix the 'a' tag carries. To keep that contract enforced in one place, the constants, types, and folding logic are now in src/lib/agoraModeration.ts; useCampaignModeration and the new useOrganizationModeration both call foldModerationLabels with their respective kind. The campaign hook's external surface (AGORA_MODERATION_NAMESPACE, ModerationLabel, CampaignModerationData) is preserved via re-exports so existing call sites don't move. Moderators see a CommunityModerationMenu kebab overlaid on every CommunityMiniCard exposing approve/unapprove, hide/unhide, and feature/unfeature. Mounting reads moderation state once per page from the shared TanStack cache, mirroring CampaignCard. Non-moderators see no overlay (the menu returns null) and no card affordances change. The 'My organizations' shelf intentionally ignores moderation — a user's own founded, moderated, or followed organizations always render regardless of label state. Only the Featured shelf consumes the curation rollup. The Featured grid is uncapped: moderators control how many orgs surface by labeling, and ordering follows the recency of each 'featured' label so re-publishing bumps an org to the top. NIP.md's 'Campaign Moderation Labels' section is renamed to 'Agora Moderation Labels' and documents the kind-34550 coord form and the 'My organizations ignores moderation' rule. Note: existing surfaced organizations will disappear from the shelf until a moderator publishes featured labels for them. --- NIP.md | 57 +++++-- src/components/CommunityModerationMenu.tsx | 152 ++++++++++++++++++ .../discovery/CommunityMiniCard.tsx | 40 ++++- src/hooks/useCampaignModeration.ts | 144 +++-------------- src/hooks/useFeaturedOrganizations.ts | 125 ++++++++++---- src/hooks/useOrganizationModeration.ts | 111 +++++++++++++ src/lib/agoraModeration.ts | 152 ++++++++++++++++++ src/pages/CommunitiesPage.tsx | 4 +- 8 files changed, 619 insertions(+), 166 deletions(-) create mode 100644 src/components/CommunityModerationMenu.tsx create mode 100644 src/hooks/useOrganizationModeration.ts create mode 100644 src/lib/agoraModeration.ts diff --git a/NIP.md b/NIP.md index caed2d8f..c3cad83e 100644 --- a/NIP.md +++ b/NIP.md @@ -463,9 +463,16 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned - **No category, no topics:** kind 33863 events MUST NOT carry `t` tags or NIP-32 category labels in any `agora.*` namespace. Campaigns are individual stories; discovery happens via search (NIP-50 against title/summary/content), country (`#i`), and moderator curation (below). - **Migration:** kind 33863 has no relationship to any earlier campaign kind. Clients MUST NOT read, merge, or migrate events of any other kind into the kind 33863 namespace. -### Campaign Moderation Labels +### Agora Moderation Labels -Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`) via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The campaign event itself is never modified — surfacing is purely a client-side rollup of label events. +Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), and which kind 34550 organizations appear in the Featured shelf on `/communities`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events. + +Campaigns and organizations share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the two streams is the kind prefix on the `a` tag of each label: + +- `33863::` — campaign (kind 33863, see "Open Campaigns" above). +- `34550::` — organization (kind 34550, NIP-72 community definition). + +A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. Mixing the two streams would let a moderator's `featured` label on a campaign appear to feature an unrelated organization with the same `d` tag, or vice versa. #### Namespace @@ -480,22 +487,30 @@ Each label event carries the namespace twice, per NIP-32: #### Label values -Three independent axes; the newest moderator-signed label per axis per campaign wins. +Three independent axes; the newest moderator-signed label per axis per coordinate wins. | Axis | Values | Meaning | |----------|---------------------------|-------------------------------------------------------------------------| -| approval | `approved`, `unapproved` | `approved` allows the campaign on `/` and Discover. `unapproved` retracts a previous approval. | -| hide | `hidden`, `unhidden` | `hidden` suppresses the campaign everywhere it would otherwise appear. `unhidden` retracts a previous hide. | -| featured | `featured`, `unfeatured` | `featured` places the campaign in the hand-picked Featured row on `/`. `unfeatured` retracts. | +| approval | `approved`, `unapproved` | `approved` allows the campaign/organization on its discovery surfaces. `unapproved` retracts a previous approval. | +| hide | `hidden`, `unhidden` | `hidden` suppresses the campaign/organization everywhere it would otherwise appear. `unhidden` retracts a previous hide. | +| featured | `featured`, `unfeatured` | `featured` places the campaign in the hand-picked Featured row on `/`, or the organization in the Featured shelf on `/communities`. `unfeatured` retracts. | Surfacing rules (hide always wins): +**Campaigns** + - **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first. Featured is independent of Approved at the protocol level; a campaign may be featured without being approved (the home page treats Featured and Approved as deduplicated bins, with Featured taking precedence). - **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above). - **Discover shelf** — iff approved AND not hidden. - **Moderator-only "Pending"** — iff neither approved nor hidden. - **Moderator-only "Hidden"** — iff hidden. +**Organizations** + +- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first. +- **"My organizations" shelf on `/communities`** — intentionally ignores all moderation labels. A user's own founded, moderated, or followed organizations always render regardless of label state. +- **Hide enforcement on other organization discovery surfaces** — clients SHOULD suppress `hidden` organizations from any future "All organizations" / browse surface for non-moderators. Moderators MAY see hidden organizations with a "Hidden" treatment so they can unhide. + #### Event Structure ```json @@ -511,14 +526,27 @@ Surfacing rules (hide always wins): } ``` -A `featured` label has the same shape with `["l", "featured", "agora.moderation"]` and `["alt", "Campaign moderation: featured"]`. +An organization label has the same shape with a kind 34550 `a` tag: + +```json +{ + "kind": 1985, + "content": "", + "tags": [ + ["L", "agora.moderation"], + ["l", "featured", "agora.moderation"], + ["a", "34550::"], + ["alt", "Organization moderation: featured"] + ] +} +``` Required tags: - `L` set to `agora.moderation`. - `l` with the label value as the 2nd element and `agora.moderation` as the 3rd. -- `a` referencing the campaign coordinate `33863::`. -- `alt` (NIP-31) — clients without label support will display this string. +- `a` referencing the target coordinate (`33863::` for a campaign, `34550::` for an organization). +- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured` or `Organization moderation: featured`) so non-Agora clients can read it. #### Trust Model @@ -533,9 +561,11 @@ d-tag: k4p5w0n22suf The pack `p` tags are the authoritative moderator list. Clients MUST pin `authors:` on their label REQ to the pack `p` tags; events from non-pack authors MUST be ignored. This means: - Self-approval is impossible unless the pack author has added you. -- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive only by their labels return to "pending" until another moderator approves them. +- A moderator removed from the pack immediately loses moderation authority — campaigns/organizations kept alive only by their labels return to "pending" until another moderator approves them. - The pack author (single signer) can reset the entire moderator roster by republishing the pack. +The same moderator set governs both campaign and organization labels. Carving out per-surface moderator subsets is out of scope; clients that need that distinction would have to introduce a second follow pack and a second label namespace. + #### Querying Step 1 — fetch the pack: @@ -560,13 +590,14 @@ Step 2 — fetch label events from pack members in the namespace: } ``` -Step 3 — fold by `(campaign-coord, axis)`, latest-`created_at`-wins. Then fetch only the approved-and-not-hidden campaign coordinates with one filter per author (bundled in a single REQ). +Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the relevant kind prefix (`33863:` for campaigns or `34550:` for organizations). Then fetch the targeted events themselves — one filter per author (bundled in a single REQ) keyed by their d-tags. #### Client Behavior -- Clients SHOULD render approve/hide controls only for users whose pubkey appears in the pack. -- Clients MAY display "Hidden" badges on hidden campaigns when viewed by a moderator, and SHOULD NOT render them at all to non-moderators. +- Clients SHOULD render approve/hide/feature controls only for users whose pubkey appears in the pack. +- Clients MAY display "Hidden" badges on hidden campaigns/organizations when viewed by a moderator, and SHOULD NOT render them at all to non-moderators. - Non-moderator authors viewing the homepage SHOULD see their own pending campaigns in a separate explained section so they understand why their campaign isn't yet on the homepage. The campaign URL remains live and donatable regardless of moderation state. +- Organization authors are not shown an equivalent "pending" surface today — organizations are visible at their NIP-19 route regardless of moderation, and the only moderation surface is the Featured shelf. --- diff --git a/src/components/CommunityModerationMenu.tsx b/src/components/CommunityModerationMenu.tsx new file mode 100644 index 00000000..e36e2a4e --- /dev/null +++ b/src/components/CommunityModerationMenu.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { Check, EyeOff, Eye, Loader2, MoreHorizontal, ShieldCheck, ShieldOff, Sparkles, SparklesIcon } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useCampaignModerators } from '@/hooks/useCampaignModerators'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useOrganizationModeration } from '@/hooks/useOrganizationModeration'; +import { useToast } from '@/hooks/useToast'; +import type { ModerationLabel } from '@/lib/agoraModeration'; + +interface CommunityModerationMenuProps { + /** The organization's `34550::` coordinate. */ + coord: string; + /** Visible name for the organization (for toast feedback). */ + organizationName: string; + /** Whether the organization is currently approved. */ + isApproved: boolean; + /** Whether the organization is currently hidden. */ + isHidden: boolean; + /** Whether the organization is currently featured. */ + isFeatured: boolean; + className?: string; +} + +/** + * Per-card kebab menu exposing the six moderation actions for an + * organization: + * + * Approve / Unapprove (axis = approval) + * Hide / Unhide (axis = hide) + * Feature / Unfeature (axis = featured) + * + * Renders `null` for users who are not Team Soapbox pack members. Sits + * inside the clickable `CommunityMiniCard` ``, so the trigger + * swallows its own click and the dropdown content stops propagation — + * otherwise every menu interaction would navigate to the organization + * detail page. + * + * Mirrors `CampaignModerationMenu`; the only differences are the coord + * prefix (`34550:` vs `33863:`), the toast copy ("organization" vs + * "campaign"), and the mutation hook. + */ +export function CommunityModerationMenu({ + coord, + organizationName, + isApproved, + isHidden, + isFeatured, + className, +}: CommunityModerationMenuProps) { + const { user } = useCurrentUser(); + const { data: moderators } = useCampaignModerators(); + const { moderate } = useOrganizationModeration(); + const { toast } = useToast(); + const [busy, setBusy] = useState(null); + + const isMod = !!user && !!moderators && moderators.includes(user.pubkey); + if (!isMod) return null; + + const runAction = async (action: ModerationLabel, verbPast: string) => { + if (busy) return; + setBusy(action); + try { + await moderate.mutateAsync({ coord, action }); + toast({ title: verbPast, description: organizationName }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + toast({ + title: `Failed to ${action}`, + description: message, + variant: 'destructive', + }); + } finally { + setBusy(null); + } + }; + + return ( + + e.preventDefault()}> + + + e.stopPropagation()}> + + Moderator actions + + + {isApproved ? ( + runAction('unapproved', 'Removed approval')}> + + Unapprove + + Approved + + + ) : ( + runAction('approved', 'Approved organization')}> + + Approve + + )} + {isHidden ? ( + runAction('unhidden', 'Unhidden')}> + + Unhide + + Hidden + + + ) : ( + runAction('hidden', 'Hidden')} + className="text-destructive focus:text-destructive" + > + + Hide + + )} + + {isFeatured ? ( + runAction('unfeatured', 'Removed from featured')}> + + Unfeature + + Featured + + + ) : ( + runAction('featured', 'Featured organization')}> + + Feature + + )} + + + ); +} diff --git a/src/components/discovery/CommunityMiniCard.tsx b/src/components/discovery/CommunityMiniCard.tsx index 31b8f549..8ef7d30a 100644 --- a/src/components/discovery/CommunityMiniCard.tsx +++ b/src/components/discovery/CommunityMiniCard.tsx @@ -1,10 +1,13 @@ import { Link } from 'react-router-dom'; -import { Users } from 'lucide-react'; +import { EyeOff, Users } from 'lucide-react'; import { nip19 } from 'nostr-tools'; +import { CommunityModerationMenu } from '@/components/CommunityModerationMenu'; +import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { useAuthor } from '@/hooks/useAuthor'; +import { useOrganizationModeration } from '@/hooks/useOrganizationModeration'; import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; @@ -29,6 +32,10 @@ interface CommunityMiniCardProps { * - founder avatar + display name in a muted row. * * Kept narrow enough to fit ~4 cards across a desktop content column. + * + * Moderators (Team Soapbox pack members) see a kebab menu overlaid on the + * banner exposing the Approve / Hide / Feature actions. Non-moderators see + * no overlay at all because `CommunityModerationMenu` returns `null`. */ export function CommunityMiniCard({ community, className }: CommunityMiniCardProps) { const founder = useAuthor(community.founderPubkey); @@ -45,6 +52,15 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro identifier: community.dTag, }); + // Per-card moderation state. Reads from the shared TanStack cache; the + // underlying query is fetched once per page render no matter how many + // cards mount this hook. + const { data: moderation } = useOrganizationModeration(); + const coord = community.aTag; + const isApproved = moderation.approvedCoords.has(coord); + const isHidden = moderation.hiddenCoords.has(coord); + const isFeatured = moderation.featuredCoords.has(coord); + return ( )} + {/* Moderator overlay. Mirrors `CampaignCard`: Hidden badge sits to + the left of the kebab so moderators can see the state at a + glance. Both render `null` for non-moderators or unlabelled + orgs. */} +
+ {isHidden && ( + + + Hidden + + )} + +

diff --git a/src/hooks/useCampaignModeration.ts b/src/hooks/useCampaignModeration.ts index 06f3c9a2..65eb1e36 100644 --- a/src/hooks/useCampaignModeration.ts +++ b/src/hooks/useCampaignModeration.ts @@ -1,130 +1,31 @@ import { useNostr } from '@nostrify/react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { NostrEvent } from '@nostrify/nostrify'; import { useNostrPublish } from './useNostrPublish'; import { useCampaignModerators } from './useCampaignModerators'; import { CAMPAIGN_KIND } from '@/lib/campaign'; +import { + AGORA_MODERATION_NAMESPACE, + EMPTY_MODERATION_DATA, + LABEL_KIND, + type ModerationData, + type ModerationLabel, + type ModerationState, + foldModerationLabels, +} from '@/lib/agoraModeration'; -/** NIP-32 label kind. */ -const LABEL_KIND = 1985; -/** Label namespace for Agora's moderation labels. */ -export const AGORA_MODERATION_NAMESPACE = 'agora.moderation'; - -/** The six possible label values in the moderation namespace. */ -export type ModerationLabel = - | 'approved' - | 'unapproved' - | 'hidden' - | 'unhidden' - | 'featured' - | 'unfeatured'; - -/** A single label event narrowed to its decision axis. */ -interface AxisDecision { - /** Latest label observed on this axis. */ - label: ModerationLabel; - /** Author of the latest label. */ - pubkey: string; - /** Created-at of the latest label. */ - createdAt: number; -} +// Re-exports for existing import sites. The namespace constant and the +// `ModerationLabel` type are imported from this module by the campaign +// moderation menu and other surfaces; keep those exports stable so the +// shared-module refactor stays a no-op for callers. +export { AGORA_MODERATION_NAMESPACE }; +export type { ModerationLabel }; /** Per-campaign rollup of approval + hide + featured state. */ -export interface CampaignModerationState { - approval?: AxisDecision; // `approved` or `unapproved` - hide?: AxisDecision; // `hidden` or `unhidden` - featured?: AxisDecision; // `featured` or `unfeatured` -} +export type CampaignModerationState = ModerationState; -export interface CampaignModerationData { - /** Map of `30223::` -> rollup. */ - byCoord: Map; - /** Coordinates where the latest approval label is `approved`. */ - approvedCoords: Set; - /** Coordinates where the latest hide label is `hidden`. */ - hiddenCoords: Set; - /** Coordinates where the latest featured label is `featured`. */ - featuredCoords: Set; - /** - * Map of `coord` -> `created_at` of the latest `featured` label. - * Used to sort the home-page featured row newest-first. - */ - featuredOrder: Map; - /** Pubkeys that were considered moderators when the query ran. */ - moderators: string[]; -} - -const EMPTY_DATA: CampaignModerationData = { - byCoord: new Map(), - approvedCoords: new Set(), - hiddenCoords: new Set(), - featuredCoords: new Set(), - featuredOrder: new Map(), - moderators: [], -}; - -/** True if a label value belongs to the approval axis. */ -function isApprovalLabel(value: string): value is 'approved' | 'unapproved' { - return value === 'approved' || value === 'unapproved'; -} - -/** True if a label value belongs to the hide axis. */ -function isHideLabel(value: string): value is 'hidden' | 'unhidden' { - return value === 'hidden' || value === 'unhidden'; -} - -/** True if a label value belongs to the featured axis. */ -function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' { - return value === 'featured' || value === 'unfeatured'; -} - -/** - * Fold a flat list of label events into per-coordinate rollups by axis. - * The newest event per `(coord, axis)` wins. Events not addressing a - * campaign coordinate or carrying a value outside the namespace are dropped. - */ -function foldLabelEvents(events: NostrEvent[], moderators: string[]): CampaignModerationData { - const byCoord = new Map(); - - for (const event of events) { - const value = event.tags.find(([n, , ns]) => n === 'l' && ns === AGORA_MODERATION_NAMESPACE)?.[1]; - if (!value) continue; - const aTag = event.tags.find(([n, v]) => n === 'a' && typeof v === 'string' && v.startsWith(`${CAMPAIGN_KIND}:`))?.[1]; - if (!aTag) continue; - - const state = byCoord.get(aTag) ?? {}; - if (isApprovalLabel(value)) { - if (!state.approval || event.created_at > state.approval.createdAt) { - state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at }; - } - } else if (isHideLabel(value)) { - if (!state.hide || event.created_at > state.hide.createdAt) { - state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at }; - } - } else if (isFeaturedLabel(value)) { - if (!state.featured || event.created_at > state.featured.createdAt) { - state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at }; - } - } - byCoord.set(aTag, state); - } - - const approvedCoords = new Set(); - const hiddenCoords = new Set(); - const featuredCoords = new Set(); - const featuredOrder = new Map(); - for (const [coord, state] of byCoord) { - if (state.approval?.label === 'approved') approvedCoords.add(coord); - if (state.hide?.label === 'hidden') hiddenCoords.add(coord); - if (state.featured?.label === 'featured') { - featuredCoords.add(coord); - featuredOrder.set(coord, state.featured.createdAt); - } - } - - return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators }; -} +/** Surface-scoped alias so existing callers keep working. */ +export type CampaignModerationData = ModerationData; /** * Fetches and folds campaign-moderation label events authored by Team @@ -154,7 +55,8 @@ export function useCampaignModeration() { // an empty `authors:` filter (which would return everything matching the // namespace from any author and break our trust model — see AGENTS.md). // Once moderators arrives empty, the query runs and immediately resolves - // to EMPTY_DATA — no rendering can promote a campaign without a moderator. + // to EMPTY_MODERATION_DATA — no rendering can promote a campaign without + // a moderator. const moderatorsKey = moderators ? [...moderators].sort().join(',') : ''; const moderationQuery = useQuery({ @@ -162,7 +64,7 @@ export function useCampaignModeration() { enabled: moderators !== undefined, queryFn: async ({ signal }): Promise => { if (!moderators || moderators.length === 0) { - return { ...EMPTY_DATA, moderators: [] }; + return { ...EMPTY_MODERATION_DATA, moderators: [] }; } const events = await nostr.query( [ @@ -179,7 +81,7 @@ export function useCampaignModeration() { ], { signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) }, ); - return foldLabelEvents(events, moderators); + return foldModerationLabels(events, moderators, CAMPAIGN_KIND); }, staleTime: 30_000, }); @@ -207,7 +109,7 @@ export function useCampaignModeration() { }); return { - data: moderationQuery.data ?? EMPTY_DATA, + data: moderationQuery.data ?? EMPTY_MODERATION_DATA, isPending: moderationQuery.isPending, isLoading: moderationQuery.isLoading, isReady: moderationQuery.isSuccess, diff --git a/src/hooks/useFeaturedOrganizations.ts b/src/hooks/useFeaturedOrganizations.ts index c9fc1735..3c1d3250 100644 --- a/src/hooks/useFeaturedOrganizations.ts +++ b/src/hooks/useFeaturedOrganizations.ts @@ -1,7 +1,8 @@ import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; -import type { NostrEvent } from '@nostrify/nostrify'; +import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; +import { useOrganizationModeration } from '@/hooks/useOrganizationModeration'; import { COMMUNITY_DEFINITION_KIND, parseCommunityEvent, @@ -9,56 +10,122 @@ import { } from '@/lib/communityUtils'; import { dedupeAddressableLatest } from '@/lib/addressableEvents'; -/** - * Hand-curated list of featured organization authors. - * - * We query all organizations authored by these accounts in one relay request, - * then latest-wins dedupe addressable revisions client-side. - */ -export const FEATURED_ORGANIZATION_AUTHORS = [ - '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d', -] as const; - export interface FeaturedOrganization { community: ParsedCommunity; event: NostrEvent; } /** - * Fetch featured organizations by author. + * Parse a kind 34550 coordinate string (`34550::`) into its + * pubkey and d-tag components. Returns `null` for malformed coords so the + * caller can skip them without crashing the query. + */ +function parseCoord(coord: string): { pubkey: string; dTag: string } | null { + const colon1 = coord.indexOf(':'); + if (colon1 < 0) return null; + const colon2 = coord.indexOf(':', colon1 + 1); + if (colon2 < 0) return null; + const pubkey = coord.slice(colon1 + 1, colon2); + const dTag = coord.slice(colon2 + 1); + if (!pubkey || !dTag) return null; + return { pubkey, dTag }; +} + +/** + * Fetch the featured organizations selected by Agora moderators. * - * One author-filtered query is more reliable than pinning individual event IDs - * because kind 34550 definitions are addressable and can be revised. + * Featured selection rides the shared `agora.moderation` namespace (kind + * 1985 NIP-32 labels): a moderator publishes a `featured` label tagging + * the organization's `34550::` coordinate, and the home/communities + * page surfaces it here. A `hidden` label on the same coord always wins — + * even if `featured` is set, a hidden org never reaches this list. + * + * Coords are grouped by author into one filter per unique author, then OR'd + * into a single `nostr.query` call. For the typical case (a small handful + * of featured orgs across a few authors) this stays one relay round-trip. + * Results are sorted by the `created_at` of the latest `featured` label so + * moderators control ordering by simply re-publishing the label to bump an + * org to the top. */ export function useFeaturedOrganizations() { const { nostr } = useNostr(); + const { data: moderation, isReady: moderationReady } = useOrganizationModeration(); + + // Derive the curated coord set: featured minus hidden, sorted by the + // recency of the `featured` label. No cap — the moderator pack controls + // how many orgs surface. + const featuredCoords = moderationReady + ? Array.from(moderation.featuredCoords) + .filter((coord) => !moderation.hiddenCoords.has(coord)) + .sort( + (a, b) => + (moderation.featuredOrder.get(b) ?? 0) - + (moderation.featuredOrder.get(a) ?? 0), + ) + : []; + + // Include the coord set in the query key so the cache busts whenever the + // curation changes (mutations invalidate `featured-organizations` too, so + // there's no one-tick gap). + const featuredCoordsKey = featuredCoords.join('|'); return useQuery({ - queryKey: ['featured-organizations', FEATURED_ORGANIZATION_AUTHORS.join(',')], + queryKey: ['featured-organizations', featuredCoordsKey], + enabled: moderationReady, queryFn: async ({ signal }) => { - const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]); + if (featuredCoords.length === 0) return []; - const events = await nostr.query( - [{ + // Group coord d-tags by author so we can issue one filter per author + // instead of one per coord. Most featured orgs cluster around a few + // founders, so this typically collapses to a single-digit number of + // filters in one round-trip. + const dTagsByAuthor = new Map(); + for (const coord of featuredCoords) { + const parsed = parseCoord(coord); + if (!parsed) continue; + const bucket = dTagsByAuthor.get(parsed.pubkey); + if (bucket) { + bucket.push(parsed.dTag); + } else { + dTagsByAuthor.set(parsed.pubkey, [parsed.dTag]); + } + } + if (dTagsByAuthor.size === 0) return []; + + const filters: NostrFilter[] = Array.from(dTagsByAuthor.entries()).map( + ([pubkey, dTags]) => ({ kinds: [COMMUNITY_DEFINITION_KIND], - authors: [...FEATURED_ORGANIZATION_AUTHORS], - limit: 60, - }], - { signal: combinedSignal }, + authors: [pubkey], + '#d': dTags, + }), ); - const entries: FeaturedOrganization[] = []; + const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]); + const events = await nostr.query(filters, { signal: combinedSignal }); + + // Latest-wins dedupe of addressable revisions, then index by coord so + // we can return them in the moderator-controlled `featuredOrder`. + const byCoord = new Map(); for (const event of dedupeAddressableLatest(events)) { const community = parseCommunityEvent(event); if (!community) continue; - entries.push({ community, event }); + byCoord.set(community.aTag, { community, event }); } - entries.sort((a, b) => b.event.created_at - a.event.created_at); - - return entries; + // Preserve the moderator's chosen ordering by walking `featuredCoords` + // (already sorted newest-label-first) and emitting entries in that + // order. Drops coords whose underlying 34550 event we couldn't fetch + // (e.g. it was deleted or never reached the queried relays). + const ordered: FeaturedOrganization[] = []; + for (const coord of featuredCoords) { + const entry = byCoord.get(coord); + if (entry) ordered.push(entry); + } + return ordered; }, - // 5 minutes — featured list is hand-curated, doesn't churn. - staleTime: 5 * 60_000, + // Short — featured rows churn whenever moderators act, and the + // moderation hook invalidates this key on mutation anyway. Keep the + // staleness floor tight so background refetches catch external updates. + staleTime: 30_000, }); } diff --git a/src/hooks/useOrganizationModeration.ts b/src/hooks/useOrganizationModeration.ts new file mode 100644 index 00000000..81d1b80e --- /dev/null +++ b/src/hooks/useOrganizationModeration.ts @@ -0,0 +1,111 @@ +import { useNostr } from '@nostrify/react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { useNostrPublish } from './useNostrPublish'; +import { useCampaignModerators } from './useCampaignModerators'; +import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils'; +import { + AGORA_MODERATION_NAMESPACE, + EMPTY_MODERATION_DATA, + LABEL_KIND, + type ModerationData, + type ModerationLabel, + foldModerationLabels, +} from '@/lib/agoraModeration'; + +/** Surface-scoped alias so call sites read naturally. */ +export type OrganizationModerationData = ModerationData; + +/** + * Fetches and folds organization-moderation label events authored by Team + * Soapbox members. Returns approval / hide / featured rollups per community + * coordinate (`34550::`). + * + * Organizations ride the same `agora.moderation` namespace and the same + * moderator pack as campaigns; we just narrow the fold to labels whose `a` + * tag points at a kind 34550 coordinate. The relay-side query is identical + * to the campaign side (we fetch every namespace-tagged label authored by + * moderators) — the surface separation is purely client-side. + * + * **Display rule** consumers should follow: + * - Featured shelf on `/communities` iff + * `featuredCoords.has(coord) && !hiddenCoords.has(coord)`. + * - Future "All organizations" / discovery surfaces iff + * `!hiddenCoords.has(coord)` for non-moderators; moderators may see hidden + * orgs with a dimmed treatment. + * - "My organizations" intentionally ignores moderation — a user's own + * founded / moderated / followed orgs always render regardless of label. + * - Featured is independent of Approved at the protocol level; hide always + * wins over both. + * + * The mutation `moderate({ coord, action })` publishes a single kind 1985 + * event labeling one organization in the `agora.moderation` namespace. + * Callers MUST be in the moderator set or the relay-side `authors:` filter + * on read will silently ignore the new event. + */ +export function useOrganizationModeration() { + const { nostr } = useNostr(); + const queryClient = useQueryClient(); + const { mutateAsync: publishEvent } = useNostrPublish(); + const { data: moderators } = useCampaignModerators(); + + // Same gating as the campaign hook: never fire with an empty `authors:` + // filter, since that would return labels from any author and break the + // trust model. + const moderatorsKey = moderators ? [...moderators].sort().join(',') : ''; + + const moderationQuery = useQuery({ + queryKey: ['organization-moderation', moderatorsKey], + enabled: moderators !== undefined, + queryFn: async ({ signal }): Promise => { + if (!moderators || moderators.length === 0) { + return { ...EMPTY_MODERATION_DATA, moderators: [] }; + } + const events = await nostr.query( + [ + { + kinds: [LABEL_KIND], + authors: moderators, + '#L': [AGORA_MODERATION_NAMESPACE], + limit: 2000, + }, + ], + { signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) }, + ); + return foldModerationLabels(events, moderators, COMMUNITY_DEFINITION_KIND); + }, + staleTime: 30_000, + }); + + const moderate = useMutation({ + mutationFn: async ({ coord, action }: { coord: string; action: ModerationLabel }) => { + if (!coord.startsWith(`${COMMUNITY_DEFINITION_KIND}:`)) { + throw new Error(`Coordinate must start with ${COMMUNITY_DEFINITION_KIND}:`); + } + return publishEvent({ + kind: LABEL_KIND, + content: '', + tags: [ + ['L', AGORA_MODERATION_NAMESPACE], + ['l', action, AGORA_MODERATION_NAMESPACE], + ['a', coord], + ['alt', `Organization moderation: ${action}`], + ], + }); + }, + onSuccess: () => { + // Invalidate both the moderation rollup and the derived featured + // query so the grid reflects the new state immediately. + queryClient.invalidateQueries({ queryKey: ['organization-moderation'] }); + queryClient.invalidateQueries({ queryKey: ['featured-organizations'] }); + }, + }); + + return { + data: moderationQuery.data ?? EMPTY_MODERATION_DATA, + isPending: moderationQuery.isPending, + isLoading: moderationQuery.isLoading, + isReady: moderationQuery.isSuccess, + moderate, + }; +} diff --git a/src/lib/agoraModeration.ts b/src/lib/agoraModeration.ts new file mode 100644 index 00000000..53de7851 --- /dev/null +++ b/src/lib/agoraModeration.ts @@ -0,0 +1,152 @@ +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Shared building blocks for Agora's moderation labels (NIP-32 kind 1985 in + * the `agora.moderation` namespace). Both campaigns (kind 33863) and + * organizations (kind 34550) ride the same label stream and the same + * moderator pack (Team Soapbox); the only thing that varies between them is + * the kind prefix on the `a` tag. + * + * Centralizing the constants, types, and folding logic here keeps the two + * per-surface hooks (`useCampaignModeration`, `useOrganizationModeration`) + * from drifting apart on namespace strings, axis semantics, or the + * surfacing-rule contract documented in NIP.md. + */ + +/** NIP-32 label kind. */ +export const LABEL_KIND = 1985; + +/** Label namespace for Agora's moderation labels. */ +export const AGORA_MODERATION_NAMESPACE = 'agora.moderation'; + +/** The six possible label values in the moderation namespace. */ +export type ModerationLabel = + | 'approved' + | 'unapproved' + | 'hidden' + | 'unhidden' + | 'featured' + | 'unfeatured'; + +/** A single label event narrowed to its decision axis. */ +export interface AxisDecision { + /** Latest label observed on this axis. */ + label: ModerationLabel; + /** Author of the latest label. */ + pubkey: string; + /** Created-at of the latest label. */ + createdAt: number; +} + +/** Per-coordinate rollup of approval + hide + featured state. */ +export interface ModerationState { + approval?: AxisDecision; // `approved` or `unapproved` + hide?: AxisDecision; // `hidden` or `unhidden` + featured?: AxisDecision; // `featured` or `unfeatured` +} + +/** + * Per-surface rollup. Keys are the addressable coordinates we filtered for + * (e.g. `33863::` for campaigns or `34550::` for + * organizations). + */ +export interface ModerationData { + /** Map of `::` -> rollup. */ + byCoord: Map; + /** Coordinates where the latest approval label is `approved`. */ + approvedCoords: Set; + /** Coordinates where the latest hide label is `hidden`. */ + hiddenCoords: Set; + /** Coordinates where the latest featured label is `featured`. */ + featuredCoords: Set; + /** + * Map of `coord` -> `created_at` of the latest `featured` label. Used to + * sort featured rows newest-first. + */ + featuredOrder: Map; + /** Pubkeys that were considered moderators when the query ran. */ + moderators: string[]; +} + +export const EMPTY_MODERATION_DATA: ModerationData = { + byCoord: new Map(), + approvedCoords: new Set(), + hiddenCoords: new Set(), + featuredCoords: new Set(), + featuredOrder: new Map(), + moderators: [], +}; + +function isApprovalLabel(value: string): value is 'approved' | 'unapproved' { + return value === 'approved' || value === 'unapproved'; +} + +function isHideLabel(value: string): value is 'hidden' | 'unhidden' { + return value === 'hidden' || value === 'unhidden'; +} + +function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' { + return value === 'featured' || value === 'unfeatured'; +} + +/** + * Fold a flat list of label events into per-coordinate rollups by axis. + * The newest event per `(coord, axis)` wins. + * + * Events are filtered to only those carrying an `a` tag that starts with + * `:` so the campaign and organization label streams never bleed + * into each other even though they share a namespace and signer set. + * + * Events with a value outside the moderation namespace, or with no `l` tag + * in that namespace, are dropped. + */ +export function foldModerationLabels( + events: NostrEvent[], + moderators: string[], + coordKind: number, +): ModerationData { + const coordPrefix = `${coordKind}:`; + const byCoord = new Map(); + + for (const event of events) { + const value = event.tags.find( + ([n, , ns]) => n === 'l' && ns === AGORA_MODERATION_NAMESPACE, + )?.[1]; + if (!value) continue; + const aTag = event.tags.find( + ([n, v]) => n === 'a' && typeof v === 'string' && v.startsWith(coordPrefix), + )?.[1]; + if (!aTag) continue; + + const state = byCoord.get(aTag) ?? {}; + if (isApprovalLabel(value)) { + if (!state.approval || event.created_at > state.approval.createdAt) { + state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at }; + } + } else if (isHideLabel(value)) { + if (!state.hide || event.created_at > state.hide.createdAt) { + state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at }; + } + } else if (isFeaturedLabel(value)) { + if (!state.featured || event.created_at > state.featured.createdAt) { + state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at }; + } + } + byCoord.set(aTag, state); + } + + const approvedCoords = new Set(); + const hiddenCoords = new Set(); + const featuredCoords = new Set(); + const featuredOrder = new Map(); + for (const [coord, state] of byCoord) { + if (state.approval?.label === 'approved') approvedCoords.add(coord); + if (state.hide?.label === 'hidden') hiddenCoords.add(coord); + if (state.featured?.label === 'featured') { + featuredCoords.add(coord); + featuredOrder.set(coord, state.featured.createdAt); + } + } + + return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators }; +} diff --git a/src/pages/CommunitiesPage.tsx b/src/pages/CommunitiesPage.tsx index 922359fb..cafcc406 100644 --- a/src/pages/CommunitiesPage.tsx +++ b/src/pages/CommunitiesPage.tsx @@ -386,8 +386,8 @@ function FeaturedOrganizationsShelf() { return ( } - title="No featured organizations available" - body="The curated list is currently unreachable. Try refreshing in a moment." + title="No featured organizations yet" + body="Agora moderators feature standout organizations here. Check back soon." action={null} /> );