From c2179fef2b96f4d3f69b10ba9b90e773ee84b322 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 12 Jun 2026 15:35:56 -0500 Subject: [PATCH] Let verifiers reach the campaign verify action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Verify this campaign' control was gated on the hardcoded moderator pack. Extend eligibility to self-declared verifiers (accounts with a kind 14672 verifier statement): useCampaignVerifications now exposes canVerify (moderator OR verifier) and its verify mutation accepts verifiers. The campaign moderation kebab (ModerationMenu / ModerationOverlay) now renders for verifiers too, but shows only the verify row — the moderator-only items (Hide, Add to list) and the Hidden badge stay gated on moderator status. Pledge and group surfaces are unchanged. --- src/components/moderation/ModerationMenu.tsx | 89 ++++++++++++------- .../moderation/ModerationOverlay.tsx | 77 +++++++++++++--- src/hooks/useCampaignVerifications.ts | 25 ++++-- 3 files changed, 144 insertions(+), 47 deletions(-) diff --git a/src/components/moderation/ModerationMenu.tsx b/src/components/moderation/ModerationMenu.tsx index 4d9124c5..ce9dc97e 100644 --- a/src/components/moderation/ModerationMenu.tsx +++ b/src/components/moderation/ModerationMenu.tsx @@ -252,7 +252,7 @@ function CampaignItemsInner(props: { */ onAddToList?: () => void; /** - * Called when the moderator clicks "Verify this campaign". The host + * Called when the user clicks "Verify this campaign". The host * owns the confirmation dialog (same reason as `onAddToList`). When * absent, the verify row is omitted — only hosts that render the * dialog ({@link ModerationMenu}) opt in. @@ -269,11 +269,25 @@ function CampaignItemsInner(props: { // the shared shell because the shell drives the "Add to list…" row // plus Hide / Unhide. // - // Verification is a moderator action like the rest, gated by the same - // pack — so the verify row is rendered as the first row INSIDE the - // moderator section (the `leadingExtra` slot), under the "Moderator - // actions" label. - if (!isMod) return null; + // Verification is available to a broader set than the moderator pack: + // moderators AND self-declared verifiers (kind 14672). The verify row + // ({@link CampaignVerifyItem}) gates itself on `canVerify`, so for a + // verifier who isn't a moderator we render ONLY the verify row — the + // moderator-only shell (Hide / Add to list…) is skipped. + const verifyRow = props.onRequestVerify ? ( + + ) : null; + + if (!isMod) { + // Non-moderators see nothing but the verify row (itself gated on + // `canVerify`). No "Moderator actions" label, no Hide / Add to list. + return <>{verifyRow}; + } + return ( - ) : undefined - } + leadingExtra={verifyRow ?? undefined} /> ); } /** * Verify / remove-verification row for the campaign moderation menu. - * Gated by the moderator pack — renders `null` for non-moderators. + * Gated by {@link useCampaignVerifications}'s `canVerify` — renders for + * moderators AND self-declared verifiers (kind 14672), `null` otherwise. * * Verifying opens a confirmation dialog (owned by {@link ModerationMenu}), * so this row only signals intent via `onRequestVerify`. Removing a @@ -316,9 +323,9 @@ function CampaignVerifyItem({ const { t } = useTranslation(); const { toast } = useToast(); const { user } = useCurrentUser(); - const { data, isModerator, verify, unverify } = useCampaignVerifications(); + const { data, canVerify, verify, unverify } = useCampaignVerifications(); - if (!isModerator) return null; + if (!canVerify) return null; const mine = user ? (data.byCoord.get(coord) ?? []).find((v) => v.pubkey === user.pubkey) @@ -410,6 +417,32 @@ function GroupItemsInner(props: { export function ModerationMenuItems( props: ModerationItemsProps & { onAddToList?: () => void; onRequestVerify?: () => void }, ) { + // The campaign surface has its own visibility rule (moderators OR + // verifiers), computed inside its branch so non-campaign surfaces never + // subscribe to the verification query. CampaignItemsInner returns the + // verify row for verifiers and the full shell for moderators, and `null` + // for everyone else. + if (props.surface === 'campaign') { + return ( + + ); + } + + return ; +} + +/** + * Pledge / group moderator rows. These surfaces are strictly + * moderator-gated (no verifier path), so we keep the early `!isMod` + * bail-out that skips the moderation query for non-moderators. + */ +function NonCampaignModerationItems(props: ModerationItemsProps) { const { user } = useCurrentUser(); const { data: moderators } = useCampaignModerators(); const isMod = !!user && !!moderators && moderators.includes(user.pubkey); @@ -417,16 +450,6 @@ export function ModerationMenuItems( if (!isMod) return null; switch (props.surface) { - case 'campaign': - return ( - - ); case 'pledge': return ( ); + case 'campaign': + // Handled by ModerationMenuItems before reaching here. + return null; } } @@ -475,9 +501,12 @@ export function ModerationMenu({ className, ...rest }: ModerationMenuProps) { const isMod = !!user && !!moderators && moderators.includes(user.pubkey); const [membershipOpen, setMembershipOpen] = useState(false); const [verifyOpen, setVerifyOpen] = useState(false); - const { verify } = useCampaignVerifications(); + const { canVerify, verify } = useCampaignVerifications(); - if (!isMod) return null; + // Campaign kebab is visible to moderators AND verifiers (verifiers see + // only the verify row inside it). Other surfaces stay moderator-only. + const visible = rest.surface === 'campaign' ? isMod || canVerify : isMod; + if (!visible) return null; const onConfirmVerify = async () => { try { diff --git a/src/components/moderation/ModerationOverlay.tsx b/src/components/moderation/ModerationOverlay.tsx index e855251e..9e556729 100644 --- a/src/components/moderation/ModerationOverlay.tsx +++ b/src/components/moderation/ModerationOverlay.tsx @@ -1,5 +1,6 @@ import { useCampaignModeration } from '@/hooks/useCampaignModeration'; import { useCampaignModerators } from '@/hooks/useCampaignModerators'; +import { useCampaignVerifications } from '@/hooks/useCampaignVerifications'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useOrganizationModeration } from '@/hooks/useOrganizationModeration'; import { usePledgeModeration } from '@/hooks/usePledgeModeration'; @@ -47,18 +48,29 @@ function OverlayBody({ axes, badgeSize, showMenu = true, + showHiddenBadge = true, className, -}: Omit & { isHidden: boolean }) { +}: Omit & { + isHidden: boolean; + /** + * Whether to render the "Hidden" badge. The badge is a moderator-only + * concept — a verifier who isn't a moderator gets the kebab (verify + * row) but not the hidden-state chip. Defaults to true. + */ + showHiddenBadge?: boolean; +}) { const wrapperClass = className ?? 'absolute top-2 right-2 z-10 flex items-center gap-1.5'; - // When the menu is suppressed AND nothing is hidden, the overlay - // would render an empty positioned div. Skip render entirely so the - // banner stays clean. - if (!showMenu && !isHidden) return null; + const renderBadge = isHidden && showHiddenBadge; + + // When the menu is suppressed AND nothing is rendered for the badge, + // the overlay would render an empty positioned div. Skip render + // entirely so the banner stays clean. + if (!showMenu && !renderBadge) return null; return (
- {isHidden && } + {renderBadge && } {showMenu && ( ; + const { isMod, ...rest } = props; + return ( + + ); } function PledgeOverlay(props: ModerationOverlayProps) { @@ -96,16 +115,48 @@ function GroupOverlay(props: ModerationOverlayProps) { /** * Absolutely-positioned overlay for cards: bundles the Hidden badge * (when the entity is hidden) and the moderator kebab in a single - * top-right corner. Returns `null` for non-moderators so non-mod grids - * never subscribe to the moderation label query at all. + * top-right corner. Returns `null` for users with nothing to do so + * non-mod grids never subscribe to the moderation label query at all. + * + * The campaign surface additionally surfaces the kebab to **verifiers** + * (accounts with a kind 14672 verifier statement) so they can reach the + * "Verify this campaign" action — see {@link CampaignModerationOverlay}. + * Pledges and groups stay strictly moderator-gated. * * Consistent across campaigns, pledges, and groups — same chip, same - * kebab placement, same moderator gating, same visual order. + * kebab placement, same visual order. * * Card containers must be `relative` for the absolute positioning to * anchor correctly. */ export function ModerationOverlay(props: ModerationOverlayProps) { + if (props.surface === 'campaign') { + return ; + } + return ; +} + +/** + * Campaign overlay gate: visible to moderators (full kebab + hidden + * badge) and to verifiers (kebab carrying only the verify row). Mounts + * the verification query so a verifier's eligibility is resolved. + */ +function CampaignModerationOverlay(props: ModerationOverlayProps) { + const { user } = useCurrentUser(); + const { data: moderators } = useCampaignModerators(); + const { canVerify } = useCampaignVerifications(); + const isMod = !!user && !!moderators && moderators.includes(user.pubkey); + + if (!isMod && !canVerify) return null; + + return ; +} + +/** + * Pledge / group overlay gate: strictly moderator-gated so non-mod grids + * never subscribe to the moderation label query. + */ +function NonCampaignModerationOverlay(props: ModerationOverlayProps) { const { user } = useCurrentUser(); const { data: moderators } = useCampaignModerators(); const isMod = !!user && !!moderators && moderators.includes(user.pubkey); @@ -113,8 +164,10 @@ export function ModerationOverlay(props: ModerationOverlayProps) { if (!isMod) return null; switch (props.surface) { - case 'campaign': return ; case 'pledge': return ; case 'group': return ; + case 'campaign': + // Handled by ModerationOverlay before reaching here. + return null; } } diff --git a/src/hooks/useCampaignVerifications.ts b/src/hooks/useCampaignVerifications.ts index 3ec70231..37e32d4b 100644 --- a/src/hooks/useCampaignVerifications.ts +++ b/src/hooks/useCampaignVerifications.ts @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNostrPublish } from './useNostrPublish'; import { useCurrentUser } from './useCurrentUser'; import { useCampaignModerators } from './useCampaignModerators'; +import { useVerifierStatement } from './useVerifierStatement'; import { CAMPAIGN_KIND } from '@/lib/campaign'; import { LABEL_KIND } from '@/lib/agoraModeration'; import { @@ -43,14 +44,23 @@ export function useCampaignVerifications() { const { user } = useCurrentUser(); const { mutateAsync: publishEvent } = useNostrPublish(); const { data: moderators } = useCampaignModerators(); + const { isVerifier } = useVerifierStatement(user?.pubkey); // Stable key so the query refetches when the moderator set changes. const moderatorsKey = moderators ? [...moderators].sort().join(',') : ''; - // True when the logged-in user is a moderator. Gates the verify / - // unverify controls in the UI. + // True when the logged-in user is a moderator. Moderators sign the + // verification labels that feed the stacked-avatar badge (the read path + // filters by `authors: moderators`). const isModerator = !!user && !!moderators && moderators.includes(user.pubkey); + // True when the logged-in user may verify campaigns: either a moderator + // or a self-declared verifier (someone who published a kind 14672 + // verifier statement). Verifiers' verifications surface on their own + // profile's "Verified" tab (`useVerifiedCampaigns`, scoped by author); + // moderators' additionally power the on-card verification badge. + const canVerify = isModerator || isVerifier; + const verificationQuery = useQuery({ queryKey: ['campaign-verifications', moderatorsKey], // Never fire with an empty `authors:` filter — that would match every @@ -80,8 +90,8 @@ export function useCampaignVerifications() { const verify = useMutation({ mutationFn: async ({ coord }: { coord: string }) => { if (!user) throw new Error('You must be logged in to verify a campaign.'); - if (!moderators?.includes(user.pubkey)) { - throw new Error('Only moderators can verify campaigns.'); + if (!moderators?.includes(user.pubkey) && !isVerifier) { + throw new Error('Only moderators and verifiers can verify campaigns.'); } if (!coord.startsWith(`${CAMPAIGN_KIND}:`)) { throw new Error(`Coordinate must start with ${CAMPAIGN_KIND}:`); @@ -130,8 +140,13 @@ export function useCampaignVerifications() { data: verificationQuery.data ?? EMPTY_VERIFICATION_DATA, isLoading: verificationQuery.isLoading, isReady: verificationQuery.isSuccess, - /** Whether the logged-in user may verify / unverify campaigns. */ + /** Whether the logged-in user is a campaign moderator. */ isModerator, + /** + * Whether the logged-in user may verify / unverify campaigns — true for + * moderators and for self-declared verifiers (kind 14672 statement). + */ + canVerify, verify, unverify, };