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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user