Moderator-curated featured organizations

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:<pubkey>:<d> 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.
This commit is contained in:
mkfain
2026-05-21 21:12:33 -05:00
parent c2fee23582
commit 9fd585ebdd
8 changed files with 619 additions and 166 deletions
+44 -13
View File
@@ -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:<author-pubkey>:<d>` — campaign (kind 33863, see "Open Campaigns" above).
- `34550:<author-pubkey>:<d>` — 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:<author-pubkey>:<organization-d-tag>"],
["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:<pubkey>:<d>`.
- `alt` (NIP-31) — clients without label support will display this string.
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` 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.
---
+152
View File
@@ -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:<pubkey>:<d>` 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` `<Link>`, 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<ModerationLabel | null>(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 (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label="Moderate organization"
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Moderator actions
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isApproved ? (
<DropdownMenuItem onClick={() => runAction('unapproved', 'Removed approval')}>
<ShieldOff className="h-4 w-4 mr-2" />
Unapprove
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Approved
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('approved', 'Approved organization')}>
<ShieldCheck className="h-4 w-4 mr-2" />
Approve
</DropdownMenuItem>
)}
{isHidden ? (
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
<Eye className="h-4 w-4 mr-2" />
Unhide
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Hidden
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => runAction('hidden', 'Hidden')}
className="text-destructive focus:text-destructive"
>
<EyeOff className="h-4 w-4 mr-2" />
Hide
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isFeatured ? (
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
<SparklesIcon className="h-4 w-4 mr-2" />
Unfeature
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Featured
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('featured', 'Featured organization')}>
<Sparkles className="h-4 w-4 mr-2" />
Feature
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
+39 -1
View File
@@ -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 (
<Link
to={`/${naddr}`}
@@ -67,6 +83,28 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
<Users className="size-10 text-primary/40" />
</div>
)}
{/* 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. */}
<div className="absolute top-2 right-2 flex items-center gap-1.5">
{isHidden && (
<Badge
variant="secondary"
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30 h-6 px-1.5 text-[10px]"
>
<EyeOff className="size-3 mr-1" />
Hidden
</Badge>
)}
<CommunityModerationMenu
coord={coord}
organizationName={community.name}
isApproved={isApproved}
isHidden={isHidden}
isFeatured={isFeatured}
/>
</div>
</div>
<div className="flex flex-col gap-2 p-3.5 flex-1">
<h3 className="font-semibold leading-tight text-sm tracking-tight line-clamp-1">
+23 -121
View File
@@ -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:<pubkey>:<d>` -> rollup. */
byCoord: Map<string, CampaignModerationState>;
/** Coordinates where the latest approval label is `approved`. */
approvedCoords: Set<string>;
/** Coordinates where the latest hide label is `hidden`. */
hiddenCoords: Set<string>;
/** Coordinates where the latest featured label is `featured`. */
featuredCoords: Set<string>;
/**
* Map of `coord` -> `created_at` of the latest `featured` label.
* Used to sort the home-page featured row newest-first.
*/
featuredOrder: Map<string, number>;
/** 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<string, CampaignModerationState>();
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<string>();
const hiddenCoords = new Set<string>();
const featuredCoords = new Set<string>();
const featuredOrder = new Map<string, number>();
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<CampaignModerationData> => {
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,
+96 -29
View File
@@ -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:<pubkey>:<d>`) 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:<pubkey>:<d>` 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<FeaturedOrganization[]>({
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<string, string[]>();
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<string, FeaturedOrganization>();
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,
});
}
+111
View File
@@ -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:<pubkey>:<d>`).
*
* 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<OrganizationModerationData> => {
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,
};
}
+152
View File
@@ -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:<pubkey>:<d>` for campaigns or `34550:<pubkey>:<d>` for
* organizations).
*/
export interface ModerationData {
/** Map of `<kind>:<pubkey>:<d>` -> rollup. */
byCoord: Map<string, ModerationState>;
/** Coordinates where the latest approval label is `approved`. */
approvedCoords: Set<string>;
/** Coordinates where the latest hide label is `hidden`. */
hiddenCoords: Set<string>;
/** Coordinates where the latest featured label is `featured`. */
featuredCoords: Set<string>;
/**
* Map of `coord` -> `created_at` of the latest `featured` label. Used to
* sort featured rows newest-first.
*/
featuredOrder: Map<string, number>;
/** 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
* `<coordKind>:` 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<string, ModerationState>();
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<string>();
const hiddenCoords = new Set<string>();
const featuredCoords = new Set<string>();
const featuredOrder = new Map<string, number>();
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 };
}
+2 -2
View File
@@ -386,8 +386,8 @@ function FeaturedOrganizationsShelf() {
return (
<EmptyShelf
icon={<Users className="size-7 text-primary/70" />}
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}
/>
);