Treat campaign verification as a moderator action
Verification is gated by the existing campaign moderator pack (useCampaignModerators / CAMPAIGN_MODERATORS), not a separate allowlist. - Remove the config.labelers field (AppContext interface, Zod schema, App.tsx and TestApp defaults) and delete useCampaignLabelers. - useCampaignVerifications now reads/writes agora.verified labels gated on the moderator pack (isModerator), same authors filter as the other label streams. - Move the verify / remove-verification row INSIDE the moderation kebab's 'Moderator actions' section (leadingExtra slot of ModerationItemsShell), no longer a top-level item above the section label. - Revert the isMod || isLabeler widening in ModerationMenu/Overlay back to plain isMod. - Remove the trailing 'Verified' checkmark text from 'Remove my verification'. - Rename labeler->moderator in agoraVerification, the badge component, and all locale strings; drop now-unused notVerified / verifiedState keys. - Update NIP.md to document verification as a moderator action.
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
|
||||
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
|
||||
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (hidden + featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns, organizations, and pledges identically. |
|
||||
| Campaign Verification | 33863, 1985 | Positive trust signal: trusted *labeler*-signed NIP-32 labels in the `agora.verified` namespace (value `verified`) vouching for a campaign. Gated by the `labelers` allowlist; retracted via kind 5 deletion. |
|
||||
| Campaign Verification | 33863, 1985 | Positive trust signal: moderator-signed NIP-32 labels in the `agora.verified` namespace (value `verified`) vouching for a campaign. Gated by the same moderator pack as hide/feature; retracted via kind 5 deletion. |
|
||||
| HD Wallet Derivation | — | BIP-39 mnemonic deterministically derived from the user's nsec via HKDF; seeds a BIP-86 Taproot + BIP-352 silent-payment wallet importable into any BIP-39-compatible wallet (see [Agora HD Wallet](#agora-hd-wallet-derivation) below). |
|
||||
|
||||
### Agora Content Marker
|
||||
@@ -698,7 +698,7 @@ Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the r
|
||||
|
||||
#### Campaign Verification Labels (`agora.verified`)
|
||||
|
||||
Separately from the hide/feature moderation axes above, Agora supports a positive **verification** signal: a trusted *labeler* vouches for a specific campaign. Verification is a distinct NIP-32 label namespace, `agora.verified`, with a single value `verified`. It rides the same kind 1985 label kind but is otherwise independent of `agora.moderation` — a different (and typically narrower) signer set, no axes, no rank.
|
||||
Separately from the hide/feature moderation axes above, Agora supports a positive **verification** signal: a campaign moderator vouches for a specific campaign. Verification is a distinct NIP-32 label namespace, `agora.verified`, with a single value `verified`. It rides the same kind 1985 label kind and the **same moderator pack** as the hide/feature labels, but is otherwise independent of `agora.moderation` — no axes, no rank, purely additive.
|
||||
|
||||
A verification label points at one campaign coordinate (`33863:<pubkey>:<d>`):
|
||||
|
||||
@@ -715,26 +715,26 @@ A verification label points at one campaign coordinate (`33863:<pubkey>:<d>`):
|
||||
}
|
||||
```
|
||||
|
||||
**Trust model.** The set of pubkeys whose `agora.verified` labels are honored is the client-configured *labeler* allowlist (`AppConfig.labelers`; default: the Team Soapbox curator pubkey). Clients MUST filter the read query by `authors: <labelers>` — a `verified` label signed by any pubkey outside the allowlist MUST be ignored, otherwise the badge is forgeable by anyone. As with moderation labels, clients MUST NOT run the query with an empty `authors:` filter.
|
||||
**Trust model.** The set of pubkeys whose `agora.verified` labels are honored is the campaign moderator pack — the same allowlist that governs hide/feature labels (the Team Soapbox follow pack `p` tags). Clients MUST filter the read query by `authors: <moderators>` — a `verified` label signed by any pubkey outside the pack MUST be ignored, otherwise the badge is forgeable by anyone. As with moderation labels, clients MUST NOT run the query with an empty `authors:` filter.
|
||||
|
||||
**Reading.** One filter fetches every verification across all labelers:
|
||||
**Reading.** One filter fetches every verification across all moderators:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [1985],
|
||||
"authors": ["<labeler-1>", "<labeler-2>"],
|
||||
"authors": ["<moderator-1>", "<moderator-2>"],
|
||||
"#L": ["agora.verified"],
|
||||
"#l": ["verified"],
|
||||
"limit": 2000
|
||||
}
|
||||
```
|
||||
|
||||
Fold by `(coord, labeler)`, keeping the newest label per pair. A campaign is "verified by" the set of labelers with a surviving label; clients SHOULD render the labelers' avatars stacked as a badge, with multiple labelers forming a stack.
|
||||
Fold by `(coord, moderator)`, keeping the newest label per pair. A campaign is "verified by" the set of moderators with a surviving label; clients SHOULD render the moderators' avatars stacked as a badge, with multiple moderators forming a stack.
|
||||
|
||||
**Retraction.** There is no `unverified` value. A labeler retracts a verification by publishing a NIP-09 kind 5 deletion of their own label event (referenced by `e` tag plus `k: 1985`). A kind 5 only takes effect on events authored by the signer, so a labeler can only remove their own verification.
|
||||
**Retraction.** There is no `unverified` value. A moderator retracts a verification by publishing a NIP-09 kind 5 deletion of their own label event (referenced by `e` tag plus `k: 1985`). A kind 5 only takes effect on events authored by the signer, so a moderator can only remove their own verification.
|
||||
|
||||
**Client behavior.**
|
||||
- Clients SHOULD render verify / remove-verification controls only for a logged-in user whose pubkey appears in the labeler allowlist.
|
||||
- Verification is a moderator action: clients SHOULD render the verify / remove-verification control inside the campaign moderator menu (alongside hide / add-to-list), gated on moderator membership.
|
||||
- Verification is purely additive — it never hides or promotes a campaign on its own. It is a trust hint layered over whatever moderation/discovery state already applies.
|
||||
- The label kind 1985 read is routed to Agora's search relays (`relay.ditto.pub`, `relay.dreamith.to`) where these labels are published.
|
||||
|
||||
|
||||
@@ -146,10 +146,6 @@ const hardcodedConfig: AppConfig = {
|
||||
imageProxy: 'https://wsrv.nl',
|
||||
lowBandwidthMode: false,
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
labelers: [
|
||||
'0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd',
|
||||
'932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
],
|
||||
esploraApis: [
|
||||
'https://mempool.emzy.de/api',
|
||||
'https://mempool.space/api',
|
||||
|
||||
@@ -245,9 +245,10 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
className="absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/30 to-transparent"
|
||||
/>
|
||||
|
||||
{/* Top-left verification badge — stacked labeler avatars for
|
||||
campaigns vouched for by trusted labelers. Renders nothing
|
||||
for unverified campaigns unless the viewer is a labeler. */}
|
||||
{/* Top-left verification badge — stacked moderator avatars for
|
||||
campaigns a moderator has verified. Renders nothing for
|
||||
unverified campaigns. Display-only; the verify action lives in
|
||||
the moderation kebab. */}
|
||||
<CampaignVerificationBadge
|
||||
coord={campaign.aTag}
|
||||
title={campaign.title}
|
||||
|
||||
@@ -19,8 +19,8 @@ function swallow(e: { preventDefault: () => void; stopPropagation: () => void })
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
/** One labeler avatar in the stacked badge. */
|
||||
function LabelerAvatar({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
/** One moderator avatar in the stacked badge. */
|
||||
function ModeratorAvatar({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const picture = sanitizeUrl(metadata?.picture);
|
||||
@@ -35,7 +35,7 @@ function LabelerAvatar({ pubkey, className }: { pubkey: string; className?: stri
|
||||
);
|
||||
}
|
||||
|
||||
/** A single verifier row inside the popover — links to the labeler's profile. */
|
||||
/** A single verifier row inside the popover — links to the moderator's profile. */
|
||||
function VerifierRow({ verification }: { verification: CampaignVerification }) {
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(verification.pubkey);
|
||||
@@ -74,8 +74,8 @@ interface CampaignVerificationBadgeProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only badge: a stack of labeler avatars over a campaign — one
|
||||
* avatar per trusted labeler that has issued an `agora.verified` label for
|
||||
* Display-only badge: a stack of moderator avatars over a campaign — one
|
||||
* avatar per moderator that has issued an `agora.verified` label for
|
||||
* it. Hovering / clicking opens a popover listing the verifiers, each
|
||||
* linking to its profile.
|
||||
*
|
||||
@@ -101,7 +101,7 @@ export function CampaignVerificationBadge({ coord, title, className }: CampaignV
|
||||
|
||||
const triggerLabel = t('campaignVerification.verifiedByCount', {
|
||||
count,
|
||||
defaultValue: 'Verified by {{count}} labeler',
|
||||
defaultValue: 'Verified by {{count}} moderator',
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -119,7 +119,7 @@ export function CampaignVerificationBadge({ coord, title, className }: CampaignV
|
||||
>
|
||||
<span className="flex items-center -space-x-2">
|
||||
{shown.map((v) => (
|
||||
<LabelerAvatar key={v.pubkey} pubkey={v.pubkey} />
|
||||
<ModeratorAvatar key={v.pubkey} pubkey={v.pubkey} />
|
||||
))}
|
||||
</span>
|
||||
<span className="ml-0.5 inline-flex items-center gap-0.5 pr-1 text-xs font-semibold">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BadgeCheck, Check, EyeOff, Eye, ListPlus, MoreHorizontal,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { CampaignListMembershipDialog } from '@/components/campaign-lists/CampaignListMembershipDialog';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCampaignLabelers } from '@/hooks/useCampaignLabelers';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
@@ -99,6 +99,7 @@ function ModerationItemsShell({
|
||||
moderate,
|
||||
getFeatureRank,
|
||||
onAddToList,
|
||||
leadingExtra,
|
||||
}: {
|
||||
coord: string;
|
||||
entityTitle: string;
|
||||
@@ -125,6 +126,14 @@ function ModerationItemsShell({
|
||||
* per-campaign membership modal in {@link CampaignItemsInner}.
|
||||
*/
|
||||
onAddToList?: () => void;
|
||||
/**
|
||||
* Optional extra rows rendered as the first moderator action(s), under
|
||||
* the "Moderator actions" label and above "Add to list…" / Hide. The
|
||||
* campaign surface passes its verify row here so verification reads as
|
||||
* a moderator action inside the same section, not a separate top-level
|
||||
* item.
|
||||
*/
|
||||
leadingExtra?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
@@ -166,6 +175,13 @@ function ModerationItemsShell({
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{leadingExtra && (
|
||||
<>
|
||||
{leadingExtra}
|
||||
{(onAddToList || hasHide || hasFeatured) && <DropdownMenuSeparator />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onAddToList && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => onAddToList()}>
|
||||
@@ -245,37 +261,34 @@ function CampaignItemsInner(props: {
|
||||
// the shared shell because the shell drives the "Add to list…" row
|
||||
// plus Hide / Unhide.
|
||||
//
|
||||
// The verification row is gated separately (by the labeler allowlist,
|
||||
// not the moderator pack), so it's rendered here as a sibling rather
|
||||
// than inside the shell. It sits at the TOP of the menu; the moderator
|
||||
// rows (when present) follow below a separator. The verify row only
|
||||
// renders for labelers, the moderator rows only for mods — the two
|
||||
// sets can differ, and either alone is enough to mount this menu.
|
||||
// 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;
|
||||
return (
|
||||
<>
|
||||
<CampaignVerifyItem
|
||||
coord={props.coord}
|
||||
entityTitle={props.entityTitle}
|
||||
/>
|
||||
{isMod && (
|
||||
<ModerationItemsShell
|
||||
<ModerationItemsShell
|
||||
coord={props.coord}
|
||||
entityTitle={props.entityTitle}
|
||||
axes={props.axes}
|
||||
moderation={data}
|
||||
moderate={moderate}
|
||||
onAddToList={props.onAddToList}
|
||||
leadingExtra={
|
||||
<CampaignVerifyItem
|
||||
coord={props.coord}
|
||||
entityTitle={props.entityTitle}
|
||||
axes={props.axes}
|
||||
moderation={data}
|
||||
moderate={moderate}
|
||||
onAddToList={props.onAddToList}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify / remove-verification row for the campaign moderation menu.
|
||||
* Gated by the labeler allowlist (distinct from the moderator pack) —
|
||||
* renders `null` for everyone else. Publishes an `agora.verified` label
|
||||
* on verify and a kind 5 deletion of the labeler's own label on remove.
|
||||
* Gated by the moderator pack — renders `null` for non-moderators.
|
||||
* Publishes an `agora.verified` label on verify and a kind 5 deletion of
|
||||
* the moderator's own label on remove.
|
||||
*/
|
||||
function CampaignVerifyItem({
|
||||
coord,
|
||||
@@ -287,9 +300,9 @@ function CampaignVerifyItem({
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const { data, isLabeler, verify, unverify } = useCampaignVerifications();
|
||||
const { data, isModerator, verify, unverify } = useCampaignVerifications();
|
||||
|
||||
if (!isLabeler) return null;
|
||||
if (!isModerator) return null;
|
||||
|
||||
const mine = user
|
||||
? (data.byCoord.get(coord) ?? []).find((v) => v.pubkey === user.pubkey)
|
||||
@@ -329,9 +342,6 @@ function CampaignVerifyItem({
|
||||
<DropdownMenuItem onClick={onUnverify} disabled={busy}>
|
||||
<BadgeCheck className="h-4 w-4 mr-2" />
|
||||
{t('campaignVerification.removeVerification')}
|
||||
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> {t('campaignVerification.verifiedState')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={onVerify} disabled={busy}>
|
||||
@@ -399,15 +409,9 @@ export function ModerationMenuItems(
|
||||
) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const labelers = useCampaignLabelers();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
const isLabeler = !!user && labelers.includes(user.pubkey);
|
||||
|
||||
// The campaign surface also exposes a verify row to labelers, who are a
|
||||
// distinct allowlist from the moderator pack — so a labeler who isn't a
|
||||
// moderator still gets the campaign menu. Other surfaces are mod-only.
|
||||
const canShow = props.surface === 'campaign' ? isMod || isLabeler : isMod;
|
||||
if (!canShow) return null;
|
||||
if (!isMod) return null;
|
||||
|
||||
switch (props.surface) {
|
||||
case 'campaign':
|
||||
@@ -463,15 +467,10 @@ export function ModerationMenu({ className, ...rest }: ModerationMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const labelers = useCampaignLabelers();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
const isLabeler = !!user && labelers.includes(user.pubkey);
|
||||
const [membershipOpen, setMembershipOpen] = useState(false);
|
||||
|
||||
// Campaigns mount the menu for labelers too (verify row); other
|
||||
// surfaces stay moderator-only.
|
||||
const canShow = rest.surface === 'campaign' ? isMod || isLabeler : isMod;
|
||||
if (!canShow) return null;
|
||||
if (!isMod) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCampaignLabelers } from '@/hooks/useCampaignLabelers';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
|
||||
@@ -109,15 +108,9 @@ function GroupOverlay(props: ModerationOverlayProps) {
|
||||
export function ModerationOverlay(props: ModerationOverlayProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const labelers = useCampaignLabelers();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
const isLabeler = !!user && labelers.includes(user.pubkey);
|
||||
|
||||
// Campaigns also surface the kebab to labelers (for the verify row),
|
||||
// which are a distinct allowlist from the moderator pack. Other
|
||||
// surfaces remain moderator-only.
|
||||
const canShow = props.surface === 'campaign' ? isMod || isLabeler : isMod;
|
||||
if (!canShow) return null;
|
||||
if (!isMod) return null;
|
||||
|
||||
switch (props.surface) {
|
||||
case 'campaign': return <CampaignOverlay {...props} />;
|
||||
|
||||
@@ -290,18 +290,6 @@ export interface AppConfig {
|
||||
lowBandwidthMode: boolean;
|
||||
/** Hex pubkey of the curator whose follow list defines the curated feed. */
|
||||
curatorPubkey?: string;
|
||||
/**
|
||||
* Hex pubkeys trusted to issue `agora.verified` campaign-verification labels
|
||||
* (NIP-32 kind 1985, value `verified`). A labeler's avatar is shown as a
|
||||
* badge over campaigns it has verified; multiple labelers stack. When a
|
||||
* labeler is logged in they gain verify / unverify controls.
|
||||
*
|
||||
* This is a trust allowlist — the verification query filters by
|
||||
* `authors: labelers`, so a `verified` label from anyone outside this list
|
||||
* is never read. Default: the same Team Soapbox curator pubkey used
|
||||
* elsewhere.
|
||||
*/
|
||||
labelers: string[];
|
||||
/**
|
||||
* Ordered list of base URLs for Esplora-compatible Bitcoin REST APIs. Used
|
||||
* by the wallet, on-chain zap flows, and NIP-73 Bitcoin tx/address pages.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppContext } from './useAppContext';
|
||||
|
||||
/**
|
||||
* Returns the hex pubkeys trusted to issue `agora.verified` campaign
|
||||
* verification labels, sourced from {@link AppConfig.labelers}.
|
||||
*
|
||||
* These are the only pubkeys whose kind 1985 `agora.verified` labels are
|
||||
* read and rendered as a verification badge (see
|
||||
* {@link useCampaignVerifications}). The list is configurable via
|
||||
* `agora.json`; the default is the Team Soapbox curator pubkey.
|
||||
*/
|
||||
export function useCampaignLabelers(): string[] {
|
||||
const { config } = useAppContext();
|
||||
return useMemo(() => config.labelers ?? [], [config.labelers]);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useCampaignLabelers } from './useCampaignLabelers';
|
||||
import { useCampaignModerators } from './useCampaignModerators';
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import { LABEL_KIND } from '@/lib/agoraModeration';
|
||||
import {
|
||||
@@ -17,45 +17,54 @@ import {
|
||||
|
||||
/**
|
||||
* Fetches and folds campaign **verification** label events (NIP-32 kind
|
||||
* 1985 in the `agora.verified` namespace) authored by the configured
|
||||
* labeler allowlist ({@link useCampaignLabelers}). Returns a per-coordinate
|
||||
* map of which labelers have verified each campaign — the UI stacks their
|
||||
* avatars into a badge.
|
||||
* 1985 in the `agora.verified` namespace) authored by the campaign
|
||||
* moderators ({@link useCampaignModerators}). Returns a per-coordinate
|
||||
* map of which moderators have verified each campaign — the UI stacks
|
||||
* their avatars into a badge.
|
||||
*
|
||||
* The mutations let a logged-in labeler vouch for or retract verification:
|
||||
* Verification is a moderator action, gated by the same moderator pack
|
||||
* that governs hide / feature labels. It just rides a different NIP-32
|
||||
* namespace (`agora.verified`) so it's an independent, additive trust
|
||||
* signal rather than a discovery decision.
|
||||
*
|
||||
* The mutations let a logged-in moderator vouch for or retract verification:
|
||||
* - `verify({ coord })` publishes a kind 1985 label in the verified namespace.
|
||||
* - `unverify({ event })` publishes a NIP-09 kind 5 deletion of that
|
||||
* labeler's own prior label event.
|
||||
* moderator's own prior label event.
|
||||
*
|
||||
* As with moderation labels, the read query filters by `authors: labelers`,
|
||||
* so a `verified` label signed by anyone outside the allowlist is ignored —
|
||||
* the verification badge can never be forged by an untrusted pubkey.
|
||||
* As with moderation labels, the read query filters by `authors:
|
||||
* moderators`, so a `verified` label signed by anyone outside the pack is
|
||||
* ignored — the verification badge can never be forged by an untrusted
|
||||
* pubkey.
|
||||
*/
|
||||
export function useCampaignVerifications() {
|
||||
const { nostr } = useNostr();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const labelers = useCampaignLabelers();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
|
||||
// Stable key so the query refetches when the labeler set changes.
|
||||
const labelersKey = [...labelers].sort().join(',');
|
||||
// Stable key so the query refetches when the moderator set changes.
|
||||
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
|
||||
|
||||
// True when the logged-in user is an authorized labeler. Gates the
|
||||
// verify / unverify controls in the UI.
|
||||
const isLabeler = !!user && labelers.includes(user.pubkey);
|
||||
// True when the logged-in user is a moderator. Gates the verify /
|
||||
// unverify controls in the UI.
|
||||
const isModerator = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
const verificationQuery = useQuery({
|
||||
queryKey: ['campaign-verifications', labelersKey],
|
||||
queryKey: ['campaign-verifications', moderatorsKey],
|
||||
// Never fire with an empty `authors:` filter — that would match every
|
||||
// `agora.verified` label from any author and break the trust model.
|
||||
enabled: labelers.length > 0,
|
||||
enabled: moderators !== undefined,
|
||||
queryFn: async ({ signal }): Promise<VerificationData> => {
|
||||
if (!moderators || moderators.length === 0) {
|
||||
return { ...EMPTY_VERIFICATION_DATA, moderators: [] };
|
||||
}
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{
|
||||
kinds: [LABEL_KIND],
|
||||
authors: labelers,
|
||||
authors: moderators,
|
||||
'#L': [AGORA_VERIFIED_NAMESPACE],
|
||||
'#l': [AGORA_VERIFIED_VALUE],
|
||||
limit: 2000,
|
||||
@@ -63,7 +72,7 @@ export function useCampaignVerifications() {
|
||||
],
|
||||
{ signal },
|
||||
);
|
||||
return foldVerificationLabels(events, labelers, CAMPAIGN_KIND);
|
||||
return foldVerificationLabels(events, moderators, CAMPAIGN_KIND);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -71,8 +80,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 (!labelers.includes(user.pubkey)) {
|
||||
throw new Error('Only authorized labelers can verify campaigns.');
|
||||
if (!moderators?.includes(user.pubkey)) {
|
||||
throw new Error('Only moderators can verify campaigns.');
|
||||
}
|
||||
if (!coord.startsWith(`${CAMPAIGN_KIND}:`)) {
|
||||
throw new Error(`Coordinate must start with ${CAMPAIGN_KIND}:`);
|
||||
@@ -97,7 +106,7 @@ export function useCampaignVerifications() {
|
||||
mutationFn: async ({ verification }: { verification: CampaignVerification }) => {
|
||||
if (!user) throw new Error('You must be logged in to unverify a campaign.');
|
||||
if (verification.pubkey !== user.pubkey) {
|
||||
// A labeler can only retract their own verification — a kind 5
|
||||
// A moderator can only retract their own verification — a kind 5
|
||||
// deletion only takes effect on events the signer authored.
|
||||
throw new Error('You can only remove your own verification.');
|
||||
}
|
||||
@@ -122,7 +131,7 @@ export function useCampaignVerifications() {
|
||||
isLoading: verificationQuery.isLoading,
|
||||
isReady: verificationQuery.isSuccess,
|
||||
/** Whether the logged-in user may verify / unverify campaigns. */
|
||||
isLabeler,
|
||||
isModerator,
|
||||
verify,
|
||||
unverify,
|
||||
};
|
||||
|
||||
@@ -7,20 +7,21 @@ import { LABEL_KIND } from '@/lib/agoraModeration';
|
||||
* kind 1985 stream in the `agora.verified` namespace, distinct from the
|
||||
* `agora.moderation` namespace used for hide / feature decisions.
|
||||
*
|
||||
* A verification is a positive trust signal: a trusted labeler (configured
|
||||
* in `AppConfig.labelers`) publishes a kind 1985 event with
|
||||
* `["L", "agora.verified"]`, `["l", "verified", "agora.verified"]`, and an
|
||||
* `["a", "33863:<pubkey>:<d>"]` tag pointing at the campaign it vouches for.
|
||||
* A verification is a positive trust signal: a campaign moderator (a member
|
||||
* of the same moderator pack that governs hide / feature labels) publishes
|
||||
* a kind 1985 event with `["L", "agora.verified"]`,
|
||||
* `["l", "verified", "agora.verified"]`, and an `["a", "33863:<pubkey>:<d>"]`
|
||||
* tag pointing at the campaign it vouches for.
|
||||
*
|
||||
* Multiple labelers can verify the same campaign; the UI stacks their
|
||||
* avatars into a badge. A labeler retracts a verification by issuing a
|
||||
* Multiple moderators can verify the same campaign; the UI stacks their
|
||||
* avatars into a badge. A moderator retracts a verification by issuing a
|
||||
* kind 5 deletion of their own label event (NIP-09) — there is no
|
||||
* "unverified" value, the label simply ceases to exist.
|
||||
*
|
||||
* The read path filters by `authors: labelers`, so labels from anyone
|
||||
* outside the configured allowlist never reach the fold. This is the same
|
||||
* trust model used by the moderation labels (see `agoraModeration.ts`),
|
||||
* just with its own namespace and a separate, narrower signer set.
|
||||
* The read path filters by `authors: moderators`, so labels from anyone
|
||||
* outside the moderator pack never reach the fold. This is the same trust
|
||||
* model — and the same signer set — as the moderation labels (see
|
||||
* `agoraModeration.ts`), just on its own additive namespace.
|
||||
*/
|
||||
|
||||
/** NIP-32 label kind, re-exported for verification call sites. */
|
||||
@@ -34,9 +35,9 @@ export const AGORA_VERIFIED_VALUE = 'verified';
|
||||
|
||||
/** A single verification observed for one campaign coordinate. */
|
||||
export interface CampaignVerification {
|
||||
/** Hex pubkey of the labeler who issued the verification. */
|
||||
/** Hex pubkey of the moderator who issued the verification. */
|
||||
pubkey: string;
|
||||
/** The labeler's own label event (kind 1985). Needed to delete it. */
|
||||
/** The moderator's own label event (kind 1985). Needed to delete it. */
|
||||
event: NostrEvent;
|
||||
/** `created_at` of the label event. */
|
||||
createdAt: number;
|
||||
@@ -46,13 +47,13 @@ export interface CampaignVerification {
|
||||
export interface VerificationData {
|
||||
/** Map of `33863:<pubkey>:<d>` -> verifications, ordered oldest-first. */
|
||||
byCoord: Map<string, CampaignVerification[]>;
|
||||
/** Pubkeys that were considered labelers when the query ran. */
|
||||
labelers: string[];
|
||||
/** Pubkeys that were considered moderators when the query ran. */
|
||||
moderators: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_VERIFICATION_DATA: VerificationData = {
|
||||
byCoord: new Map(),
|
||||
labelers: [],
|
||||
moderators: [],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -63,16 +64,16 @@ export const EMPTY_VERIFICATION_DATA: VerificationData = {
|
||||
* `agora.verified` namespace and an `a` tag whose coordinate starts with
|
||||
* `<coordKind>:` — so the verification stream never bleeds across kinds.
|
||||
*
|
||||
* Each `(coord, labeler)` pair keeps the newest event; a labeler who
|
||||
* Each `(coord, moderator)` pair keeps the newest event; a moderator who
|
||||
* republishes simply refreshes their own entry rather than stacking twice.
|
||||
*/
|
||||
export function foldVerificationLabels(
|
||||
events: NostrEvent[],
|
||||
labelers: string[],
|
||||
moderators: string[],
|
||||
coordKind: number,
|
||||
): VerificationData {
|
||||
const coordPrefix = `${coordKind}:`;
|
||||
// coord -> (labeler pubkey -> verification)
|
||||
// coord -> (moderator pubkey -> verification)
|
||||
const byCoordMap = new Map<string, Map<string, CampaignVerification>>();
|
||||
|
||||
for (const event of events) {
|
||||
@@ -86,23 +87,23 @@ export function foldVerificationLabels(
|
||||
)?.[1];
|
||||
if (!aTag) continue;
|
||||
|
||||
const perLabeler = byCoordMap.get(aTag) ?? new Map<string, CampaignVerification>();
|
||||
const existing = perLabeler.get(event.pubkey);
|
||||
const perModerator = byCoordMap.get(aTag) ?? new Map<string, CampaignVerification>();
|
||||
const existing = perModerator.get(event.pubkey);
|
||||
if (!existing || event.created_at > existing.createdAt) {
|
||||
perLabeler.set(event.pubkey, {
|
||||
perModerator.set(event.pubkey, {
|
||||
pubkey: event.pubkey,
|
||||
event,
|
||||
createdAt: event.created_at,
|
||||
});
|
||||
}
|
||||
byCoordMap.set(aTag, perLabeler);
|
||||
byCoordMap.set(aTag, perModerator);
|
||||
}
|
||||
|
||||
const byCoord = new Map<string, CampaignVerification[]>();
|
||||
for (const [coord, perLabeler] of byCoordMap) {
|
||||
const list = [...perLabeler.values()].sort((a, b) => a.createdAt - b.createdAt);
|
||||
for (const [coord, perModerator] of byCoordMap) {
|
||||
const list = [...perModerator.values()].sort((a, b) => a.createdAt - b.createdAt);
|
||||
byCoord.set(coord, list);
|
||||
}
|
||||
|
||||
return { byCoord, labelers };
|
||||
return { byCoord, moderators };
|
||||
}
|
||||
|
||||
@@ -151,8 +151,6 @@ export const AppConfigSchema = z.object({
|
||||
imageProxy: z.string(),
|
||||
lowBandwidthMode: z.boolean(),
|
||||
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
|
||||
/** Hex pubkeys trusted to issue `agora.verified` campaign-verification labels. */
|
||||
labelers: z.array(z.string().regex(/^[0-9a-f]{64}$/i)).optional().default([]),
|
||||
/**
|
||||
* Ordered list of Esplora REST roots tried in failover order. Accepts the
|
||||
* legacy single-string form and normalizes it to a one-element array so
|
||||
|
||||
+5
-7
@@ -661,15 +661,13 @@
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "موثّق من قبل",
|
||||
"verifiedByCount_zero": "لم يوثّقها أحد",
|
||||
"verifiedByCount_one": "موثّقة من قبل موثِّق واحد",
|
||||
"verifiedByCount_two": "موثّقة من قبل موثِّقَين",
|
||||
"verifiedByCount_few": "موثّقة من قبل {{count}} موثِّقين",
|
||||
"verifiedByCount_many": "موثّقة من قبل {{count}} موثِّقًا",
|
||||
"verifiedByCount_other": "موثّقة من قبل {{count}} موثِّق",
|
||||
"notVerified": "لم تُوثّق بعد",
|
||||
"verifiedByCount_one": "موثّقة من قبل مشرف واحد",
|
||||
"verifiedByCount_two": "موثّقة من قبل مشرفَين",
|
||||
"verifiedByCount_few": "موثّقة من قبل {{count}} مشرفين",
|
||||
"verifiedByCount_many": "موثّقة من قبل {{count}} مشرفًا",
|
||||
"verifiedByCount_other": "موثّقة من قبل {{count}} مشرف",
|
||||
"verifyCampaign": "وثّق هذه الحملة",
|
||||
"removeVerification": "إزالة توثيقي",
|
||||
"verifiedState": "موثَّقة",
|
||||
"verified": "تم توثيق الحملة",
|
||||
"unverified": "تمت إزالة التوثيق",
|
||||
"actionFailed": "فشل الإجراء"
|
||||
|
||||
+45
-31
@@ -25,7 +25,7 @@
|
||||
"donors_one": "{{count}} donor",
|
||||
"donors_other": "{{count}} donors",
|
||||
"clearSearch": "Clear search",
|
||||
"searching": "Searching\u2026",
|
||||
"searching": "Searching…",
|
||||
"searchResultsCount_one": "{{count}} result",
|
||||
"searchResultsCount_other": "{{count}} results",
|
||||
"sortAriaLabel": "Sort order",
|
||||
@@ -35,7 +35,7 @@
|
||||
"showHidden": "Show hidden",
|
||||
"filtersAriaLabel": "Search filters",
|
||||
"countryFilterAriaLabel": "Filter by country",
|
||||
"countrySearchPlaceholder": "Search countries\u2026",
|
||||
"countrySearchPlaceholder": "Search countries…",
|
||||
"countryNoResults": "No countries found.",
|
||||
"countryGlobal": "Global"
|
||||
},
|
||||
@@ -300,9 +300,18 @@
|
||||
"unknown": "UNKNOWN"
|
||||
},
|
||||
"kindHeader": {
|
||||
"photo": { "action": "shared a", "noun": "photo" },
|
||||
"encryptedMessage": { "action": "sent an", "noun": "encrypted message" },
|
||||
"letter": { "action": "sent a", "noun": "letter" },
|
||||
"photo": {
|
||||
"action": "shared a",
|
||||
"noun": "photo"
|
||||
},
|
||||
"encryptedMessage": {
|
||||
"action": "sent an",
|
||||
"noun": "encrypted message"
|
||||
},
|
||||
"letter": {
|
||||
"action": "sent a",
|
||||
"noun": "letter"
|
||||
},
|
||||
"treasureHidCreated": "hid a",
|
||||
"treasureHidUpdated": "updated a",
|
||||
"treasureNoun": "treasure",
|
||||
@@ -566,7 +575,7 @@
|
||||
"body": "No payment processor sits in the middle, so no Stripe, no Visa, no bank can cut you off mid-campaign."
|
||||
},
|
||||
"otherBitcoin": {
|
||||
"heading": "Unlike other \u2018Bitcoin\u2019 platforms",
|
||||
"heading": "Unlike other ‘Bitcoin’ platforms",
|
||||
"body": "No central Lightning node, custodian, or LSP to fail or go offline. Funds settle directly on-chain to a wallet you control."
|
||||
}
|
||||
}
|
||||
@@ -673,9 +682,9 @@
|
||||
"sectionPast": "Past pledges",
|
||||
"sectionDefault": "Pledges",
|
||||
"sectionTagline": "Help fund the actions worth making.",
|
||||
"searchPlaceholder": "Search pledges\u2026",
|
||||
"searchPlaceholder": "Search pledges…",
|
||||
"searchAriaLabel": "Search pledges",
|
||||
"noMatch": "No pledges match \u201c{{query}}\u201d",
|
||||
"noMatch": "No pledges match “{{query}}”",
|
||||
"noMatchHint": "Try a different search term, or clear the search.",
|
||||
"sortAriaLabel": "Sort",
|
||||
"sortBy": "Sort by",
|
||||
@@ -808,9 +817,9 @@
|
||||
"featuredGroupsTagline": "Standout groups worth your attention.",
|
||||
"allGroups": "Groups",
|
||||
"allGroupsTagline": "Highlighted by moderators. Search or sort to browse every group.",
|
||||
"searchPlaceholder": "Search groups\u2026",
|
||||
"searchPlaceholder": "Search groups…",
|
||||
"searchAriaLabel": "Search groups",
|
||||
"noMatch": "No groups match \u201c{{query}}\u201d",
|
||||
"noMatch": "No groups match “{{query}}”",
|
||||
"noMatchHint": "Try a different search term, or clear the search.",
|
||||
"loginToSeeTitle": "Log in to see your groups",
|
||||
"loginToSeeBody": "Groups you've founded or moderate will appear here.",
|
||||
@@ -974,8 +983,8 @@
|
||||
"onchainInvalid": "Not a recognized mainnet Bitcoin address (bc1q… / bc1p…).",
|
||||
"spInvalid": "Not a recognized BIP-352 silent-payment code (sp1…).",
|
||||
"country": "Country",
|
||||
"countryPlaceholder": "Search countries",
|
||||
"tags": "Tags",
|
||||
"countryPlaceholder": "Search countries",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "legal-defense, mutual-aid, local-news",
|
||||
"categories": {
|
||||
"humanRights": "Human Rights",
|
||||
@@ -1109,16 +1118,14 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "Verified by",
|
||||
"verifiedByCount_zero": "Verified by {{count}} labelers",
|
||||
"verifiedByCount_one": "Verified by {{count}} labeler",
|
||||
"verifiedByCount_two": "Verified by {{count}} labelers",
|
||||
"verifiedByCount_few": "Verified by {{count}} labelers",
|
||||
"verifiedByCount_many": "Verified by {{count}} labelers",
|
||||
"verifiedByCount_other": "Verified by {{count}} labelers",
|
||||
"notVerified": "Not yet verified",
|
||||
"verifiedByCount_zero": "Verified by {{count}} moderators",
|
||||
"verifiedByCount_one": "Verified by {{count}} moderator",
|
||||
"verifiedByCount_two": "Verified by {{count}} moderators",
|
||||
"verifiedByCount_few": "Verified by {{count}} moderators",
|
||||
"verifiedByCount_many": "Verified by {{count}} moderators",
|
||||
"verifiedByCount_other": "Verified by {{count}} moderators",
|
||||
"verifyCampaign": "Verify this campaign",
|
||||
"removeVerification": "Remove my verification",
|
||||
"verifiedState": "Verified",
|
||||
"verified": "Campaign verified",
|
||||
"unverified": "Verification removed",
|
||||
"actionFailed": "Action failed"
|
||||
@@ -1211,13 +1218,12 @@
|
||||
"exploreCampaigns": "Explore campaigns",
|
||||
"featuredTitle": "Featured Campaigns",
|
||||
"featuredDesc": "Campaigns hand-picked by the {{appName}} team.",
|
||||
|
||||
"allCampaigns": "All campaigns",
|
||||
"allCampaignsDesc": "Every campaign on the network, in chronological order.",
|
||||
"browseAll": "Browse all campaigns",
|
||||
"searchPlaceholder": "Search campaigns\u2026",
|
||||
"searchPlaceholder": "Search campaigns…",
|
||||
"searchAriaLabel": "Search campaigns",
|
||||
"noMatch": "No campaigns match \u201c{{query}}\u201d",
|
||||
"noMatch": "No campaigns match “{{query}}”",
|
||||
"noMatchHint": "Try a different search term, or clear the search.",
|
||||
"hidden": "Hidden",
|
||||
"hiddenDesc": "Campaigns suppressed from public discovery. Use the kebab menu on a card to unhide.",
|
||||
@@ -1233,12 +1239,12 @@
|
||||
"block1": {
|
||||
"heading": "Unlike GoFundMe",
|
||||
"body": "No platform can freeze your donations, demand refunds, or terminate your campaign over policy disagreements. No Stripe, no Visa, no bank sits in the middle and can cut you off mid-campaign.",
|
||||
"bullet1": "Freeze-proof \u2014 no platform veto",
|
||||
"bullet1": "Freeze-proof — no platform veto",
|
||||
"bullet2": "No payment processor can pull the plug",
|
||||
"bullet3": "Zero platform fees"
|
||||
},
|
||||
"block2": {
|
||||
"heading": "Unlike other \u2018Bitcoin\u2019 platforms",
|
||||
"heading": "Unlike other ‘Bitcoin’ platforms",
|
||||
"body": "No central Lightning node, custodian, or LSP to fail or go offline. Funds settle directly on Bitcoin to a wallet you control. If {{appName}} disappeared tomorrow, every campaign would keep working.",
|
||||
"bullet1": "No custodial wallet to drain or freeze",
|
||||
"bullet2": "Settles on-chain to a wallet you own",
|
||||
@@ -1274,10 +1280,10 @@
|
||||
"sortNew": "New",
|
||||
"showHidden": "Show hidden",
|
||||
"startCampaign": "Start a campaign",
|
||||
"noMatch": "No campaigns match \u201c{{query}}\u201d",
|
||||
"noMatch": "No campaigns match “{{query}}”",
|
||||
"noMatchHint": "Try a different search term, or clear the search to see every campaign.",
|
||||
"allHidden": "No campaigns to show",
|
||||
"allHiddenHint": "Every campaign on the network has been hidden by moderators. Toggle \u201cShow hidden\u201d to view them.",
|
||||
"allHiddenHint": "Every campaign on the network has been hidden by moderators. Toggle “Show hidden” to view them.",
|
||||
"empty": "No campaigns yet",
|
||||
"emptyHint": "No campaigns have been published yet. Be the first."
|
||||
},
|
||||
@@ -2431,10 +2437,18 @@
|
||||
},
|
||||
"faq": {
|
||||
"categories": {
|
||||
"getting-started": { "label": "About Agora" },
|
||||
"payments": { "label": "Bitcoin Donations on Agora" },
|
||||
"about-nostr": { "label": "About Nostr" },
|
||||
"legacy": { "label": "Legacy" }
|
||||
"getting-started": {
|
||||
"label": "About Agora"
|
||||
},
|
||||
"payments": {
|
||||
"label": "Bitcoin Donations on Agora"
|
||||
},
|
||||
"about-nostr": {
|
||||
"label": "About Nostr"
|
||||
},
|
||||
"legacy": {
|
||||
"label": "Legacy"
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"what-is-ditto": {
|
||||
|
||||
+2
-4
@@ -670,12 +670,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "Verificado por",
|
||||
"verifiedByCount_one": "Verificado por {{count}} verificador",
|
||||
"verifiedByCount_other": "Verificado por {{count}} verificadores",
|
||||
"notVerified": "Aún sin verificar",
|
||||
"verifiedByCount_one": "Verificado por {{count}} moderador",
|
||||
"verifiedByCount_other": "Verificado por {{count}} moderadores",
|
||||
"verifyCampaign": "Verificar esta campaña",
|
||||
"removeVerification": "Quitar mi verificación",
|
||||
"verifiedState": "Verificada",
|
||||
"verified": "Campaña verificada",
|
||||
"unverified": "Verificación eliminada",
|
||||
"actionFailed": "La acción falló"
|
||||
|
||||
+2
-4
@@ -670,12 +670,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "تأییدشده توسط",
|
||||
"verifiedByCount_one": "تأییدشده توسط {{count}} تأییدکننده",
|
||||
"verifiedByCount_other": "تأییدشده توسط {{count}} تأییدکننده",
|
||||
"notVerified": "هنوز تأیید نشده",
|
||||
"verifiedByCount_one": "تأییدشده توسط {{count}} ناظر",
|
||||
"verifiedByCount_other": "تأییدشده توسط {{count}} ناظر",
|
||||
"verifyCampaign": "این کمپین را تأیید کنید",
|
||||
"removeVerification": "حذف تأیید من",
|
||||
"verifiedState": "تأییدشده",
|
||||
"verified": "کمپین تأیید شد",
|
||||
"unverified": "تأیید حذف شد",
|
||||
"actionFailed": "عملیات ناموفق بود"
|
||||
|
||||
+2
-4
@@ -1108,12 +1108,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "Vérifié par",
|
||||
"verifiedByCount_one": "Vérifié par {{count}} vérificateur",
|
||||
"verifiedByCount_other": "Vérifié par {{count}} vérificateurs",
|
||||
"notVerified": "Pas encore vérifié",
|
||||
"verifiedByCount_one": "Vérifié par {{count}} modérateur",
|
||||
"verifiedByCount_other": "Vérifié par {{count}} modérateurs",
|
||||
"verifyCampaign": "Vérifier cette campagne",
|
||||
"removeVerification": "Retirer ma vérification",
|
||||
"verifiedState": "Vérifiée",
|
||||
"verified": "Campagne vérifiée",
|
||||
"unverified": "Vérification retirée",
|
||||
"actionFailed": "Échec de l’action"
|
||||
|
||||
+2
-4
@@ -1111,12 +1111,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "द्वारा सत्यापित",
|
||||
"verifiedByCount_one": "{{count}} सत्यापनकर्ता द्वारा सत्यापित",
|
||||
"verifiedByCount_other": "{{count}} सत्यापनकर्ताओं द्वारा सत्यापित",
|
||||
"notVerified": "अभी तक सत्यापित नहीं",
|
||||
"verifiedByCount_one": "{{count}} मॉडरेटर द्वारा सत्यापित",
|
||||
"verifiedByCount_other": "{{count}} मॉडरेटरों द्वारा सत्यापित",
|
||||
"verifyCampaign": "इस अभियान को सत्यापित करें",
|
||||
"removeVerification": "मेरा सत्यापन हटाएँ",
|
||||
"verifiedState": "सत्यापित",
|
||||
"verified": "अभियान सत्यापित",
|
||||
"unverified": "सत्यापन हटाया गया",
|
||||
"actionFailed": "कार्रवाई विफल"
|
||||
|
||||
+2
-4
@@ -1111,12 +1111,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "Diverifikasi oleh",
|
||||
"verifiedByCount_one": "Diverifikasi oleh {{count}} pemverifikasi",
|
||||
"verifiedByCount_other": "Diverifikasi oleh {{count}} pemverifikasi",
|
||||
"notVerified": "Belum diverifikasi",
|
||||
"verifiedByCount_one": "Diverifikasi oleh {{count}} moderator",
|
||||
"verifiedByCount_other": "Diverifikasi oleh {{count}} moderator",
|
||||
"verifyCampaign": "Verifikasi kampanye ini",
|
||||
"removeVerification": "Hapus verifikasi saya",
|
||||
"verifiedState": "Terverifikasi",
|
||||
"verified": "Kampanye terverifikasi",
|
||||
"unverified": "Verifikasi dihapus",
|
||||
"actionFailed": "Tindakan gagal"
|
||||
|
||||
+2
-4
@@ -670,12 +670,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "បានផ្ទៀងផ្ទាត់ដោយ",
|
||||
"verifiedByCount_one": "បានផ្ទៀងផ្ទាត់ដោយអ្នកផ្ទៀងផ្ទាត់ {{count}} នាក់",
|
||||
"verifiedByCount_other": "បានផ្ទៀងផ្ទាត់ដោយអ្នកផ្ទៀងផ្ទាត់ {{count}} នាក់",
|
||||
"notVerified": "មិនទាន់បានផ្ទៀងផ្ទាត់",
|
||||
"verifiedByCount_one": "បានផ្ទៀងផ្ទាត់ដោយអ្នកសម្របសម្រួល {{count}} នាក់",
|
||||
"verifiedByCount_other": "បានផ្ទៀងផ្ទាត់ដោយអ្នកសម្របសម្រួល {{count}} នាក់",
|
||||
"verifyCampaign": "ផ្ទៀងផ្ទាត់យុទ្ធនាការនេះ",
|
||||
"removeVerification": "លុបការផ្ទៀងផ្ទាត់របស់ខ្ញុំ",
|
||||
"verifiedState": "បានផ្ទៀងផ្ទាត់",
|
||||
"verified": "យុទ្ធនាការត្រូវបានផ្ទៀងផ្ទាត់",
|
||||
"unverified": "ការផ្ទៀងផ្ទាត់ត្រូវបានលុប",
|
||||
"actionFailed": "សកម្មភាពបានបរាជ័យ"
|
||||
|
||||
+2
-4
@@ -672,12 +672,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "تصدیق شوی د",
|
||||
"verifiedByCount_one": "د {{count}} تصدیق کوونکي لخوا تصدیق شوی",
|
||||
"verifiedByCount_other": "د {{count}} تصدیق کوونکو لخوا تصدیق شوی",
|
||||
"notVerified": "تر اوسه تصدیق شوی نه دی",
|
||||
"verifiedByCount_one": "د {{count}} اعتدال کوونکي لخوا تصدیق شوی",
|
||||
"verifiedByCount_other": "د {{count}} اعتدال کوونکو لخوا تصدیق شوی",
|
||||
"verifyCampaign": "دا کمپاین تصدیق کړئ",
|
||||
"removeVerification": "زما تصدیق لرې کړئ",
|
||||
"verifiedState": "تصدیق شوی",
|
||||
"verified": "کمپاین تصدیق شو",
|
||||
"unverified": "تصدیق لرې شو",
|
||||
"actionFailed": "کړنه ناکامه شوه"
|
||||
|
||||
+2
-4
@@ -1113,12 +1113,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "Verificado por",
|
||||
"verifiedByCount_one": "Verificado por {{count}} verificador",
|
||||
"verifiedByCount_other": "Verificado por {{count}} verificadores",
|
||||
"notVerified": "Ainda não verificado",
|
||||
"verifiedByCount_one": "Verificado por {{count}} moderador",
|
||||
"verifiedByCount_other": "Verificado por {{count}} moderadores",
|
||||
"verifyCampaign": "Verificar esta campanha",
|
||||
"removeVerification": "Remover minha verificação",
|
||||
"verifiedState": "Verificada",
|
||||
"verified": "Campanha verificada",
|
||||
"unverified": "Verificação removida",
|
||||
"actionFailed": "Falha na ação"
|
||||
|
||||
+4
-6
@@ -1111,14 +1111,12 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "Проверено",
|
||||
"verifiedByCount_one": "Проверено {{count}} проверяющим",
|
||||
"verifiedByCount_few": "Проверено {{count}} проверяющими",
|
||||
"verifiedByCount_many": "Проверено {{count}} проверяющими",
|
||||
"verifiedByCount_other": "Проверено {{count}} проверяющими",
|
||||
"notVerified": "Ещё не проверено",
|
||||
"verifiedByCount_one": "Проверено {{count}} модератором",
|
||||
"verifiedByCount_few": "Проверено {{count}} модераторами",
|
||||
"verifiedByCount_many": "Проверено {{count}} модераторами",
|
||||
"verifiedByCount_other": "Проверено {{count}} модераторами",
|
||||
"verifyCampaign": "Проверить эту кампанию",
|
||||
"removeVerification": "Убрать мою проверку",
|
||||
"verifiedState": "Проверено",
|
||||
"verified": "Кампания проверена",
|
||||
"unverified": "Проверка удалена",
|
||||
"actionFailed": "Не удалось выполнить действие"
|
||||
|
||||
+2
-4
@@ -672,12 +672,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "Yasimbiswa na",
|
||||
"verifiedByCount_one": "Yasimbiswa nemusimbisi {{count}}",
|
||||
"verifiedByCount_other": "Yasimbiswa nevasimbisi {{count}}",
|
||||
"notVerified": "Haisati yasimbiswa",
|
||||
"verifiedByCount_one": "Yasimbiswa nemudzori {{count}}",
|
||||
"verifiedByCount_other": "Yasimbiswa nevadzori {{count}}",
|
||||
"verifyCampaign": "Simbisa mushandirapamwe uyu",
|
||||
"removeVerification": "Bvisa kusimbisa kwangu",
|
||||
"verifiedState": "Yasimbiswa",
|
||||
"verified": "Mushandirapamwe wasimbiswa",
|
||||
"unverified": "Kusimbisa kwabviswa",
|
||||
"actionFailed": "Chiito chatadza"
|
||||
|
||||
+2
-4
@@ -1110,12 +1110,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "Imethibitishwa na",
|
||||
"verifiedByCount_one": "Imethibitishwa na mthibitishaji {{count}}",
|
||||
"verifiedByCount_other": "Imethibitishwa na wathibitishaji {{count}}",
|
||||
"notVerified": "Bado haijathibitishwa",
|
||||
"verifiedByCount_one": "Imethibitishwa na msimamizi {{count}}",
|
||||
"verifiedByCount_other": "Imethibitishwa na wasimamizi {{count}}",
|
||||
"verifyCampaign": "Thibitisha kampeni hii",
|
||||
"removeVerification": "Ondoa uthibitisho wangu",
|
||||
"verifiedState": "Imethibitishwa",
|
||||
"verified": "Kampeni imethibitishwa",
|
||||
"unverified": "Uthibitisho umeondolewa",
|
||||
"actionFailed": "Kitendo kimeshindwa"
|
||||
|
||||
+2
-4
@@ -1112,12 +1112,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "Doğrulayan",
|
||||
"verifiedByCount_one": "{{count}} doğrulayıcı tarafından doğrulandı",
|
||||
"verifiedByCount_other": "{{count}} doğrulayıcı tarafından doğrulandı",
|
||||
"notVerified": "Henüz doğrulanmadı",
|
||||
"verifiedByCount_one": "{{count}} moderatör tarafından doğrulandı",
|
||||
"verifiedByCount_other": "{{count}} moderatör tarafından doğrulandı",
|
||||
"verifyCampaign": "Bu kampanyayı doğrula",
|
||||
"removeVerification": "Doğrulamamı kaldır",
|
||||
"verifiedState": "Doğrulandı",
|
||||
"verified": "Kampanya doğrulandı",
|
||||
"unverified": "Doğrulama kaldırıldı",
|
||||
"actionFailed": "İşlem başarısız oldu"
|
||||
|
||||
@@ -672,12 +672,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "驗證者",
|
||||
"verifiedByCount_one": "已由 {{count}} 位驗證者驗證",
|
||||
"verifiedByCount_other": "已由 {{count}} 位驗證者驗證",
|
||||
"notVerified": "尚未驗證",
|
||||
"verifiedByCount_one": "已由 {{count}} 位審核員驗證",
|
||||
"verifiedByCount_other": "已由 {{count}} 位審核員驗證",
|
||||
"verifyCampaign": "驗證此活動",
|
||||
"removeVerification": "移除我的驗證",
|
||||
"verifiedState": "已驗證",
|
||||
"verified": "活動已驗證",
|
||||
"unverified": "驗證已移除",
|
||||
"actionFailed": "操作失敗"
|
||||
|
||||
+2
-4
@@ -672,12 +672,10 @@
|
||||
},
|
||||
"campaignVerification": {
|
||||
"verifiedBy": "验证者",
|
||||
"verifiedByCount_one": "已由 {{count}} 位验证者验证",
|
||||
"verifiedByCount_other": "已由 {{count}} 位验证者验证",
|
||||
"notVerified": "尚未验证",
|
||||
"verifiedByCount_one": "已由 {{count}} 位审核员验证",
|
||||
"verifiedByCount_other": "已由 {{count}} 位审核员验证",
|
||||
"verifyCampaign": "验证此活动",
|
||||
"removeVerification": "移除我的验证",
|
||||
"verifiedState": "已验证",
|
||||
"verified": "活动已验证",
|
||||
"unverified": "验证已移除",
|
||||
"actionFailed": "操作失败"
|
||||
|
||||
@@ -115,7 +115,6 @@ export function TestApp({ children }: TestAppProps) {
|
||||
imageQuality: 'compressed',
|
||||
imageProxy: '',
|
||||
lowBandwidthMode: false,
|
||||
labelers: ['932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d'],
|
||||
esploraApis: ['https://mempool.space/api'],
|
||||
blockbookBaseUrl: 'https://btc.trezor.io',
|
||||
bip352IndexerUrl: '',
|
||||
|
||||
Reference in New Issue
Block a user