b0759402cf
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.
133 lines
5.2 KiB
TypeScript
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,
|
|
};
|
|
}
|