Land the missing usePledgeModeration hook and document pledge labels

The moderation menu / overlay / review-queue refactor wired pledges
into the same shared moderation components as campaigns and groups,
and three call sites (ActionsPage, ModerationMenu, ModerationOverlay)
imported usePledgeModeration from @/hooks/usePledgeModeration. The
hook file itself was never staged, so the tree didn't build and any
fresh clone would have failed tsc at those imports.

Add the hook (two-axis model — hide + featured, no approval gate, same
agora.moderation namespace and Team Soapbox moderator pack as
campaigns/organizations) and document the kind 36639 surface in
NIP.md alongside the existing 33863 / 34550 entries so the spec
matches the implementation.

Regression-of: c61e9a06
This commit is contained in:
Chad Curtis
2026-05-25 01:24:10 -05:00
parent b2f8311d5e
commit e33f306a64
2 changed files with 168 additions and 12 deletions
+35 -12
View File
@@ -22,7 +22,7 @@
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
| Campaign Moderation | 33863, 1985, 39089 | Homepage curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster |
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns (all three axes), organizations (hidden + featured), and pledges (hidden + featured). |
| HD Wallet Derivation | — | BIP-39 mnemonic deterministically derived from the user's nsec via HKDF; seeds a BIP-86 Taproot + BIP-352 silent-payment wallet importable into any BIP-39-compatible wallet (see [Agora HD Wallet](#agora-hd-wallet-derivation) below). |
### Agora Content Marker
@@ -488,14 +488,15 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned
### Agora Moderation Labels
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.
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, 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:
Campaigns, organizations, and pledges share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the three 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).
- `36639:<author-pubkey>:<d>` — pledge (kind 36639, see "Pledge" below).
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.
A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. A client surfacing pledges MUST filter to `36639:`. Mixing the streams would let a moderator's `featured` label on a campaign appear to feature an unrelated pledge with the same `d` tag, or any other cross-surface bleed.
#### Namespace
@@ -510,13 +511,13 @@ Each label event carries the namespace twice, per NIP-32:
#### Label values
Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** use only two — `hide` and `featured` — because every Agora-tagged organization is publicly visible by default; there is no approval gate for orgs. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 coordinates, and clients MUST ignore any such labels they receive.
Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** and **pledges** use only two — `hide` and `featured` — because every Agora-tagged organization or pledge is publicly visible by default; there is no approval gate. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 or kind 36639 coordinates, and clients MUST ignore any such labels they receive.
| Axis | Values | Surfaces | Meaning |
|----------|---------------------------|----------------|-------------------------------------------------------------------------|
| approval | `approved`, `unapproved` | campaigns only | `approved` allows the campaign on its discovery surfaces. `unapproved` retracts a previous approval. |
| hide | `hidden`, `unhidden` | both | `hidden` suppresses the campaign/organization everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
| featured | `featured`, `unfeatured` | both | `featured` places the campaign in the hand-picked Featured row on `/`, or the organization in the Featured shelf on `/communities`. `unfeatured` retracts. |
| Axis | Values | Surfaces | Meaning |
|----------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
| approval | `approved`, `unapproved` | campaigns only | `approved` allows the campaign on its discovery surfaces. `unapproved` retracts a previous approval. |
| hide | `hidden`, `unhidden` | campaigns, organizations, pledges | `hidden` suppresses the target everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
| featured | `featured`, `unfeatured` | campaigns, organizations, pledges | `featured` places the target in a hand-picked Featured row. `unfeatured` retracts. |
Surfacing rules (hide always wins):
@@ -536,6 +537,13 @@ Surfacing rules (hide always wins):
- **Moderator-only "Hidden"** — iff hidden.
- **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.
**Pledges**
- **Discovery surfaces on `/pledges`** — non-moderators MUST NOT see `hidden` pledges in the active / upcoming / past sections, the search results grid, or any future browse surface. Moderators MAY opt-in to seeing hidden pledges via a Show-hidden toggle so they can unhide.
- **Author-own surfaces** — a pledge author's own pledges in their profile always render regardless of moderation state. Moderation governs public discovery, not authorship.
- **Direct-URL access** — a pledge's detail page (`/<naddr>`) renders regardless of moderation state. Hidden pledges remain reachable by anyone who has the link; moderation only governs which surfaces enumerate them.
- **Featured** — reserved for a future curated pledge shelf. The `featured` axis is defined for symmetry with campaigns/organizations, and clients MAY use it when implementing such a shelf.
#### Event Structure
```json
@@ -566,12 +574,27 @@ An organization label has the same shape with a kind 34550 `a` tag:
}
```
A pledge label has the same shape with a kind 36639 `a` tag:
```json
{
"kind": 1985,
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "hidden", "agora.moderation"],
["a", "36639:<author-pubkey>:<pledge-d-tag>"],
["alt", "Pledge moderation: hidden"]
]
}
```
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 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.
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization, `36639:<pubkey>:<d>` for a pledge).
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured`, `Organization moderation: featured`, or `Pledge moderation: hidden`) so non-Agora clients can read it.
#### Trust Model
+133
View File
@@ -0,0 +1,133 @@
import { useNostr } from '@nostrify/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from './useNostrPublish';
import { useCampaignModerators } from './useCampaignModerators';
import {
AGORA_MODERATION_NAMESPACE,
EMPTY_MODERATION_DATA,
LABEL_KIND,
type ModerationData,
type ModerationLabel,
foldModerationLabels,
} from '@/lib/agoraModeration';
/** Pledge kind. Pinned here to keep this hook decoupled from useActions. */
export const PLEDGE_KIND = 36639;
/** Surface-scoped alias so call sites read naturally. */
type PledgeModerationData = ModerationData;
/**
* Fetches and folds pledge-moderation label events authored by Team
* Soapbox members. Returns hide / featured rollups per pledge coordinate
* (`36639:<pubkey>:<d>`).
*
* Pledges ride the same `agora.moderation` namespace and the same
* moderator pack as campaigns and organizations; we just narrow the fold
* to labels whose `a` tag points at a kind 36639 coordinate. The
* relay-side query is identical to the other two surfaces — surface
* separation is purely client-side.
*
* **Two-axis model.** Like organizations, pledges don't have an
* `approved` axis. Every Agora-tagged pledge is publicly visible by
* default; moderation reduces to `featured` (lift into a curated slot)
* and `hidden` (suppress from public discovery). The shared fold helper
* still tracks `approvedCoords` for type symmetry with the campaign
* hook, but the pledge UI never emits or reads it — moderators SHOULD
* NOT publish `approved` / `unapproved` labels against kind 36639
* coordinates.
*
* **Display rule** consumers should follow:
* - Hide enforcement on `/pledges` and any pledge discovery surface:
* non-moderators MUST NOT see `hidden` pledges. Moderators MAY see
* them via a Show-hidden toggle so they can unhide.
* - A pledge's detail page remains accessible by direct URL regardless
* of moderation state — moderation only governs discovery surfaces.
* - "My pledges" / author-own surfaces intentionally ignore moderation —
* a user's own pledges always render in their own listing.
* - Hide always wins over featured.
*
* The mutation `moderate({ coord, action })` publishes a single kind
* 1985 event labeling one pledge 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 usePledgeModeration() {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
const { data: moderators } = useCampaignModerators();
// Same gating as the other moderation hooks: never fire with an empty
// `authors:` filter, since that would return labels from any author
// and break the trust model (see AGENTS.md `nostr-security`).
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
const moderationQuery = useQuery({
queryKey: ['pledge-moderation', moderatorsKey],
enabled: moderators !== undefined,
queryFn: async ({ signal }): Promise<PledgeModerationData> => {
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, PLEDGE_KIND);
},
// Moderation labels change on human timescales. A generous staleTime
// keeps repeat visits to /pledges instant; the `moderate` mutation
// below explicitly invalidates so local changes are immediate.
staleTime: 5 * 60_000,
gcTime: 60 * 60_000,
});
const moderate = useMutation({
mutationFn: async ({ coord, action }: { coord: string; action: ModerationLabel }) => {
if (!coord.startsWith(`${PLEDGE_KIND}:`)) {
throw new Error(`Coordinate must start with ${PLEDGE_KIND}:`);
}
// Pledges use a two-axis model — only `featured` / `unfeatured` /
// `hidden` / `unhidden` are valid here. Reject `approved` /
// `unapproved` defensively so a stray UI bug can't poison the
// label stream with axis-mixed events.
if (action === 'approved' || action === 'unapproved') {
throw new Error(`Pledges do not support the ${action} label`);
}
return publishEvent({
kind: LABEL_KIND,
content: '',
tags: [
['L', AGORA_MODERATION_NAMESPACE],
['l', action, AGORA_MODERATION_NAMESPACE],
['a', coord],
['alt', `Pledge moderation: ${action}`],
],
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pledge-moderation'] });
// Discovery queries that paint /pledges read from these caches;
// invalidate so the change reflects immediately.
queryClient.invalidateQueries({ queryKey: ['actions'] });
queryClient.invalidateQueries({ queryKey: ['organization-activity'] });
},
});
return {
data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
isPending: moderationQuery.isPending,
isLoading: moderationQuery.isLoading,
isReady: moderationQuery.isSuccess,
moderate,
};
}