- 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>
108 lines
3.3 KiB
TypeScript
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] });
|
|
},
|
|
});
|
|
}
|