7f7db43910
Phase 3 of the invalidation cleanup. * useDeleteEvent previously invalidated only ['feed'], ['profile-feed'], ['profile-likes-infinite'], ['replies'], and ['notifications']. A deleted event can sit in many other surfaces — country feeds (agora-feed-paginated / agora-feed-new-posts), comment threads (['nostr', 'comments', ...], ['event-comments', ...], wall-comments), campaign and pledge lists, community / organization activity feeds, trending, and per-event caches. Switch to a predicate that sweeps a curated allow-list of feed-shaped query-key prefixes so the deleted post drops off every visible surface in a single refetch wave. * useCampaignModeration only invalidated its own ['campaign-moderation'] cache. Moderation labels (approve / hide / feature) gate which campaigns surface on the home page, discover shelf, and community grids, so the list queries need refetching too. Cascade to ['campaigns'], ['campaigns-all'], and ['campaigns-all-scores']. ReportDialog (kind 1984) and useRequestToVanish (kind 62) were reviewed and intentionally left alone: ReportDialog has no UI consequence inside Agora (reports only show up to external moderators), and Request to Vanish logs the user out, after which any cached state is cleared anyway.
126 lines
5.2 KiB
TypeScript
126 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 { CAMPAIGN_KIND } from '@/lib/campaign';
|
|
import {
|
|
AGORA_MODERATION_NAMESPACE,
|
|
EMPTY_MODERATION_DATA,
|
|
LABEL_KIND,
|
|
type ModerationData,
|
|
type ModerationLabel,
|
|
type ModerationState,
|
|
foldModerationLabels,
|
|
} from '@/lib/agoraModeration';
|
|
|
|
// 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 type CampaignModerationState = ModerationState;
|
|
|
|
/** Surface-scoped alias so existing callers keep working. */
|
|
export type CampaignModerationData = ModerationData;
|
|
|
|
/**
|
|
* Fetches and folds campaign-moderation label events authored by Team
|
|
* Soapbox members. Returns approval / hide / featured rollups per campaign
|
|
* coordinate.
|
|
*
|
|
* **Display rule** consumers should follow:
|
|
* - Featured row on `/` iff `featuredCoords.has(coord) && !hiddenCoords.has(coord)`.
|
|
* - Community Campaigns grid on `/` iff `approvedCoords.has(coord) && !hiddenCoords.has(coord) && !featuredCoords.has(coord)` (featured dedupe).
|
|
* - Discover shelf iff `approvedCoords.has(coord) && !hiddenCoords.has(coord)`.
|
|
* - "Pending" (moderator-only sections) iff `!approvedCoords.has(coord) && !hiddenCoords.has(coord)`.
|
|
* - "Hidden" (moderator-only sections) iff `hiddenCoords.has(coord)`.
|
|
* - Featured is independent of Approved at the protocol level; hide always wins.
|
|
*
|
|
* The mutation `moderate({ coord, action })` publishes a single kind 1985
|
|
* event labeling one campaign 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 useCampaignModeration() {
|
|
const { nostr } = useNostr();
|
|
const queryClient = useQueryClient();
|
|
const { mutateAsync: publishEvent } = useNostrPublish();
|
|
const { data: moderators } = useCampaignModerators();
|
|
|
|
// The query is gated on `moderators !== undefined` so we never fire it with
|
|
// 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_MODERATION_DATA — no rendering can promote a campaign without
|
|
// a moderator.
|
|
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
|
|
|
|
const moderationQuery = useQuery({
|
|
queryKey: ['campaign-moderation', moderatorsKey],
|
|
enabled: moderators !== undefined,
|
|
queryFn: async ({ signal }): Promise<CampaignModerationData> => {
|
|
if (!moderators || moderators.length === 0) {
|
|
return { ...EMPTY_MODERATION_DATA, moderators: [] };
|
|
}
|
|
const events = await nostr.query(
|
|
[
|
|
{
|
|
kinds: [LABEL_KIND],
|
|
authors: moderators,
|
|
// The capital-L tag is what NIP-32 indexes as the namespace. The
|
|
// lowercase `l` carries the value + namespace as its 2nd/3rd
|
|
// elements. We filter relay-side by namespace, then fold
|
|
// client-side to per-axis decisions.
|
|
'#L': [AGORA_MODERATION_NAMESPACE],
|
|
limit: 2000,
|
|
},
|
|
],
|
|
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
|
|
);
|
|
return foldModerationLabels(events, moderators, CAMPAIGN_KIND);
|
|
},
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const moderate = useMutation({
|
|
mutationFn: async ({ coord, action }: { coord: string; action: ModerationLabel }) => {
|
|
// Quick parse-check on the coord so we don't sign garbage.
|
|
if (!coord.startsWith(`${CAMPAIGN_KIND}:`)) {
|
|
throw new Error(`Coordinate must start with ${CAMPAIGN_KIND}:`);
|
|
}
|
|
return publishEvent({
|
|
kind: LABEL_KIND,
|
|
content: '',
|
|
tags: [
|
|
['L', AGORA_MODERATION_NAMESPACE],
|
|
['l', action, AGORA_MODERATION_NAMESPACE],
|
|
['a', coord],
|
|
['alt', `Campaign moderation: ${action}`],
|
|
],
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['campaign-moderation'] });
|
|
// Moderation decisions (approve / hide / feature) gate which campaigns
|
|
// surface on the home page, discover shelf, and community grids — so
|
|
// the list queries need to refetch too, otherwise the moderator's UI
|
|
// still shows the old approval state until refresh.
|
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
|
queryClient.invalidateQueries({ queryKey: ['campaigns-all'] });
|
|
queryClient.invalidateQueries({ queryKey: ['campaigns-all-scores'] });
|
|
},
|
|
});
|
|
|
|
return {
|
|
data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
|
|
isPending: moderationQuery.isPending,
|
|
isLoading: moderationQuery.isLoading,
|
|
isReady: moderationQuery.isSuccess,
|
|
moderate,
|
|
};
|
|
}
|