Let verifiers reach the campaign verify action

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.
This commit is contained in:
Alex Gleason
2026-06-12 15:35:56 -05:00
parent d0d315a9b2
commit c2179fef2b
3 changed files with 144 additions and 47 deletions
+59 -30
View File
@@ -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 ? (
<CampaignVerifyItem
coord={props.coord}
entityTitle={props.entityTitle}
onRequestVerify={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 (
<ModerationItemsShell
coord={props.coord}
@@ -282,22 +296,15 @@ function CampaignItemsInner(props: {
moderation={data}
moderate={moderate}
onAddToList={props.onAddToList}
leadingExtra={
props.onRequestVerify ? (
<CampaignVerifyItem
coord={props.coord}
entityTitle={props.entityTitle}
onRequestVerify={props.onRequestVerify}
/>
) : 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 (
<CampaignItemsInner
coord={props.coord}
entityTitle={props.entityTitle}
axes={props.axes}
onAddToList={props.onAddToList}
onRequestVerify={props.onRequestVerify}
/>
);
}
return <NonCampaignModerationItems {...props} />;
}
/**
* 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 (
<CampaignItemsInner
coord={props.coord}
entityTitle={props.entityTitle}
axes={props.axes}
onAddToList={props.onAddToList}
onRequestVerify={props.onRequestVerify}
/>
);
case 'pledge':
return (
<PledgeItemsInner
@@ -443,6 +466,9 @@ export function ModerationMenuItems(
axes={props.axes}
/>
);
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 {
+65 -12
View File
@@ -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<ModerationOverlayProps, never> & { isHidden: boolean }) {
}: Omit<ModerationOverlayProps, never> & {
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 (
<div className={wrapperClass}>
{isHidden && <HiddenBadge size={badgeSize ?? 'compact'} />}
{renderBadge && <HiddenBadge size={badgeSize ?? 'compact'} />}
{showMenu && (
<ModerationMenu
coord={coord}
@@ -78,9 +90,16 @@ function OverlayBody({
// dedicated components keeps the rules of hooks happy.
// ─────────────────────────────────────────────────────────────────────
function CampaignOverlay(props: ModerationOverlayProps) {
function CampaignOverlay(props: ModerationOverlayProps & { isMod: boolean }) {
const { data } = useCampaignModeration();
return <OverlayBody {...props} isHidden={data.hiddenCoords.has(props.coord)} />;
const { isMod, ...rest } = props;
return (
<OverlayBody
{...rest}
isHidden={data.hiddenCoords.has(props.coord)}
showHiddenBadge={isMod}
/>
);
}
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 <CampaignModerationOverlay {...props} />;
}
return <NonCampaignModerationOverlay {...props} />;
}
/**
* 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 <CampaignOverlay {...props} isMod={isMod} />;
}
/**
* 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 <CampaignOverlay {...props} />;
case 'pledge': return <PledgeOverlay {...props} />;
case 'group': return <GroupOverlay {...props} />;
case 'campaign':
// Handled by ModerationOverlay before reaching here.
return null;
}
}
+20 -5
View File
@@ -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,
};