Files
eranos/src/hooks/usePledgeModeration.ts
T
mkfain b0759402cf Retire the approval axis; Featured becomes the sole positive-curation mechanism
Now that moderators can directly order the Featured row, the second
"Community Campaigns" bucket (approved + not-featured + not-hidden)
is redundant. This commit removes the approval axis end-to-end and
collapses the home page to a single curated section.

Protocol (NIP.md):

- `ModerationLabel` shrinks from six values to four — `hidden`,
  `unhidden`, `featured`, `unfeatured`. The legacy `approved` /
  `unapproved` labels are now ignored on read and MUST NOT be
  published.
- `ModerationAxis` shrinks from three to two: `hide` and `featured`,
  both supported by all three surfaces (campaigns, organizations,
  pledges).
- The rank tag now only applies to `featured` labels.
- A migration note in NIP.md explains the retirement and tells
  clients to ignore lingering approval-axis labels in relay
  archives.

UI:

- CampaignsPage drops the Community Campaigns and Pending sections.
  Home is now Featured (with the empty state in place when nothing
  is featured) → Browse-all link → moderator-only Hidden section.
  The labeled-coord targeted fetch shrinks to hidden coords only.
- ModerationMenu loses the Approve / Unapprove rows and the
  `hasApproval` / `isApproved` plumbing.
- `CampaignCard`'s `axes` prop drops `'approval'`.
- `ReorderAxis` collapses to a single axis — the type and the
  parameter are removed from the reorder hook, provider, context,
  and grid component since every reorder targets the featured axis.
- Pledge and organization moderation hooks lose their defensive
  `'approved' | 'unapproved'` rejection branches now that those
  values are off the `ModerationLabel` union.

i18n (16 locales):

- Five moderation.menu keys removed: `approve`, `unapprove`,
  `approvedState`, `toastApproved`, `toastUnapproved`.
- Five campaigns.home keys removed: `community`, `communityDesc`,
  `pending`, `pendingDesc`, `pendingEmpty`.
- `campaigns.home.yourCampaignsDesc` rewritten across every locale
  to drop the "appears on the homepage once a moderator approves"
  copy; new copy points authors at /campaigns for discovery and
  notes that the team curates a featured selection on the home page.

Test suite green: tsc, eslint, vitest, vite build all pass.
2026-05-30 22:48:21 +02:00

133 lines
5.2 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from './useNostrPublish';
import { useCampaignModerators } from './useCampaignModerators';
import { DITTO_RELAY } from '@/lib/appRelays';
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;
interface UsePledgeModerationOptions {
/** Restrict moderation lookup to known pledge coordinates. */
coordinates?: string[];
/** Allows pages to wait until their coordinate list has loaded. */
enabled?: boolean;
}
/**
* 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.
*
* **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({ coordinates, enabled = true }: UsePledgeModerationOptions = {}) {
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 coordinatesKey = coordinates ? [...coordinates].sort().join(',') : undefined;
const moderationQuery = useQuery({
queryKey: ['pledge-moderation', moderatorsKey, coordinatesKey],
enabled: enabled && moderators !== undefined,
queryFn: async ({ signal }): Promise<PledgeModerationData> => {
if (!moderators || moderators.length === 0) {
return { ...EMPTY_MODERATION_DATA, moderators: [] };
}
if (coordinates && coordinates.length === 0) {
return { ...EMPTY_MODERATION_DATA, moderators };
}
const filter = {
kinds: [LABEL_KIND],
authors: moderators,
'#L': [AGORA_MODERATION_NAMESPACE],
...(coordinates ? { '#a': coordinates } : {}),
limit: coordinates ? Math.max(coordinates.length * 6, 100) : 2000,
};
const relay = nostr.relay(DITTO_RELAY);
const events = await relay.query(
[filter],
{ 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}:`);
}
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: ['agora-actions'] });
queryClient.invalidateQueries({ queryKey: ['organization-activity'] });
},
});
return {
data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
isPending: moderationQuery.isPending,
isLoading: moderationQuery.isLoading,
isReady: moderationQuery.isSuccess,
moderate,
};
}