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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user