SkateSpots/src/hooks/useCrewModeration.ts
shakespeare.diy f5483a0dbc Add crew admin moderation + invite actions
- useCrewModeration: fetch NIP-32 `hidden` labels authored by founder+admins for a crew; useHideCrewPost publishes a kind-1985 hidden label (L/l org.skatespots.moderation, e=post, a=crew)
- CrewDetailPage feed: moderators can hide posts via an EyeOff button + confirm dialog; hidden posts filtered from the feed for everyone, shown greyed/"Hidden from feed" to moderators (reversible/auditable)
- CrewInviteDialog: founder+admins share an invite link/message (native share + copy); founder gets a shortcut to add a member key in crew settings
- Gate all admin actions on useCrewRole().canManage; moderators = founder + delegated admins
- Add MODERATION_LABEL_NAMESPACE/HIDDEN constants
- NIP.md: document feed moderation label format (author must be founder/admin) + invite semantics

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 17:01:28 +07:00

108 lines
3.3 KiB
TypeScript

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import {
MODERATION_LABEL_NAMESPACE,
MODERATION_LABEL_HIDDEN,
} from '@/lib/types';
interface UseCrewModerationParams {
/** Crew addressable coordinate `kind:pubkey:d`. */
crewCoord: string;
/** Pubkeys allowed to moderate: the founder + delegated admins. */
moderators: string[];
enabled?: boolean;
}
/**
* Fetch the set of crew-feed post IDs hidden by the crew's moderators
* (founder + admins) via NIP-32 `hidden` labels.
*
* Only labels authored by a listed moderator are honored, so a random user
* can't hide someone else's post.
*/
export function useCrewModeration({
crewCoord,
moderators,
enabled = true,
}: UseCrewModerationParams) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['crew-moderation', crewCoord, moderators.slice().sort().join(','), getQueryKeySuffix()],
queryFn: async (): Promise<Set<string>> => {
if (moderators.length === 0) return new Set<string>();
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[
{
kinds: [1985],
authors: moderators,
'#a': [crewCoord],
'#l': [MODERATION_LABEL_HIDDEN],
limit: 200,
},
],
{ signal },
);
const modSet = new Set(moderators.map((m) => m.toLowerCase()));
const hidden = new Set<string>();
for (const ev of events) {
// Validate namespace + label + moderator authorship.
const hasNamespace = ev.tags.some(
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
);
const hasLabel = ev.tags.some(
([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN,
);
const refsCrew = ev.tags.some(([n, v]) => n === 'a' && v === crewCoord);
if (!hasNamespace || !hasLabel || !refsCrew) continue;
if (!modSet.has(ev.pubkey.toLowerCase())) continue;
for (const [n, v] of ev.tags) {
if (n === 'e' && v) hidden.add(v);
}
}
return hidden;
},
enabled: enabled && moderators.length > 0 && !!crewCoord,
staleTime: 60 * 1000,
});
}
interface HidePostVars {
postId: string;
/** Author of the post being hidden (added as a `p` hint). */
postAuthor?: string;
}
/**
* Publish a NIP-32 `hidden` moderation label for a crew-feed post.
* The caller must be a crew moderator (gate on `useCrewRole().canManage`).
*/
export function useHideCrewPost(crewCoord: string) {
const { mutateAsync: publish } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ postId, postAuthor }: HidePostVars) => {
const tags: string[][] = [
['L', MODERATION_LABEL_NAMESPACE],
['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE],
['e', postId],
['a', crewCoord],
['alt', 'Crew moderation: post hidden'],
];
if (postAuthor) tags.push(['p', postAuthor]);
await publish({ kind: 1985, content: '', tags });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-moderation', crewCoord] });
},
});
}