Compare commits

...

11 Commits

Author SHA1 Message Date
Alex Gleason 71ca2778fd home: stop fetching kind 1985 moderation labels
The home page (CampaignsPage) called useCampaignModeration() solely to
drop hidden campaigns from the WLC hero row, which fired a kind 1985
label query (limit 2000) on every initial load just to check ≤6
curated coords. Remove the dependency: the hero row now only reorders
to the moderator-curated list order. Hidden-campaign moderation already
lives entirely on /campaigns, so the home page no longer needs it.
2026-06-01 23:07:57 +02:00
mkfain 545f288aee campaigns: drop duplicate arrow from browseAll button label
The 'Browse all campaigns' Link on the home page renders an <ArrowRight>
lucide icon next to t('campaigns.home.browseAll'), but the translated
string itself ended in '→' (or '←' for RTL locales), so the button
displayed two arrows. Strip the literal arrow from all 16 locale files
and let the icon do the visual work — it already handles RTL via
rtl:rotate-180 in CampaignsPage.tsx.
2026-06-01 23:07:57 +02:00
Chad Curtis 0895b763c6 campaigns: hardcode moderators, gate lists on a single curator
The home page used to serialize two single-relay round-trips before any
campaign card could render: useCampaignModerators fetched the Team Soapbox
follow pack (kind 39089), and useCampaignLists waited on it to apply an
authors: gate. Each could stall up to an 8s EOSE timeout against the app
relay.

Both lookups are now eliminated from the critical path:

- CAMPAIGN_MODERATORS in agoraDefaults.ts is a hardcoded snapshot of the
  pack's p tags. useCampaignModerators serves it synchronously (no
  queryFn network call), keeping its useQuery return shape so all ~15
  consumers work unchanged. The roster changes rarely; update the array
  and re-cut a release when it does.

- Lists are an editorial surface curated by one identity (MK Fain / Team
  Soapbox), not the whole moderator pack. useCampaignLists now pins
  authors: to LIST_CURATOR_PUBKEY and no longer depends on the moderator
  query at all. The multi-author allowlist remains for labels only
  (approve/hide), where any pack member is trusted.

Regression-of: be1fadfc
2026-06-01 23:07:57 +02:00
Chad Curtis 670ef9a3e9 home: decouple funding-bar skeleton from card, parallelize list queries
CampaignCard now paints immediately and shows a dedicated skeleton for
the funding/progress bar while useCampaignDonations resolves, instead of
flashing a misleading "0 raised" before the on-chain balance lands.

useCampaignLists no longer serializes behind useCampaignModerators: the
list relay query fires immediately on the hashtag filter and the
moderator allowlist is applied client-side in foldCampaignLists. The two
single-relay round-trips (each up to an 8s EOSE timeout) now run in
parallel on cold sessions. The trust gate is unchanged — a list authored
by a non-moderator is dropped before it reaches the UI.
2026-06-01 23:07:57 +02:00
filemon be3c6fd3eb campaign: warm the wallet card to match the Save-your-key onboarding
Bring the wallet destination card into the same warm, protective visual
language as the onboarding SecureStep card so the wallet decision feels
just as carefully guided.

- Swap the neutral muted surface for the onboarding card's primary-token
  wash (bg-primary/10, border-2 border-primary/30, ring-primary/10).
- Retint the Bitcoin chip with brand tokens (bg-primary/15, text-primary,
  ring-primary/20).
- Add an inset 'destination preview' panel (bg-background/60, dark:bg-
  black/20, border-primary/20) echoing the masked-key area, with a
  'Donations will land here' headline and the destination note.
- Drop the neutral hairline; the inset panel now provides separation.
- Restyle the accept pills for the tinted card: inset surface when
  unselected, solid primary fill when selected (a faint tint vanished on
  the wash). Behavior, labels, disabled states unchanged.
- Update walletStepSubtitle and walletDestinationNote; add
  walletDestinationLanding. All locales updated.

No wizard or wallet logic changes.
2026-06-01 23:07:57 +02:00
filemon 1996d960b8 campaign: consolidate wallet step into one calm card
Make the wizard's wallet step read as a single guided decision in the
cadence of the onboarding "Save your key" screen instead of a stack of
form sections.

- Render the wallet picker bare in the wizard step (drop the FormSection
  "Bitcoin wallet / Required" header, which competed with the centered
  step title). Edit mode keeps the FormSection wrapper for single-page
  layout consistency.
- Collapse the 'mine' branch into one Card: identity row, theme-aware
  hairline, accept-mode picker, a reassuring destination note, and the
  soft "Use another wallet instead" action.
- Reword the step title/subtitle and add walletDestinationNote; update
  all locales.

No wallet logic or wizard behavior changes.
2026-06-01 23:07:56 +02:00
Alex Gleason 5c9d332d21 Upgrade Nostrify 2026-06-01 23:07:56 +02:00
filemon 86d132ed73 campaign: polish donation destination step with premium wallet card
Restyle the Agora wallet row in the campaign wizard's wallet step as a
theme-aware muted Card with a subtle orange ring/glow, larger avatar,
stronger spacing, and a Bitcoin icon on the right so it reads as the
chosen donation destination. Give the Accept All / Public Only /
Private Only toggles a clearer orange active state. No layout or wallet
logic changes.

Reword the step copy (title, subtitle, custom-wallet link) and update
all locales to match.
2026-06-01 22:18:35 +02:00
filemon c1ace8422b Disable Since dropdown while From block override is active
The Since dropdown showed 'Last month' on page load even though the
pre-filled From block override (block 951430) was the actual scan
source. This was visually misleading.

- Disable the Select and hide the Custom hours input while
  fromOverride is non-empty, so the UI makes it obvious that the
  manual block height is in control.
- Show a short hint below the disabled Select: 'Using the From block
  override below. Clear it to use a time window instead.'
- Clearing the From block re-enables the dropdown for preset scans.
2026-06-01 22:18:35 +02:00
filemon e02a008069 Pre-fill recovery-era block height and fix audit findings
Restore the original recovery guarantee: the Advanced 'From block'
input is pre-populated with recovery.defaultFromHeight (block 951430)
and the Advanced section starts open, so the first scan covers the
full known affected window without depending on mempool.space. Users
can clear the override and use the Since preset dropdown for narrower
re-scans.

Additional fixes from the pre-merge audit:

- Add isManualUpToDate guard: disable Start and show a hint when the
  manual override exceeds the chain tip (consistent with
  HDSilentPaymentScanDialog).
- Disable Select, custom hours input, and From block input while
  isResolvingSince is true to prevent mid-flight input changes.
- Reset sweptSats alongside step/error/txid when starting a new scan.
- Add recoveryWindowHint and upToDate locale strings.
2026-06-01 22:18:35 +02:00
filemon a4d8bf50e3 Show dates instead of block heights in double-tweak recovery UI
Replace the raw block-height input and progress display with
human-readable dates so non-technical users can navigate the recovery
scanner intuitively.

- Add estimateDateFromHeight/estimateHeightFromDate utilities anchored
  to block 840 000 (4th halving) with Bitcoin's 10-min average interval.
- Swap the numeric height <Input> for a <input type="date"> that
  converts the selected date to an estimated height internally.
- Render scan progress and chain-tip hint as localized date strings
  (e.g. "May 15, 2026") instead of block numbers.
- Update en.json labels: fromHeightLabel → fromDateLabel, tipHint and
  progress now interpolate date strings, noFunds nudges an earlier date
  instead of an earlier height.
