- {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,
};