2026-06-01 22:18:35 +02:00
25 changed files with 607 additions and 279 deletions
+24 -24
View File
@@ -52,8 +52,8 @@
"@milkdown/utils": "^7.20.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^1.8.0",
"@nostrify/nostrify": "^0.52.1",
"@nostrify/react": "^0.6.1",
"@nostrify/nostrify": "^0.52.2",
"@nostrify/react": "^0.6.2",
"@nostrify/types": "^0.37.0",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -2506,9 +2506,9 @@
}
},
"node_modules/@nostrify/nostrify": {
"version": "0.52.1",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.1.tgz",
"integrity": "sha512-tnzl7PTXyiZfYd3sTlPzxrZsTs9MxguJqh0ZG6vguUJEUwgHacvFeHXCWWok5CLsbpedYVrO/MpeCV8BqwDVpg==",
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.2.tgz",
"integrity": "sha512-X4pteBW9p2sVhBX9Dxt7Wf+beJYI7ophfEopcNmaTipNdj/u1LeS5ufze2fKozTvje53s4MoK7+DkMpRtFSKDg==",
"dependencies": {
"@nostrify/types": "0.37.0",
"@scure/base": "^2.0.0",
@@ -2547,11 +2547,11 @@
"license": "MIT"
},
"node_modules/@nostrify/react": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.1.tgz",
"integrity": "sha512-+fI4WyWYRLc5YhfGD6HCYmWXe3im35av1+sdaNqToxOZDfs5le/7QoyFQIVAdfLggmM+8ycEZcZfmFoTknbqhg==",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.2.tgz",
"integrity": "sha512-D7SXjhEQ74Gd3aEjlG4FOzrDZ/uPMb3LgWwGmZg48F8noRWKAUjDBS9i7d3J6lShPBydw/BLg7Yhue2GValAhg==",
"dependencies": {
"@nostrify/nostrify": "0.52.1",
"@nostrify/nostrify": "0.52.2",
"@nostrify/types": "0.37.0"
},
"peerDependencies": {
@@ -5871,13 +5871,13 @@
}
},
"node_modules/@smithy/core": {
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz",
"integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==",
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz",
"integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@smithy/types": "^4.14.2",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2"
},
"engines": {
@@ -5897,9 +5897,9 @@
}
},
"node_modules/@smithy/types": {
"version": "4.14.2",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz",
"integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==",
"version": "4.14.3",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz",
"integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -5909,12 +5909,12 @@
}
},
"node_modules/@smithy/util-base64": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.4.5.tgz",
"integrity": "sha512-2J8l+DoX3IIiP75X5SYkJ3mIgOkxW29MxOs7oPjbXLuInQ7UL6zLw2IJHbQ44+eKDBBhTjvt+GgwsTTNBGt8zA==",
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.4.6.tgz",
"integrity": "sha512-V6ApAGvCQnb7Wy1Sy60AQc+7UOEaNQxvAXBLdMi5Zzm66cmX0srvfAxDmg7BGuJ+9H9ez0PPWS/AeFgWxwGavA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.24.5",
"@smithy/core": "^3.24.6",
"tslib": "^2.6.2"
},
"engines": {
@@ -5935,12 +5935,12 @@
}
},
"node_modules/@smithy/util-hex-encoding": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.3.5.tgz",
"integrity": "sha512-+ip3QrXGjDOzV/ciNWPTm6bhJuXjmzugMR19ouXgA26QqhEo0zuXM7pvYE9S4VfX13YmPgSYDPkF4+2bPqIwAg==",
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.3.6.tgz",
"integrity": "sha512-ooo5MQdstAtIlgS0bchoMkVsQ3x1wLLPtFilpeIV8wVtpwZYY8PoSdlvR79+yw0aJU9hjd8stKsmzIxrmAQ6fw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.24.5",
"@smithy/core": "^3.24.6",
"tslib": "^2.6.2"
},
"engines": {
+2 -2
View File
@@ -59,8 +59,8 @@
"@milkdown/utils": "^7.20.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^1.8.0",
"@nostrify/nostrify": "^0.52.1",
"@nostrify/react": "^0.6.1",
"@nostrify/nostrify": "^0.52.2",
"@nostrify/react": "^0.6.2",
"@nostrify/types": "^0.37.0",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
+27 -2
View File
@@ -47,13 +47,33 @@ function CampaignProgress({
raisedSats,
goalUsd,
btcPrice,
isLoading,
className,
}: {
raisedSats: number;
goalUsd?: number;
btcPrice?: number;
/**
* True while the donation totals are still being fetched. The bar gets
* its own skeleton — independent of the card, which paints immediately —
* so we never flash a misleading "0 raised" before the on-chain balance
* lands. Footprint matches the loaded state (bar row + one text row).
*/
isLoading?: boolean;
className?: string;
}) {
if (isLoading) {
return (
<div className={cn('space-y-1.5', className)}>
<Skeleton className="h-2 w-full" />
<div className="flex items-baseline justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
</div>
</div>
);
}
const hasGoal = !!goalUsd && goalUsd > 0;
const raisedUsd = satsToUsd(raisedSats, btcPrice);
const pct = hasGoal && raisedUsd !== undefined
@@ -151,7 +171,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
});
const displayCampaign = parseCampaign(translatedEvent) ?? campaign;
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign);
const { data: stats, isLoading: donationsLoading } = useCampaignDonations(campaign);
const { data: btcPrice } = useBtcPrice();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
@@ -287,7 +307,12 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
{isSilentPayment ? (
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
) : (
<CampaignProgress raisedSats={raisedSats} goalUsd={campaign.goalUsd} btcPrice={btcPrice} />
<CampaignProgress
raisedSats={raisedSats}
goalUsd={campaign.goalUsd}
btcPrice={btcPrice}
isLoading={donationsLoading}
/>
)}
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
+14 -24
View File
@@ -2,7 +2,6 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useCampaignModerators } from './useCampaignModerators';
import {
CAMPAIGN_LIST_KIND,
CAMPAIGN_LIST_HASHTAG,
@@ -10,6 +9,7 @@ import {
type ParsedCampaignList,
foldCampaignLists,
} from '@/lib/campaignLists';
import { LIST_CURATOR_PUBKEY } from '@/lib/agoraDefaults';
import { DITTO_RELAY } from '@/lib/appRelays';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -22,15 +22,19 @@ interface UseCampaignListsResult {
}
/**
* Reads moderator-curated campaign lists (kind 30003 with the
* Reads curator-authored campaign lists (kind 30003 with the
* `agora.campaign-list` hashtag) plus the optional list-of-lists order
* sentinel (`agora.campaign-lists.index`).
*
* **Trust model.** The query gates `authors:` on
* {@link useCampaignModerators}'s allowlist (Team Soapbox follow pack
* members). Without that gate, any pubkey could publish a kind 30003
* with our hashtag and appear in the strip — same self-appointment hole
* we avoid in `useCampaignModeration`.
* **Trust model.** Lists are an editorial surface curated by a single
* pubkey ({@link LIST_CURATOR_PUBKEY}). The relay query pins `authors:`
* to that pubkey, so a kind 30003 with our hashtag from anyone else —
* including a label moderator — never appears. This is deliberately
* narrower than label moderation (`useCampaignModerators`), where any
* follow-pack member is trusted to sign approve / hide labels.
*
* Because the curator is a hardcoded constant, this query depends on no
* other query — it fires on first paint with no waterfall.
*
* Lists *and* the index are pulled in a single filter via
* `'#t': [LIST_HASHTAG, LIST_INDEX_HASHTAG]` so there's only one
@@ -38,20 +42,10 @@ interface UseCampaignListsResult {
*/
export function useCampaignLists() {
const { nostr } = useNostr();
const { data: moderators, isLoading: moderatorsLoading } = useCampaignModerators();
const moderatorsKey = useMemo(
() => (moderators ? [...moderators].sort().join(',') : ''),
[moderators],
);
const query = useQuery<UseCampaignListsResult>({
queryKey: ['campaign-lists', moderatorsKey],
enabled: !!moderators && moderators.length > 0,
queryKey: ['campaign-lists', LIST_CURATOR_PUBKEY],
queryFn: async ({ signal }) => {
if (!moderators || moderators.length === 0) {
return { lists: [], indexEvent: undefined };
}
// Query the canonical app relay directly. The same reasoning as
// `useCampaignModerators` applies: a fast empty EOSE from a
// less-populated relay should not race the moderation surface to
@@ -61,23 +55,19 @@ export function useCampaignLists() {
[
{
kinds: [CAMPAIGN_LIST_KIND],
authors: moderators,
authors: [LIST_CURATOR_PUBKEY],
'#t': [CAMPAIGN_LIST_HASHTAG, CAMPAIGN_LIST_INDEX_HASHTAG],
limit: 500,
},
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
return foldCampaignLists(events);
},
staleTime: 30_000,
});
return {
...query,
isLoading: query.isLoading || moderatorsLoading,
};
return query;
}
/** Lookup a single list by slug from the cached collection. */
+21 -56
View File
@@ -1,71 +1,36 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { TEAM_SOAPBOX } from '@/lib/agoraDefaults';
import { DITTO_RELAY } from '@/lib/appRelays';
/** A 64-character lowercase hex string. */
const HEX_64_RE = /^[0-9a-f]{64}$/;
import { CAMPAIGN_MODERATORS } from '@/lib/agoraDefaults';
/**
* Returns the hex pubkeys of campaign moderators — the `p` tags of the
* Team Soapbox follow pack (kind 39089).
* Returns the hex pubkeys of campaign moderators — the pubkeys allowed to
* sign approve / hide labels in the `agora.moderation` namespace (see
* NIP.md).
*
* A campaign appears on `/` and Discover only if a moderator has labeled it
* `approved` (see {@link useCampaignModeration}). A moderator's `hidden`
* label always wins over any approval. The pack itself is authored by a
* single admin pubkey, so we pin `authors` to that pubkey to prevent anyone
* else from publishing a same-`d` event and self-appointing.
* label always wins over any approval.
*
* **Phase 1 tradeoff:** the pack is fetched live every cold session. We
* accept the 1-round-trip latency in exchange for not shipping a release
* every time the moderator roster changes. If perf matters, snapshot the
* `p` tags into a hardcoded array and short-circuit this hook.
* **Hardcoded snapshot.** This used to fetch the Team Soapbox follow pack
* (kind 39089) live every cold session, which put a single-relay round-trip
* — up to an 8s EOSE timeout — on the critical path of every
* moderation-gated surface (home, Discover, profile campaigns, etc.). The
* roster changes rarely, so the membership is now snapshotted in
* {@link CAMPAIGN_MODERATORS} and served synchronously with zero network
* cost. Update that array (and re-cut a release) when the pack changes.
*
* @see TEAM_SOAPBOX (src/lib/agoraDefaults.ts) for the pack coordinate.
* The hook keeps its `useQuery` return shape so existing consumers
* (`{ data, isLoading, ... }`) continue to work unchanged; the query is a
* pure synchronous read with no `queryFn` network call.
*
* @see CAMPAIGN_MODERATORS (src/lib/agoraDefaults.ts) for the pubkey list.
* @see NIP.md "Campaign moderation labels" for the namespace this powers.
*/
export function useCampaignModerators() {
const { nostr } = useNostr();
return useQuery({
queryKey: ['campaign-moderators', TEAM_SOAPBOX.pubkey, TEAM_SOAPBOX.identifier],
queryFn: async ({ signal }) => {
// The home page gates campaign visibility on this pack. Query the
// canonical app relay directly so a fast empty EOSE from another relay
// cannot race the pack out and make the page render as empty.
const relay = nostr.relay(DITTO_RELAY);
const events = await relay.query(
[
{
kinds: [TEAM_SOAPBOX.kind],
// Pinning to the pack author is required: kind 39089 is
// addressable, so without this anyone could publish a competing
// event with the same `d` and force themselves into the moderator
// list. (See AGENTS.md `nostr-security`.)
authors: [TEAM_SOAPBOX.pubkey],
'#d': [TEAM_SOAPBOX.identifier],
limit: 1,
},
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
if (events.length === 0) return [] as string[];
// The pack is replaceable; relays may serve old revisions alongside the
// current one. Keep the newest.
const newest = events.reduce((latest, current) =>
current.created_at > latest.created_at ? current : latest,
);
// Filter malformed `p` tags so a typo doesn't blow up downstream
// relay filters (which reject non-hex `authors:` entries).
return newest.tags
.filter(([name, value]) => name === 'p' && typeof value === 'string' && HEX_64_RE.test(value))
.map(([, pubkey]) => pubkey);
},
staleTime: 10 * 60_000,
gcTime: 60 * 60_000,
queryKey: ['campaign-moderators', 'snapshot'],
queryFn: () => CAMPAIGN_MODERATORS.slice(),
staleTime: Infinity,
gcTime: Infinity,
});
}
+53
View File
@@ -60,3 +60,56 @@ export const TEAM_SOAPBOX = {
identifier: teamSoapboxDecoded.data.identifier,
relays: teamSoapboxDecoded.data.relays,
} as const;
/**
* The single pubkey allowed to author campaign **lists** (kind 30003 with
* the `agora.campaign-list` hashtag) and the list-of-lists index sentinel.
*
* This is deliberately narrower than the moderator allowlist
* ({@link CAMPAIGN_MODERATORS}). That allowlist governs **labels** —
* approve / hide moderation in the `agora.moderation` namespace — where
* any pack member is trusted to sign. Lists are an editorial surface (the
* home hero row, the topic strip) curated by one person (MK Fain / Team
* Soapbox), so a list authored by anyone else — including another
* moderator — is dropped before it reaches the UI.
*
* It happens to equal the follow-pack author (`TEAM_SOAPBOX.pubkey`),
* which is the same single admin identity, so we derive it from there
* rather than duplicating the hex.
*/
export const LIST_CURATOR_PUBKEY = TEAM_SOAPBOX.pubkey;
/**
* Hardcoded snapshot of the campaign-moderator pubkeys — the `p` tags of
* the Team Soapbox follow pack ({@link TEAM_SOAPBOX}) as of the snapshot
* date below.
*
* These pubkeys form the authoritative allowlist for **labels**: who may
* sign approve / hide moderation in the `agora.moderation` namespace (see
* NIP.md and `useCampaignModerators`). A campaign appears on `/` and
* Discover only if one of these pubkeys labeled it `approved`; a `hidden`
* label from any of them always wins.
*
* **Why hardcoded.** The pack used to be fetched live every cold session
* (kind 39089), which put a single-relay round-trip — up to an 8s EOSE
* timeout — on the critical path of every moderation-gated surface. The
* roster changes rarely, so we snapshot it here and pay zero network cost.
* When the pack membership changes, update this array (and re-cut a
* release). Source of truth remains the on-relay pack; this is a copy.
*
* Snapshot taken from pack event `740838e6…fac76` (created_at 1779321391).
*/
export const CAMPAIGN_MODERATORS: readonly string[] = [
'781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5',
'0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd',
'932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
'3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24',
'86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47',
'47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4',
'ce97367c75d7d91fb9bc3bc6ff5bb3bdb52c18941bfce2f368616dcbf0adfd2f',
'0574536d3ef4d65faf95b42393610b8475d22f4c294649d46c50d5d36f75267c',
'be7358c4fe50148cccafc02ea205d80145e253889aa3958daafa8637047c840e',
'2093baa8621c5b255e8f4fc2c6fdfc10d8a5598a25517664efaba860735f1030',
'8f53782e8693e88afb710b6d68182ad973973c8822caa237bb60288b125673ca',
'c839bc85846f24fc6b777548fe654672377f4cc2a04cab19cddec75b2f8b4dbd',
] as const;
+6 -4
View File
@@ -498,7 +498,9 @@
"myWalletDefault": "محفظتي",
"walletChoose": "اختر محفظة",
"walletCustom": "مخصصة",
"walletUseCustom": "استخدم محفظة مخصصة بدلاً من ذلك",
"walletUseCustom": "استخدم محفظة أخرى بدلاً من ذلك",
"walletDestinationLanding": "ستصل التبرعات هنا",
"walletDestinationNote": "سيتم نشر هذه المحفظة كوجهة التبرعات لحملتك.",
"walletUseMine": "استخدم محفظة Agora الخاصة بي",
"acceptAll": "قبول جميع أنواع الدفع",
"acceptPublic": "قبول الدفعات العامة فقط",
@@ -581,8 +583,8 @@
"wizard": {
"titleStepTitle": "سمِّ حملتك",
"titleStepSubtitle": "اسم قصير وواضح يتعرّف عليه المتبرعون.",
"walletStepTitle": "أين تذهب التبرعات؟",
"walletStepSubtitle": "اختر محفظة Agora أو ألصق عنوانك الخاص.",
"walletStepTitle": "اختر مَن يتلقى التبرعات",
"walletStepSubtitle": "محفظة Agora الخاصة بك جاهزة لتلقّي تبرعات Bitcoin لهذه الحملة.",
"bannerStepTitle": "أضف صورة بانر",
"bannerStepSubtitle": "صورة واحدة لافتة تُمثّل الحملة في كل بطاقة.",
"storyStepTitle": "احكِ قصتك",
@@ -744,7 +746,7 @@
"wlcDesc": "حملات منتقاة من World Liberty Congress.",
"allCampaigns": "كل الحملات",
"allCampaignsDesc": "كل الحملات على الشبكة، بالترتيب الزمني.",
"browseAll": "تصفّح كل الحملات",
"browseAll": "تصفّح كل الحملات",
"hidden": "مخفية",
"hiddenDesc": "حملات أُزيلت من الصفحة الرئيسية العامة. استخدم قائمة البطاقة لإلغاء الإخفاء.",
"hiddenEmpty": "لا توجد حملات مخفية حالياً.",
+20 -8
View File
@@ -936,7 +936,9 @@
"myWalletDefault": "My wallet",
"walletChoose": "Choose a wallet",
"walletCustom": "Custom wallet",
"walletUseCustom": "Use a custom wallet instead",
"walletUseCustom": "Use another wallet instead",
"walletDestinationLanding": "Donations will land here",
"walletDestinationNote": "This wallet will be published as the donation destination for your campaign.",
"walletUseMine": "Use my Agora wallet",
"acceptAll": "Accept all payment types",
"acceptPublic": "Accept public payments only",
@@ -1019,8 +1021,8 @@
"wizard": {
"titleStepTitle": "Name your campaign",
"titleStepSubtitle": "A short, clear name donors will recognize.",
"walletStepTitle": "Where do donations go?",
"walletStepSubtitle": "Pick your Agora wallet or paste your own address.",
"walletStepTitle": "Choose who receives donations",
"walletStepSubtitle": "Your Agora wallet is ready to receive Bitcoin donations for this campaign.",
"bannerStepTitle": "Add a banner",
"bannerStepSubtitle": "One striking image carries the campaign on every card.",
"storyStepTitle": "Tell your story",
@@ -1186,7 +1188,7 @@
"wlcDesc": "Campaigns curated by World Liberty Congress.",
"allCampaigns": "All campaigns",
"allCampaignsDesc": "Every campaign on the network, in chronological order.",
"browseAll": "Browse all campaigns",
"browseAll": "Browse all campaigns",
"searchPlaceholder": "Search campaigns\u2026",
"searchAriaLabel": "Search campaigns",
"noMatch": "No campaigns match \u201c{{query}}\u201d",
@@ -1654,17 +1656,27 @@
},
"scan": {
"title": "Scan for stranded payments",
"description": "Choose the block height to start scanning from. Recovery checks every block from there to the chain tip.",
"fromHeightLabel": "Start block height",
"tipHint": "Current chain tip: {{tip}}",
"description": "Choose how far back to scan. Recovery checks the blockchain from the selected time window to the present.",
"since": "Since",
"overrideActive": "Using the From block override below. Clear it to use a time window instead.",
"advanced": "Advanced",
"fromBlock": "From block",
"connectingIndexer": "Connecting to indexer…",
"tipHint": "Indexer tip: {{tip}}",
"recoveryWindowHint": "Default covers the known affected recovery window.",
"upToDate": "From block is past the chain tip — nothing to scan.",
"start": "Scan",
"cancel": "Cancel scan",
"progress": "Scanning block {{current}} of {{to}} — {{found}} found",
"tipMissing": "Resolving chain tip…"
},
"resolveFailed": {
"title": "Couldn't look up the start block",
"description": "mempool.space is unreachable right now. Enter a starting block under Advanced → From block to scan anyway."
},
"noFunds": {
"title": "Nothing to recover",
"description": "No stranded silent payments were found in the scanned range. Try an earlier start height if you expected funds."
"description": "No stranded silent payments were found in the scanned range. Try a longer time window if you expected funds."
},
"found": {
"title": "Stranded payments found",
+6 -4
View File
@@ -510,7 +510,9 @@
"myWalletDefault": "Mi cartera",
"walletChoose": "Elige una cartera",
"walletCustom": "Personalizada",
"walletUseCustom": "Usar una cartera personalizada",
"walletUseCustom": "Usar otra cartera",
"walletDestinationLanding": "Las donaciones llegarán aquí",
"walletDestinationNote": "Esta cartera se publicará como el destino de las donaciones de tu campaña.",
"walletUseMine": "Usar mi cartera de Agora",
"acceptAll": "Aceptar todos los pagos",
"acceptPublic": "Aceptar solo pagos públicos",
@@ -593,8 +595,8 @@
"wizard": {
"titleStepTitle": "Nombra tu campaña",
"titleStepSubtitle": "Un nombre corto y claro que los donantes reconocerán.",
"walletStepTitle": "¿Adónde van las donaciones?",
"walletStepSubtitle": "Elige tu cartera de Agora o pega tu propia dirección.",
"walletStepTitle": "Elige quién recibe las donaciones",
"walletStepSubtitle": "Tu cartera de Agora está lista para recibir donaciones en Bitcoin para esta campaña.",
"bannerStepTitle": "Añade una portada",
"bannerStepSubtitle": "Una imagen impactante acompaña a la campaña en cada tarjeta.",
"storyStepTitle": "Cuenta tu historia",
@@ -756,7 +758,7 @@
"wlcDesc": "Campañas curadas por el World Liberty Congress.",
"allCampaigns": "Todas las campañas",
"allCampaignsDesc": "Todas las campañas de la red, en orden cronológico.",
"browseAll": "Ver todas las campañas",
"browseAll": "Ver todas las campañas",
"hidden": "Ocultas",
"hiddenDesc": "Campañas suprimidas de la página de inicio pública. Usa el menú de la tarjeta para mostrarlas de nuevo.",
"hiddenEmpty": "No hay campañas ocultas actualmente.",
+6 -4
View File
@@ -510,7 +510,9 @@
"myWalletDefault": "کیف پول من",
"walletChoose": "یک کیف پول انتخاب کن",
"walletCustom": "سفارشی",
"walletUseCustom": "به جای آن از کیف پول سفارشی استفاده کن",
"walletUseCustom": "به جای آن از کیف پول دیگری استفاده کن",
"walletDestinationLanding": "اهدا‌ها اینجا می‌رسند",
"walletDestinationNote": "این کیف پول به عنوان مقصد اهدا‌های کمپین تو منتشر خواهد شد.",
"walletUseMine": "از کیف پول Agora من استفاده کن",
"acceptAll": "پذیرش همهٔ نوع‌های پرداخت",
"acceptPublic": "پذیرش فقط پرداخت‌های عمومی",
@@ -593,8 +595,8 @@
"wizard": {
"titleStepTitle": "به کمپینت نام بده",
"titleStepSubtitle": "نامی کوتاه و روشن که اهداکنندگان به‌یاد بسپارند.",
"walletStepTitle": "اهدا‌ها کجا بروند؟",
"walletStepSubtitle": "کیف پول آگورای خودت را انتخاب کن یا نشانی دلخواهت را وارد کن.",
"walletStepTitle": "انتخاب کن چه کسی اهدا‌ها را دریافت کند",
"walletStepSubtitle": "کیف پول آگورای تو آماده دریافت اهدا‌های Bitcoin برای این کمپین است.",
"bannerStepTitle": "یک بنر اضافه کن",
"bannerStepSubtitle": "یک تصویر گیرا، کمپین را روی هر کارت همراهی می‌کند.",
"storyStepTitle": "داستانت را تعریف کن",
@@ -756,7 +758,7 @@
"wlcDesc": "کمپین‌های گزینش‌شده توسط کنگرهٔ آزادی جهانی (World Liberty Congress).",
"allCampaigns": "همه کمپین‌ها",
"allCampaignsDesc": "همه کمپین‌های شبکه، به ترتیب زمانی.",
"browseAll": "مرور همه کمپین‌ها",
"browseAll": "مرور همه کمپین‌ها",
"hidden": "پنهان‌شده",
"hiddenDesc": "کمپین‌هایی که از صفحه اصلی عمومی حذف شده‌اند. برای آشکار کردن دوباره از منوی کارت استفاده کنید.",
"hiddenEmpty": "در حال حاضر هیچ کمپینی پنهان نشده است.",
+6 -4
View File
@@ -941,7 +941,9 @@
"myWalletDefault": "Mon portefeuille",
"walletChoose": "Choisir un portefeuille",
"walletCustom": "Personnalisé",
"walletUseCustom": "Utiliser un portefeuille personnalisé",
"walletUseCustom": "Utiliser un autre portefeuille",
"walletDestinationLanding": "Les dons arriveront ici",
"walletDestinationNote": "Ce portefeuille sera publié comme destination des dons pour votre campagne.",
"walletUseMine": "Utiliser mon portefeuille Agora",
"acceptAll": "Accepter tous les types de paiement",
"acceptPublic": "Accepter uniquement les paiements publics",
@@ -1024,8 +1026,8 @@
"wizard": {
"titleStepTitle": "Nommez votre campagne",
"titleStepSubtitle": "Un nom court et clair que les donateurs reconnaîtront.",
"walletStepTitle": "Où vont les dons ?",
"walletStepSubtitle": "Choisissez votre portefeuille Agora ou collez votre propre adresse.",
"walletStepTitle": "Choisissez qui reçoit les dons",
"walletStepSubtitle": "Votre portefeuille Agora est prêt à recevoir des dons en Bitcoin pour cette campagne.",
"bannerStepTitle": "Ajoutez une bannière",
"bannerStepSubtitle": "Une image marquante porte la campagne sur chaque carte.",
"storyStepTitle": "Racontez votre histoire",
@@ -1187,7 +1189,7 @@
"wlcDesc": "Campagnes sélectionnées par le World Liberty Congress.",
"allCampaigns": "Toutes les campagnes",
"allCampaignsDesc": "Toutes les campagnes du réseau, par ordre chronologique.",
"browseAll": "Parcourir toutes les campagnes",
"browseAll": "Parcourir toutes les campagnes",
"hidden": "Masquées",
"hiddenDesc": "Campagnes supprimées de la page d'accueil publique. Utilisez le menu en kebab d'une carte pour les démasquer.",
"hiddenEmpty": "Aucune campagne n'est actuellement masquée.",
+6 -4
View File
@@ -942,7 +942,9 @@
"myWalletDefault": "मेरा वॉलेट",
"walletChoose": "वॉलेट चुनें",
"walletCustom": "कस्टम",
"walletUseCustom": "इसके बजाय कस्टम वॉलेट का उपयोग करें",
"walletUseCustom": "इसके बजाय कोई दूसरा वॉलेट उपयोग करें",
"walletDestinationLanding": "डोनेशन यहाँ आएँगे",
"walletDestinationNote": "यह वॉलेट आपके कैंपेन के डोनेशन डेस्टिनेशन के रूप में प्रकाशित किया जाएगा।",
"walletUseMine": "मेरे Agora वॉलेट का उपयोग करें",
"acceptAll": "सभी भुगतान प्रकार स्वीकार करें",
"acceptPublic": "केवल सार्वजनिक भुगतान स्वीकार करें",
@@ -1025,8 +1027,8 @@
"wizard": {
"titleStepTitle": "अपने कैंपेन को नाम दें",
"titleStepSubtitle": "एक छोटा, स्पष्ट नाम जिसे डोनर पहचान सकें।",
"walletStepTitle": "डोनेशन कहाँ जाएँगे?",
"walletStepSubtitle": "अपना Agora वॉलेट चुनें या अपना एड्रेस पेस्ट करें।",
"walletStepTitle": "चुनें कि डोनेशन कौन प्राप्त करेगा",
"walletStepSubtitle": "आपका Agora वॉलेट इस कैंपेन के लिए Bitcoin डोनेशन प्राप्त करने को तैयार है।",
"bannerStepTitle": "एक बैनर जोड़ें",
"bannerStepSubtitle": "एक प्रभावशाली इमेज हर कार्ड पर कैंपेन को आगे बढ़ाती है।",
"storyStepTitle": "अपनी कहानी बताएँ",
@@ -1188,7 +1190,7 @@
"wlcDesc": "World Liberty Congress द्वारा चुने गए कैंपेन।",
"allCampaigns": "सभी कैंपेन",
"allCampaignsDesc": "नेटवर्क के सभी कैंपेन, कालक्रम के अनुसार।",
"browseAll": "सभी कैंपेन देखें",
"browseAll": "सभी कैंपेन देखें",
"hidden": "छुपा हुआ",
"hiddenDesc": "सार्वजनिक होमपेज से दबाए गए कैंपेन। कार्ड के kebab मेन्यू से अनहाइड करें।",
"hiddenEmpty": "अभी कोई कैंपेन छुपा हुआ नहीं है।",
+6 -4
View File
@@ -942,7 +942,9 @@
"myWalletDefault": "Dompet saya",
"walletChoose": "Pilih dompet",
"walletCustom": "Kustom",
"walletUseCustom": "Gunakan dompet kustom",
"walletUseCustom": "Gunakan dompet lain",
"walletDestinationLanding": "Donasi akan masuk ke sini",
"walletDestinationNote": "Dompet ini akan dipublikasikan sebagai tujuan donasi untuk kampanye Anda.",
"walletUseMine": "Gunakan dompet Agora saya",
"acceptAll": "Terima semua jenis pembayaran",
"acceptPublic": "Hanya terima pembayaran publik",
@@ -1025,8 +1027,8 @@
"wizard": {
"titleStepTitle": "Beri nama kampanye Anda",
"titleStepSubtitle": "Nama singkat dan jelas yang mudah dikenali donatur.",
"walletStepTitle": "Ke mana donasi akan masuk?",
"walletStepSubtitle": "Pilih dompet Agora Anda atau tempel alamat Anda sendiri.",
"walletStepTitle": "Pilih siapa yang menerima donasi",
"walletStepSubtitle": "Dompet Agora Anda siap menerima donasi Bitcoin untuk kampanye ini.",
"bannerStepTitle": "Tambahkan banner",
"bannerStepSubtitle": "Satu gambar menarik membawa kampanye di setiap kartu.",
"storyStepTitle": "Ceritakan kisah Anda",
@@ -1188,7 +1190,7 @@
"wlcDesc": "Kampanye yang dikurasi oleh World Liberty Congress.",
"allCampaigns": "Semua kampanye",
"allCampaignsDesc": "Semua kampanye di jaringan, dalam urutan kronologis.",
"browseAll": "Telusuri semua kampanye",
"browseAll": "Telusuri semua kampanye",
"hidden": "Tersembunyi",
"hiddenDesc": "Kampanye yang disembunyikan dari beranda publik. Gunakan menu kebab pada kartu untuk menampilkannya kembali.",
"hiddenEmpty": "Tidak ada kampanye yang sedang disembunyikan.",
+6 -4
View File
@@ -510,7 +510,9 @@
"myWalletDefault": "កាបូបរបស់ខ្ញុំ",
"walletChoose": "ជ្រើសរើសកាបូប",
"walletCustom": "ផ្ទាល់ខ្លួន",
"walletUseCustom": "ប្រើកាបូបផ្ទាល់ខ្លួនជំនួសវិញ",
"walletUseCustom": "ប្រើកាបូបផ្សេងជំនួសវិញ",
"walletDestinationLanding": "ការបរិច្ចាគនឹងមកដល់ទីនេះ",
"walletDestinationNote": "កាបូបនេះនឹងត្រូវបានផ្សាយជាគោលដៅនៃការបរិច្ចាគសម្រាប់យុទ្ធនាការរបស់អ្នក។",
"walletUseMine": "ប្រើកាបូប Agora របស់ខ្ញុំ",
"acceptAll": "ទទួលយកការទូទាត់គ្រប់ប្រភេទ",
"acceptPublic": "ទទួលយកការទូទាត់សាធារណៈតែប៉ុណ្ណោះ",
@@ -593,8 +595,8 @@
"wizard": {
"titleStepTitle": "ដាក់ឈ្មោះយុទ្ធនាការរបស់អ្នក",
"titleStepSubtitle": "ឈ្មោះខ្លី ច្បាស់លាស់ ដែលអ្នកបរិច្ចាគនឹងស្គាល់។",
"walletStepTitle": "តើការបរិច្ចាគទៅទីណា?",
"walletStepSubtitle": "ជ្រើសរើសកាបូប Agora របស់អ្នក ឬបិទភ្ជាប់អាសយដ្ឋានផ្ទាល់ខ្លួន។",
"walletStepTitle": "ជ្រើសរើសអ្នកដែលទទួលការបរិច្ចាគ",
"walletStepSubtitle": "កាបូប Agora របស់អ្នកត្រៀមរួចរាល់ដើម្បីទទួលការបរិច្ចាគ Bitcoin សម្រាប់យុទ្ធនាការនេះ។",
"bannerStepTitle": "បន្ថែមបដា",
"bannerStepSubtitle": "រូបភាពគួរឱ្យចាប់អារម្មណ៍មួយ នាំយុទ្ធនាការនៅលើកាតគ្រប់ទីកន្លែង។",
"storyStepTitle": "ប្រាប់រឿងរបស់អ្នក",
@@ -756,7 +758,7 @@
"wlcDesc": "យុទ្ធនាការដែលបានសម្រិតសម្រាំងដោយសភាសេរីភាពពិភពលោក (World Liberty Congress)។",
"allCampaigns": "យុទ្ធនាការទាំងអស់",
"allCampaignsDesc": "យុទ្ធនាការទាំងអស់នៅលើបណ្តាញ តាមលំដាប់កាលប្បវត្តិ។",
"browseAll": "មើលយុទ្ធនាការទាំងអស់",
"browseAll": "មើលយុទ្ធនាការទាំងអស់",
"hidden": "បានលាក់",
"hiddenDesc": "យុទ្ធនាការដែលត្រូវបានដកចេញពីទំព័រដើមសាធារណៈ។ ប្រើម៉ឺនុយលើកាតដើម្បីឈប់លាក់។",
"hiddenEmpty": "បច្ចុប្បន្នគ្មានយុទ្ធនាការត្រូវបានលាក់។",
+6 -4
View File
@@ -510,7 +510,9 @@
"myWalletDefault": "زما پاکټ",
"walletChoose": "پاکټ وټاکئ",
"walletCustom": "ګمرکي",
"walletUseCustom": "ګمرکي پاکټ وکاروئ",
"walletUseCustom": "پرځای يې بل پاکټ وکاروئ",
"walletDestinationLanding": "بسپنې به دلته راشي",
"walletDestinationNote": "دا پاکټ به ستاسو د کمپاین د بسپنو د منزل په توګه خپور شي.",
"walletUseMine": "زما د اګورا پاکټ وکاروئ",
"acceptAll": "د ټولو پیسو ډولونو منل",
"acceptPublic": "یوازې د عامه پیسو منل",
@@ -593,8 +595,8 @@
"wizard": {
"titleStepTitle": "خپل کمپاین ته نوم ورکړئ",
"titleStepSubtitle": "لنډ او روښانه نوم چې بسپنه ورکوونکي یې وپېژني.",
"walletStepTitle": "بسپنې چېرته ورځي؟",
"walletStepSubtitle": "خپل د اګورا پاکټ وټاکئ یا خپله پته ورپېسټ کړئ.",
"walletStepTitle": "وټاکئ چې بسپنې به څوک ترلاسه کوي",
"walletStepSubtitle": "ستاسو د اګورا پاکټ د دې کمپاین لپاره د Bitcoin بسپنو ترلاسه کولو ته چمتو دی.",
"bannerStepTitle": "بنر اضافه کړئ",
"bannerStepSubtitle": "یو زړه‌راښکونکی انځور په هر کارت کې کمپاین ښیي.",
"storyStepTitle": "خپله کیسه ووایاست",
@@ -756,7 +758,7 @@
"wlcDesc": "د World Liberty Congress لخوا غوره شوي کمپاینونه.",
"allCampaigns": "ټول کمپاینونه",
"allCampaignsDesc": "د شبکې ټول کمپاینونه، د وخت په ترتیب.",
"browseAll": "ټول کمپاینونه وګورئ",
"browseAll": "ټول کمپاینونه وګورئ",
"hidden": "پټ شوي",
"hiddenDesc": "هغه کمپاینونه چې د عمومي کور پاڼې څخه پټ شوي. د بېرته ښودلو لپاره د کارت مینو وکاروئ.",
"hiddenEmpty": "اوس مهال هیڅ کمپاین پټ نه دی.",
+6 -4
View File
@@ -942,7 +942,9 @@
"myWalletDefault": "Minha carteira",
"walletChoose": "Escolher uma carteira",
"walletCustom": "Personalizada",
"walletUseCustom": "Usar uma carteira personalizada",
"walletUseCustom": "Usar outra carteira",
"walletDestinationLanding": "As doações chegarão aqui",
"walletDestinationNote": "Esta carteira será publicada como o destino das doações da sua campanha.",
"walletUseMine": "Usar minha carteira Agora",
"acceptAll": "Aceitar todos os tipos de pagamento",
"acceptPublic": "Aceitar apenas pagamentos públicos",
@@ -1025,8 +1027,8 @@
"wizard": {
"titleStepTitle": "Dê um nome à sua campanha",
"titleStepSubtitle": "Um nome curto e claro que os doadores reconhecerão.",
"walletStepTitle": "Para onde vão as doações?",
"walletStepSubtitle": "Escolha sua carteira Agora ou cole seu próprio endereço.",
"walletStepTitle": "Escolha quem recebe as doações",
"walletStepSubtitle": "Sua carteira Agora está pronta para receber doações em Bitcoin para esta campanha.",
"bannerStepTitle": "Adicione um banner",
"bannerStepSubtitle": "Uma imagem marcante representa a campanha em cada card.",
"storyStepTitle": "Conte sua história",
@@ -1188,7 +1190,7 @@
"wlcDesc": "Campanhas selecionadas pelo World Liberty Congress.",
"allCampaigns": "Todas as campanhas",
"allCampaignsDesc": "Todas as campanhas da rede, em ordem cronológica.",
"browseAll": "Navegar por todas as campanhas",
"browseAll": "Navegar por todas as campanhas",
"hidden": "Ocultas",
"hiddenDesc": "Campanhas suprimidas da página inicial pública. Use o menu de três pontos em um cartão para reexibir.",
"hiddenEmpty": "Nenhuma campanha está oculta atualmente.",
+6 -4
View File
@@ -942,7 +942,9 @@
"myWalletDefault": "Мой кошелёк",
"walletChoose": "Выбрать кошелёк",
"walletCustom": "Пользовательский",
"walletUseCustom": "Использовать пользовательский кошелёк",
"walletUseCustom": "Использовать другой кошелёк",
"walletDestinationLanding": "Пожертвования будут поступать сюда",
"walletDestinationNote": "Этот кошелёк будет опубликован как адрес для пожертвований вашей кампании.",
"walletUseMine": "Использовать мой кошелёк Agora",
"acceptAll": "Принимать все типы платежей",
"acceptPublic": "Принимать только публичные платежи",
@@ -1025,8 +1027,8 @@
"wizard": {
"titleStepTitle": "Назовите свою кампанию",
"titleStepSubtitle": "Короткое и понятное название, которое запомнят доноры.",
"walletStepTitle": "Куда пойдут пожертвования?",
"walletStepSubtitle": "Выберите кошелёк Agora или вставьте свой адрес.",
"walletStepTitle": "Выберите, кто получает пожертвования",
"walletStepSubtitle": "Ваш кошелёк Agora готов принимать пожертвования в Bitcoin для этой кампании.",
"bannerStepTitle": "Добавьте баннер",
"bannerStepSubtitle": "Одно яркое изображение представит кампанию на каждой карточке.",
"storyStepTitle": "Расскажите свою историю",
@@ -1188,7 +1190,7 @@
"wlcDesc": "Кампании, отобранные World Liberty Congress.",
"allCampaigns": "Все кампании",
"allCampaignsDesc": "Все кампании в сети, в хронологическом порядке.",
"browseAll": "Просмотреть все кампании",
"browseAll": "Просмотреть все кампании",
"hidden": "Скрытые",
"hiddenDesc": "Кампании, скрытые с публичной главной страницы. Используйте меню с тремя точками на карточке, чтобы вернуть.",
"hiddenEmpty": "В настоящее время нет скрытых кампаний.",
+6 -4
View File
@@ -510,7 +510,9 @@
"myWalletDefault": "Chikwama changu",
"walletChoose": "Sarudza chikwama",
"walletCustom": "Chenyu",
"walletUseCustom": "Shandisa chikwama chako pachako",
"walletUseCustom": "Shandisa chimwe chikwama panzvimbo pacho",
"walletDestinationLanding": "Zvipo zvichasvika pano",
"walletDestinationNote": "Chikwama ichi chichaburitswa senzvimbo inoenda zvipo zvemushandirapamwe wako.",
"walletUseMine": "Shandisa chikwama changu cheAgora",
"acceptAll": "Gamuchira mhando dzese dzemubhadharo",
"acceptPublic": "Gamuchira chete mibhadharo yepachena",
@@ -593,8 +595,8 @@
"wizard": {
"titleStepTitle": "Tumidza campaign yako",
"titleStepSubtitle": "Zita pfupi, riri pachena, ravapi vachaziva.",
"walletStepTitle": "Zvipo zvinoenda kupi?",
"walletStepSubtitle": "Sarudza chikwama chako cheAgora kana unamatire kero yako.",
"walletStepTitle": "Sarudza ndiani anogamuchira zvipo",
"walletStepSubtitle": "Chikwama chako cheAgora chakagadzirira kugamuchira zvipo zveBitcoin zvemushandirapamwe uyu.",
"bannerStepTitle": "Wedzera bhana",
"bannerStepSubtitle": "Mufananidzo mumwe chete unotapira unotakura campaign pakadhi rega rega.",
"storyStepTitle": "Taura nyaya yako",
@@ -756,7 +758,7 @@
"wlcDesc": "Mishandirapamwe yakasarudzwa neWorld Liberty Congress.",
"allCampaigns": "Mishandirapamwe yose",
"allCampaignsDesc": "Mishandirapamwe yose pamutambo, neumboo wenguva.",
"browseAll": "Tarisa mishandirapamwe yose",
"browseAll": "Tarisa mishandirapamwe yose",
"hidden": "Yakavanzwa",
"hiddenDesc": "Mishandirapamwe yakabviswa papeji rekutanga reveruzhinji. Shandisa menu pakadhi kuti uibvise pakuvanzwa.",
"hiddenEmpty": "Parizvino hapana mushandirapamwe wakavanzwa.",
+6 -4
View File
@@ -941,7 +941,9 @@
"myWalletDefault": "Pochi yangu",
"walletChoose": "Chagua pochi",
"walletCustom": "Maalum",
"walletUseCustom": "Tumia pochi maalum badala yake",
"walletUseCustom": "Tumia pochi nyingine badala yake",
"walletDestinationLanding": "Michango itafika hapa",
"walletDestinationNote": "Pochi hii itachapishwa kama mahali pa kupokea michango ya kampeni yako.",
"walletUseMine": "Tumia pochi yangu ya Agora",
"acceptAll": "Kubali aina zote za malipo",
"acceptPublic": "Kubali malipo ya umma pekee",
@@ -1024,8 +1026,8 @@
"wizard": {
"titleStepTitle": "Ipe kampeni yako jina",
"titleStepSubtitle": "Jina fupi na wazi ambalo wafadhili watalitambua.",
"walletStepTitle": "Michango itaenda wapi?",
"walletStepSubtitle": "Chagua pochi yako ya Agora au bandika anwani yako mwenyewe.",
"walletStepTitle": "Chagua nani anayepokea michango",
"walletStepSubtitle": "Pochi yako ya Agora iko tayari kupokea michango ya Bitcoin kwa kampeni hii.",
"bannerStepTitle": "Ongeza bango",
"bannerStepSubtitle": "Picha moja yenye mvuto hubeba kampeni kwenye kila kadi.",
"storyStepTitle": "Eleza hadithi yako",
@@ -1187,7 +1189,7 @@
"wlcDesc": "Kampeni zilizoteuliwa na World Liberty Congress.",
"allCampaigns": "Kampeni zote",
"allCampaignsDesc": "Kampeni zote kwenye mtandao, kwa mpangilio wa wakati.",
"browseAll": "Vinjari kampeni zote",
"browseAll": "Vinjari kampeni zote",
"hidden": "Vilivyofichwa",
"hiddenDesc": "Kampeni zilizofichwa kutoka kwenye ukurasa wa mwanzo wa umma. Tumia menyu ya nukta tatu kwenye kadi ili kuonyesha.",
"hiddenEmpty": "Hakuna kampeni zilizofichwa kwa sasa.",
+6 -4
View File
@@ -941,7 +941,9 @@
"myWalletDefault": "Cüzdanım",
"walletChoose": "Bir cüzdan seçin",
"walletCustom": "Özel",
"walletUseCustom": "Bunun yerine özel bir cüzdan kullan",
"walletUseCustom": "Bunun yerine başka bir cüzdan kullan",
"walletDestinationLanding": "Bağışlar buraya ulaşacak",
"walletDestinationNote": "Bu cüzdan, kampanyanızın bağış adresi olarak yayımlanacak.",
"walletUseMine": "Agora cüzdanımı kullan",
"acceptAll": "Tüm ödeme türlerini kabul et",
"acceptPublic": "Yalnızca açık ödemeleri kabul et",
@@ -1024,8 +1026,8 @@
"wizard": {
"titleStepTitle": "Kampanyanıza isim verin",
"titleStepSubtitle": "Bağışçıların tanıyacağı kısa ve net bir ad.",
"walletStepTitle": "Bağışlar nereye gidecek?",
"walletStepSubtitle": "Agora cüzdanınızı seçin ya da kendi adresinizi yapıştırın.",
"walletStepTitle": "Bağışları kimin alacağını seçin",
"walletStepSubtitle": "Agora cüzdanınız bu kampanya için Bitcoin bağışları almaya hazır.",
"bannerStepTitle": "Bir pankart ekleyin",
"bannerStepSubtitle": "Çarpıcı tek bir görsel, kampanyayı her kartta öne çıkarır.",
"storyStepTitle": "Hikâyenizi anlatın",
@@ -1187,7 +1189,7 @@
"wlcDesc": "World Liberty Congress tarafından özenle seçilmiş kampanyalar.",
"allCampaigns": "Tüm kampanyalar",
"allCampaignsDesc": "Ağdaki tüm kampanyalar, kronolojik sırayla.",
"browseAll": "Tüm kampanyalara göz at",
"browseAll": "Tüm kampanyalara göz at",
"hidden": "Gizli",
"hiddenDesc": "Herkese açık ana sayfadan gizlenmiş kampanyalar. Gizlemeyi kaldırmak için karttaki kebap menüsünü kullanın.",
"hiddenEmpty": "Şu anda gizlenmiş kampanya yok.",
+6 -4
View File
@@ -510,7 +510,9 @@
"myWalletDefault": "我的錢包",
"walletChoose": "選擇錢包",
"walletCustom": "自定義",
"walletUseCustom": "改用自定義錢包",
"walletUseCustom": "改用其他錢包",
"walletDestinationLanding": "捐款將會送到這裡",
"walletDestinationNote": "這個錢包將會被發佈為你活動的捐款目的地。",
"walletUseMine": "使用我的 Agora 錢包",
"acceptAll": "接受所有支付型別",
"acceptPublic": "僅接受公開支付",
@@ -593,8 +595,8 @@
"wizard": {
"titleStepTitle": "為你的活動命名",
"titleStepSubtitle": "簡短、清晰,讓捐贈者一眼就能認出。",
"walletStepTitle": "捐款要送到哪裡?",
"walletStepSubtitle": "選擇你的 Agora 錢包,或貼上自己的地址。",
"walletStepTitle": "選擇由誰接收捐款",
"walletStepSubtitle": "你的 Agora 錢包已準備好為這個活動接收 Bitcoin 捐款。",
"bannerStepTitle": "新增橫幅",
"bannerStepSubtitle": "一張搶眼的圖片,讓你的活動在每張卡片上脫穎而出。",
"storyStepTitle": "說說你的故事",
@@ -756,7 +758,7 @@
"wlcDesc": "由世界自由大會(World Liberty Congress)精選的活動。",
"allCampaigns": "所有活動",
"allCampaignsDesc": "網絡上的所有活動,按時間順序排列。",
"browseAll": "瀏覽所有活動",
"browseAll": "瀏覽所有活動",
"hidden": "已隱藏",
"hiddenDesc": "已從公共首頁隱藏的活動。使用卡片上的選單可以取消隱藏。",
"hiddenEmpty": "當前沒有被隱藏的活動。",
+6 -4
View File
@@ -510,7 +510,9 @@
"myWalletDefault": "我的钱包",
"walletChoose": "选择钱包",
"walletCustom": "自定义",
"walletUseCustom": "改用自定义钱包",
"walletUseCustom": "改用其他钱包",
"walletDestinationLanding": "捐款将会送到这里",
"walletDestinationNote": "这个钱包将会被发布为你活动的捐款目的地。",
"walletUseMine": "使用我的 Agora 钱包",
"acceptAll": "接受所有支付类型",
"acceptPublic": "仅接受公开支付",
@@ -593,8 +595,8 @@
"wizard": {
"titleStepTitle": "为你的活动起个名字",
"titleStepSubtitle": "一个简短清晰、捐赠者易于辨识的名称。",
"walletStepTitle": "捐款流向何处?",
"walletStepSubtitle": "选择你的 Agora 钱包,或粘贴你自己的地址。",
"walletStepTitle": "选择由谁接收捐款",
"walletStepSubtitle": "你的 Agora 钱包已准备好为这个活动接收 Bitcoin 捐款。",
"bannerStepTitle": "添加横幅",
"bannerStepSubtitle": "一张醒目的图片,让活动在每张卡片上脱颖而出。",
"storyStepTitle": "讲述你的故事",
@@ -756,7 +758,7 @@
"wlcDesc": "由世界自由大会(World Liberty Congress)精选的活动。",
"allCampaigns": "所有活动",
"allCampaignsDesc": "网络上的所有活动,按时间顺序排列。",
"browseAll": "浏览所有活动",
"browseAll": "浏览所有活动",
"hidden": "已隐藏",
"hiddenDesc": "已从公共首页隐藏的活动。使用卡片上的菜单可以取消隐藏。",
"hiddenEmpty": "当前没有被隐藏的活动。",
+4 -9
View File
@@ -22,7 +22,6 @@ import { StartCampaignLink } from '@/components/StartCampaignLink';
import { useAuthor } from '@/hooks/useAuthor';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useCampaignList } from '@/hooks/useCampaignLists';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { useAppContext } from '@/hooks/useAppContext';
@@ -127,23 +126,19 @@ export function CampaignsPage() {
: { coordinates: [] },
);
// Filter out hidden campaigns and reorder to match the list's
// declared order. `useCampaigns` returns events in network order
// which we override here so the hero row always reflects the
// moderator's intent.
const { data: moderation } = useCampaignModeration();
// Reorder to match the list's declared order. `useCampaigns` returns
// events in network order which we override here so the hero row always
// reflects the moderator's intent.
const orderedCampaigns = useMemo<ParsedCampaign[]>(() => {
if (!heroCampaigns || cappedCoords.length === 0) return [];
const hidden = moderation?.hiddenCoords ?? new Set<string>();
const byCoord = new Map(heroCampaigns.map((c) => [c.aTag, c]));
const out: ParsedCampaign[] = [];
for (const coord of cappedCoords) {
if (hidden.has(coord)) continue;
const found = byCoord.get(coord);
if (found) out.push(found);
}
return out;
}, [heroCampaigns, cappedCoords, moderation]);
}, [heroCampaigns, cappedCoords]);
useSeoMeta({
title: `${t('campaigns.home.seoTitle')} | ${config.appName}`,
+59 -20
View File
@@ -9,6 +9,7 @@ import { nip19 } from 'nostr-tools';
import {
AlertTriangle,
ArrowLeft,
Bitcoin,
ChevronDown,
HandHeart,
HelpCircle,
@@ -889,8 +890,7 @@ export function CreateCampaignPage() {
</FormSection>
);
const walletSection = (
<FormSection title={t('campaignsCreate.wallet')} requirement="Required">
const walletPicker = (
<WalletPicker
hdWalletAvailable={hdWalletAvailable}
silentPaymentSupported={silentPaymentSupported}
@@ -909,6 +909,15 @@ export function CreateCampaignPage() {
onCustomSpChange={setCustomSp}
parsedCustomSp={parsedCustomSp}
/>
);
// Edit mode keeps the picker inside a titled FormSection so it lines up
// with the other single-page sections. The wizard step (below) renders
// the picker bare — its centered title/subtitle already frame the step,
// so a second "Bitcoin wallet · Required" header would only compete.
const walletSection = (
<FormSection title={t('campaignsCreate.wallet')} requirement="Required">
{walletPicker}
</FormSection>
);
@@ -1166,7 +1175,7 @@ export function CreateCampaignPage() {
{
title: t('campaignsCreate.wizard.walletStepTitle'),
subtitle: t('campaignsCreate.wizard.walletStepSubtitle'),
body: walletSection,
body: walletPicker,
},
{
title: t('campaignsCreate.wizard.bannerStepTitle'),
@@ -1336,13 +1345,20 @@ function WalletPicker({
return (
<div className="space-y-4">
{walletSource === 'mine' ? (
<>
/* One calm card carries the whole decision — identity, the
accept-mode choice, a reassuring note, and the soft swap to a
custom wallet — so the step reads as a single guided choice
rather than a stack of form sections. Mirrors the onboarding
"save your key" card's warm primary-tinted treatment so the
wallet decision feels just as carefully guided. */
<Card className="rounded-xl border-2 border-primary/30 bg-primary/10 shadow-md ring-1 ring-primary/10">
<CardContent className="space-y-4 p-4">
{/* Identity + balance row. Pure visual chrome — the swap to
custom mode is handled by the "Use a custom wallet"
custom mode is handled by the "Use another wallet"
sub-link below so the row reads as confirmation of the
destination, not as a tappable target. */}
<div className="flex items-center gap-3 px-1 py-2">
<Avatar className="size-10 shrink-0">
<div className="flex items-center gap-4">
<Avatar className="size-12 shrink-0">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
@@ -1364,17 +1380,22 @@ function WalletPicker({
</p>
)}
</div>
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary ring-2 ring-primary/20">
<Bitcoin className="size-5" />
</span>
</div>
{/* "Use a custom wallet" sub-link — the only affordance for
swapping to custom mode. */}
<button
type="button"
onClick={() => onWalletSourceChange('custom')}
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:underline"
>
{t('campaignsCreate.walletUseCustom')}
</button>
{/* Destination preview inset, in the spirit of the onboarding
masked-key panel: a lighter inset on the warm card that
states plainly where donations land. */}
<div className="rounded-lg border border-primary/20 bg-background/60 p-3 text-center dark:bg-black/20">
<p className="text-sm font-medium text-foreground">
{t('campaignsCreate.walletDestinationLanding')}
</p>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
{t('campaignsCreate.walletDestinationNote')}
</p>
</div>
{/* Accept-mode segmented picker. Default 'all' (HD + SP); the
non-SP options are only relevant if SP is unsupported. */}
@@ -1383,7 +1404,18 @@ function WalletPicker({
onChange={onMineAcceptChange}
silentPaymentSupported={silentPaymentSupported}
/>
</>
{/* "Use another wallet" — soft secondary action, the only
affordance for swapping to custom mode. */}
<button
type="button"
onClick={() => onWalletSourceChange('custom')}
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:underline"
>
{t('campaignsCreate.walletUseCustom')}
</button>
</CardContent>
</Card>
) : (
<>
{/* Header — name the current mode, then offer the swap back
@@ -1453,6 +1485,13 @@ function AcceptModePicker({
}) {
const { t } = useTranslation();
// Pills sit on the warm primary-tinted wallet card, so the unselected
// state gets an inset surface to stay legible, and the selected state
// is a solid primary fill (a faint tint would vanish on the tinted
// card). Behavior/labels are unchanged.
const acceptItemClass =
'h-auto justify-center rounded-full border-primary/20 bg-background/60 px-3 py-2 text-xs font-medium data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground';
const caption = {
all: t('campaignsCreate.acceptAllHint'),
public: t('campaignsCreate.acceptPublicHint'),
@@ -1477,20 +1516,20 @@ function AcceptModePicker({
<ToggleGroupItem
value="all"
disabled={!silentPaymentSupported}
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
className={acceptItemClass}
>
{t('campaignsCreate.acceptAllShort')}
</ToggleGroupItem>
<ToggleGroupItem
value="public"
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
className={acceptItemClass}
>
{t('campaignsCreate.acceptPublicShort')}
</ToggleGroupItem>
<ToggleGroupItem
value="private"
disabled={!silentPaymentSupported}
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
className={acceptItemClass}
>
{t('campaignsCreate.acceptPrivateShort')}
</ToggleGroupItem>
+244 -25
View File
@@ -1,10 +1,12 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronUp,
Loader2,
Search,
Wallet as WalletIcon,
@@ -13,10 +15,18 @@ import {
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { LoginArea } from '@/components/auth/LoginArea';
import { PageHeader } from '@/components/PageHeader';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useHdWallet } from '@/hooks/useHdWallet';
@@ -35,6 +45,62 @@ type Step = 'idle' | 'sweeping' | 'success' | 'error';
/** sat/vB — conservative default for the recovery sweep. */
const SWEEP_FEE_RATE = 5;
// ---------------------------------------------------------------------------
// "Since" presets — same pattern as HDSilentPaymentScanDialog
// ---------------------------------------------------------------------------
const PRESETS = {
lastHour: { seconds: 60 * 60 },
last3h: { seconds: 3 * 60 * 60 },
last24h: { seconds: 24 * 60 * 60 },
lastWeek: { seconds: 7 * 24 * 60 * 60 },
lastMonth: { seconds: 30 * 24 * 60 * 60 },
} as const;
type PresetId = keyof typeof PRESETS;
const CUSTOM_SINCE = 'custom' as const;
type SinceId = PresetId | typeof CUSTOM_SINCE;
const PRESET_ORDER: PresetId[] = ['lastHour', 'last3h', 'last24h', 'lastWeek', 'lastMonth'];
const SINCE_ORDER: SinceId[] = [...PRESET_ORDER, CUSTOM_SINCE];
const DEFAULT_SINCE: SinceId = 'lastMonth';
/**
* BIP-113 median-time-past safety margin — same 11-block rewind used
* by the regular SP scan dialog to account for out-of-order timestamps.
*/
const TIME_RESOLUTION_SAFETY_BLOCKS = 11;
const MEMPOOL_TIMESTAMP_BLOCK_URL = 'https://mempool.space/api/v1/mining/blocks/timestamp';
interface MempoolTimestampBlockResponse {
height?: unknown;
}
async function fetchMempoolTimestampBlockHeight(cutoffTime: number): Promise<number> {
const response = await fetch(`${MEMPOOL_TIMESTAMP_BLOCK_URL}/${cutoffTime}`);
if (!response.ok) {
throw new Error(`mempool.space timestamp lookup returned ${response.status}`);
}
const data = (await response.json()) as MempoolTimestampBlockResponse;
if (typeof data.height !== 'number' || !Number.isInteger(data.height) || data.height < 0) {
throw new Error('mempool.space timestamp lookup missing valid block height');
}
return data.height;
}
async function resolveWindowFromHeight(
windowSeconds: number,
tipHeight: number,
): Promise<number> {
const cutoffTime = Math.floor(Date.now() / 1000) - windowSeconds;
let boundary = await fetchMempoolTimestampBlockHeight(cutoffTime);
boundary = Math.min(boundary, tipHeight);
return Math.max(0, boundary - TIME_RESOLUTION_SAFETY_BLOCKS);
}
// ---------------------------------------------------------------------------
/**
* Recovery page at `/wallet/double-tweak-fix`.
*
@@ -58,7 +124,13 @@ export function WalletDoubleTweakFixPage() {
const blockbookUrl = (config.blockbookBaseUrl ?? '').trim();
const destinationAddress = wallet.currentReceiveAddress?.address;
const [fromHeight, setFromHeight] = useState(String(recovery.defaultFromHeight));
const [since, setSince] = useState<SinceId>(DEFAULT_SINCE);
const [customHours, setCustomHours] = useState('');
// Pre-populate with the known recovery-era start block so the first scan
// covers every possible stranded output without depending on mempool.space.
const [fromOverride, setFromOverride] = useState(String(recovery.defaultFromHeight));
const [advancedOpen, setAdvancedOpen] = useState(true);
const [isResolvingSince, setIsResolvingSince] = useState(false);
const [step, setStep] = useState<Step>('idle');
const [error, setError] = useState<string | null>(null);
const [txid, setTxid] = useState<string | null>(null);
@@ -69,21 +141,82 @@ export function WalletDoubleTweakFixPage() {
description: t('walletDoubleTweak.seoDescription'),
});
const fromHeightNum = useMemo(() => {
const n = parseInt(fromHeight, 10);
return Number.isInteger(n) && n >= 0 ? n : undefined;
}, [fromHeight]);
// Parse Advanced → From block override.
const overrideTrimmed = fromOverride.trim();
const overrideParsed = overrideTrimmed === '' ? undefined : Number(overrideTrimmed);
const overrideValid =
overrideTrimmed === '' ||
(Number.isInteger(overrideParsed) && (overrideParsed as number) >= 0);
const effectiveFrom = overrideTrimmed !== '' ? overrideParsed : undefined;
// Parse Custom hours input.
const customTrimmed = customHours.trim();
const customParsed = customTrimmed === '' ? undefined : Number(customTrimmed);
const customValid =
customTrimmed === '' ||
(typeof customParsed === 'number' &&
Number.isFinite(customParsed) &&
(customParsed as number) > 0);
const customSeconds =
typeof customParsed === 'number' && customValid && customParsed > 0
? Math.round(customParsed * 60 * 60)
: undefined;
const tipHeight = recovery.tipHeight;
// If the manual override exceeds the tip, there's nothing to scan.
const isManualUpToDate =
tipHeight !== undefined && effectiveFrom !== undefined && effectiveFrom > tipHeight;
const sinceReady = since === CUSTOM_SINCE ? customSeconds !== undefined : true;
const canStart =
overrideValid &&
customValid &&
(overrideTrimmed !== '' ? effectiveFrom !== undefined : tipHeight !== undefined) &&
sinceReady &&
!isManualUpToDate &&
!recovery.isScanning &&
!isResolvingSince;
async function runScan() {
if (fromHeightNum === undefined) return;
if (!canStart) return;
setStep('idle');
setError(null);
setTxid(null);
setSweptSats(null);
// If the user filled in a manual block height override, use it directly.
if (overrideTrimmed !== '') {
if (effectiveFrom === undefined) return;
try {
await recovery.scan({ fromHeight: fromHeightNum });
await recovery.scan({ fromHeight: effectiveFrom });
} catch (err) {
logger.error('[DoubleTweakFix] scan failed', err);
}
return;
}
if (tipHeight === undefined) return;
// Resolve the Since preset / custom hours to a window in seconds.
const windowSeconds =
since === CUSTOM_SINCE ? customSeconds : PRESETS[since].seconds;
if (windowSeconds === undefined) return;
setIsResolvingSince(true);
try {
const fromHeight = await resolveWindowFromHeight(windowSeconds, tipHeight);
await recovery.scan({ fromHeight });
} catch {
toast({
title: t('walletDoubleTweak.resolveFailed.title'),
description: t('walletDoubleTweak.resolveFailed.description'),
variant: 'destructive',
});
setAdvancedOpen(true);
} finally {
setIsResolvingSince(false);
}
}
async function runSweep() {
@@ -202,28 +335,113 @@ export function WalletDoubleTweakFixPage() {
<CardDescription>{t('walletDoubleTweak.scan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Primary control: relative time window.
Disabled when the From block override is filled — the override
takes priority and this dropdown would be ignored. */}
<div className="space-y-1.5">
<Label htmlFor="dt-from-height" className="text-xs">
{t('walletDoubleTweak.scan.fromHeightLabel')}
<Label htmlFor="dt-scan-since" className="text-xs">
{t('walletDoubleTweak.scan.since')}
</Label>
<Input
id="dt-from-height"
inputMode="numeric"
value={fromHeight}
onChange={(e) => setFromHeight(e.target.value.replace(/[^0-9]/g, ''))}
placeholder={
recovery.defaultFromHeight !== undefined
? String(recovery.defaultFromHeight)
: '—'
}
disabled={recovery.isScanning}
/>
{recovery.tipHeight !== undefined && (
<Select
value={since}
onValueChange={(v) => setSince(v as SinceId)}
disabled={recovery.isScanning || isResolvingSince || overrideTrimmed !== ''}
>
<SelectTrigger id="dt-scan-since">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SINCE_ORDER.map((id) => (
<SelectItem key={id} value={id}>
{t(`spScan.preset.${id}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{overrideTrimmed !== '' && (
<p className="text-[11px] text-muted-foreground">
{t('walletDoubleTweak.scan.tipHint', { tip: recovery.tipHeight.toLocaleString() })}
{t('walletDoubleTweak.scan.overrideActive')}
</p>
)}
{since === CUSTOM_SINCE && overrideTrimmed === '' && (
<div className="pt-1.5 space-y-1.5">
<Label htmlFor="dt-scan-custom-hours" className="text-xs">
{t('spScan.customHours')}
</Label>
<Input
id="dt-scan-custom-hours"
type="number"
inputMode="decimal"
min={0}
step="any"
placeholder={t('spScan.customHoursPlaceholder')}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
disabled={recovery.isScanning || isResolvingSince}
aria-invalid={!customValid}
/>
</div>
)}
</div>
{/* Advanced disclosure — From block override for power users. */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm cursor-pointer"
>
{advancedOpen ? (
<ChevronUp className="size-3" />
) : (
<ChevronDown className="size-3" />
)}
{t('walletDoubleTweak.scan.advanced')}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
<div className="space-y-1.5">
<Label htmlFor="dt-from-block" className="text-xs">
{t('walletDoubleTweak.scan.fromBlock')}
</Label>
<Input
id="dt-from-block"
type="number"
inputMode="numeric"
min={0}
value={fromOverride}
onChange={(e) => setFromOverride(e.target.value)}
disabled={recovery.isScanning || isResolvingSince}
aria-invalid={!overrideValid}
/>
</div>
{tipHeight !== undefined && (
<p className="text-xs text-muted-foreground">
{t('walletDoubleTweak.scan.tipHint', { tip: tipHeight.toLocaleString() })}
</p>
)}
{overrideTrimmed !== '' && overrideValid && !isManualUpToDate && (
<p className="text-xs text-muted-foreground">
{t('walletDoubleTweak.scan.recoveryWindowHint')}
</p>
)}
{isManualUpToDate && (
<p className="text-xs text-muted-foreground">
{t('walletDoubleTweak.scan.upToDate')}
</p>
)}
</CollapsibleContent>
</Collapsible>
{/* Disabled-state hints. */}
{!recovery.isScanning && tipHeight === undefined && overrideTrimmed === '' && (
<p className="text-xs text-muted-foreground">
{t('walletDoubleTweak.scan.connectingIndexer')}
</p>
)}
{recovery.isScanning ? (
<div className="space-y-2">
@@ -245,8 +463,9 @@ export function WalletDoubleTweakFixPage() {
<Button
className="w-full"
onClick={runScan}
disabled={fromHeightNum === undefined}
disabled={!canStart}
>
{isResolvingSince && <Loader2 className="size-4 animate-spin mr-1.5" />}
<Search className="size-4 mr-1.5" />
{t('walletDoubleTweak.scan.start')}
</Button>