Compare commits

...

30 Commits

Author SHA1 Message Date
Chad Curtis 1c06e070cd release: v2.8.3 2026-06-02 03:22:02 -05:00
Chad Curtis f0c3ff1a80 android: raise minSdk to 26 for barcode-scanner plugin
@capacitor/barcode-scanner v3 pulls in ionbarcode-android:2.0.1, which
declares minSdk 26. The inherited Ditto minSdk of 24 fails the manifest
merger. Raise the floor to 26 (Android 8.0) as the merger recommends.
2026-06-02 03:21:22 -05:00
Chad Curtis 13a0bb3e3a release: v2.8.2 2026-06-02 03:09:16 -05:00
Chad Curtis 646ed9777f ci: pass alias key password to keytool keystore migration
The build-apk JKS->PKCS12 migration only supplied the store password,
so keytool prompted for the upload key's distinct password on the
non-interactive runner and failed with 'Too many failures - try later'.
Pass -srckeypass/-destkeypass ($KEY_PASSWORD) to match key.properties.
2026-06-02 03:07:04 -05:00
Chad Curtis 437613641a release: v2.8.1 2026-06-02 03:02:12 -05:00
Chad Curtis d0836328a4 qrcode: stop fixed pixel styles overflowing the container
The qrcode library hard-codes inline width/height pixel styles on the
canvas, overriding the Tailwind sizing classes (h-auto w-full) callers
pass in. On viewports narrower than the QR's intrinsic size this made
the code spill outside its rounded box — visible on the campaign
details donate panel. Remove the inline styles after rendering so the
caller's className controls the responsive size.
2026-06-02 02:02:58 -05:00
Chad Curtis 123f53e7a6 campaigns: carry public/private framing into custom wallet mode
Switching to a custom (manual-entry) wallet used to drop the friendly
accept picker entirely, leaving two unlabeled-purpose address inputs.
Restore the hand-holding: add an intro line restating the field-driven
model (public address, private code, or both) and label each input
with its meaning. The public/on-chain input is marked with a Bitcoin
icon and a 'Public. Anyone can see these donations.' caption; the
silent-payment input with an EyeOff icon and a privacy caption. Both
inputs keep the Wallet leading icon. Updates all 16 locales.
2026-06-02 01:59:44 -05:00
Chad Curtis 977fd000ea campaigns: make the donation-accept picker friendlier
Replace the three terse jargon pills (Accept All / Public Only /
Private Only, captioned with 'on-chain' and 'silent payment') with a
vertical stack of selectable option cards. Each card has a friendly
icon, a plain-language title, and a one-sentence reassurance written
for an anxious first-time creator, with the SP-dependent options
clearly disabled when the login can't support them.

Also softens the wallet hero card: drop the linked-icon trio for a
simple campaign-to-wallet arrow, and rewrite the copy without the
key/posts technical aside or em dashes. Updates all 16 locales.
2026-06-02 01:59:44 -05:00
Chad Curtis 5132141aa2 campaigns: give the wallet step a hero coupling card
Redesign the 'My wallet' branch of the campaign wizard's donation
destination step. Replace the plain identity+balance row with a
primary-tinted hero card modelled on the onboarding 'Save your key'
surface: a linked-icon trio (campaign -> key -> wallet) explains that
donations land in the creator's own Agora wallet unlocked by the same
key that signs their posts, with the avatar+live-balance chip
confirming the exact destination and a ShieldCheck reassurance line
below.
2026-06-02 01:59:44 -05:00
Chad Curtis b6dc57eb85 Merge branch 'feat/send-MAX' into 'main'
Feat/send max

See merge request soapbox-pub/agora!40
2026-06-02 06:36:55 +00:00
Chad Curtis 016a7b4a7d Constrain event detail pages to max-w-3xl
Both PostDetailShell (Nostr event details) and ExternalContentPage
(NIP-73 external content like bitcoin:tx) rendered their <main> with no
max-width under the wide layout, stretching edge-to-edge on large
screens. Add w-full max-w-3xl mx-auto to match the narrow-layout column
width used elsewhere.
2026-06-02 01:30:55 -05:00
mkfain 7ae63883e9 campaigns: surface 'Add to list' in the detail-page three-dots menu 2026-06-02 00:28:08 +02:00
mkfain d4cf4ba0d8 campaigns: collapse curated lists strip after first 5 pills 2026-06-02 00:28:08 +02:00
Chad Curtis 399dc53395 Merge branch 'remove-pin' into 'main'
Remove redundant campaign location pin icons

See merge request soapbox-pub/agora!39
2026-06-01 22:26:29 +00:00
filemon 699e505fb5 Merge remote-tracking branch 'origin/main' into remove-pin
# Conflicts:
#	src/components/CampaignCard.tsx
2026-06-02 00:23:07 +02:00
lemon 20839f4de3 wallet: match silent inputs without prevout index 2026-06-01 15:18:13 -07:00
lemon 4e9da2d168 wallet: prune rediscovered spent silent outputs 2026-06-01 15:18:13 -07:00
lemon 32b477bd01 wallet: refresh history after pruning silent inputs 2026-06-01 15:18:13 -07:00
lemon 564459e12d wallet: disable send button when balance is unspendable 2026-06-01 15:18:13 -07:00
lemon c97d0723a6 wallet: add max send option 2026-06-01 15:18:12 -07:00
filemon 53da626461 Remove redundant campaign location pin icons
Regression-of: ba2c541c
2026-06-02 00:13:35 +02:00
Chad Curtis c79699ca71 campaigns: remove deadline from events and form
Drop the optional `deadline` tag from kind 33863 campaigns. Removes the
date input and validation from the create/edit form, the deadline chips
on the card, detail, and inline-preview surfaces, and the derived
"ended" state that disabled donations after the deadline. Cleans up the
associated locale keys and NIP.md documentation.
2026-06-01 17:03:30 -05:00
Alex Gleason e58c031a85 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-06-01 23:17:58 +02:00
Alex Gleason bc80dba826 home: fan out single-relay queries to fix load waterfall
The home page serialized its first paint behind relay.ditto.pub:
useCampaignLists queried that one relay via nostr.relay(DITTO_RELAY)
(awaited, up to an 8s timeout) and every hero campaign was gated on
its result, so a slow ditto.pub stalled the whole page. Connection
sharing made it worse — pooled queries multiplexed onto the same
stalled socket.

Switch the home-critical moderation/list/discovery hooks from
single-relay nostr.relay(DITTO_RELAY) calls to the parallel pooled
nostr.query() fan-out:

- useCampaignLists: authors:[curator] filter enforces trust; relay
  pin was unnecessary and headed the waterfall.
- useCampaignModeration: authors:[moderators] filter enforces trust.
- useFeaturedOrganizations: per-author filters enforce curation.
- useDiscoverCommunities: global discovery — fan-out broadens coverage.

useDashboardCounts stays pinned: NIP-45 COUNT is a single-relay
primitive and isn't mergeable across relays, and it's off the home
critical path.

Regression-of: 3d825aef
2026-06-01 23:16:45 +02:00
Chad Curtis 611f97488e home: drop label-based hidden filter from the WLC hero row
The home page hero row is already a moderator-curated kind-30003 list,
so re-filtering its members through the agora.moderation hide axis was
redundant: a campaign that shouldn't appear simply shouldn't be on the
list. The hidden-filter only mattered in the narrow window where a
listed campaign also carried a moderator hidden label, and it cost an
extra limit:2000 kind-1985 query to DITTO_RELAY on every landing-page
load for that edge case.

Render the curated list verbatim, in list order. Label-based hidden
moderation still lives on /campaigns and every other surface; only the
home hero row stops consulting it.
2026-06-01 16:04:22 -05:00
Alex Gleason a948725245 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 22:51:19 +02:00
mkfain dde9865284 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 22:41:06 +02:00
Chad Curtis 3d825aef04 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 15:36:08 -05:00
Chad Curtis 575603554b 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 15:36:08 -05:00
Alex Gleason dfb0a52603 Upgrade Nostrify 2026-06-01 22:20:43 +02:00
49 changed files with 1275 additions and 804 deletions
+7
View File
@@ -169,6 +169,11 @@ build-apk:
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility
- echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/my-upload-key.jks
# Pass the alias key password explicitly via -srckeypass / -destkeypass.
# The upload key inside the JKS has its own password ($KEY_PASSWORD) that
# differs from the store password ($KEYSTORE_PASSWORD); without these flags
# keytool prompts for it on a non-interactive runner and dies with
# "Too many failures - try later".
- keytool -importkeystore
-srckeystore android/app/my-upload-key.jks
-destkeystore android/app/my-upload-key.keystore
@@ -177,6 +182,8 @@ build-apk:
-deststorepass "$KEYSTORE_PASSWORD"
-srcalias upload
-destalias upload
-srckeypass "$KEY_PASSWORD"
-destkeypass "$KEY_PASSWORD"
-noprompt
- rm android/app/my-upload-key.jks
+32
View File
@@ -1,5 +1,37 @@
# Changelog
## [2.8.3] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.2] - 2026-06-02
A maintenance release that fixes the Android build pipeline so signed releases publish correctly. No user-facing changes.
## [2.8.1] - 2026-06-02
Agora becomes a home for putting your money where your heart is. Launch and back fundraising campaigns, rally around organizations with their own events and pledges, and send support straight from a built-in Bitcoin and Lightning wallet. Explore the world through immersive country pages, chat with a new AI agent, and move through a faster, cleaner app with a fresh look throughout.
### Added
- Fundraising campaigns as the new home surface — create, edit, and back campaigns, set goals, add beneficiaries, and follow progress.
- Organizations with their own events, pledges, members, and moderation tools.
- Built-in wallet for sending Bitcoin and Lightning payments, with transaction history and balances shown in USD.
- One-tap support: zap posts, profiles, campaigns, and organizations.
- AI agent chat with a model selector, tool-calling, and slash commands.
- Immersive country pages with anthems, flags, weather, and a country-scoped feed, plus a new Discover square for exploring the world.
- Comments and reactions on campaigns, and donation receipts shown inline.
### Changed
- Refreshed Agora branding, navigation, and app icons throughout.
- Streamlined onboarding with country and people follows.
- Polished campaign, organization, and donation flows end to end.
### Removed
- Direct messaging and ephemeral geo chat.
## [1.0.0] - 2026-04-30
### Added
+2 -4
View File
@@ -282,11 +282,11 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
### Summary
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional deadline, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
The author of the event is also the beneficiary. Campaigns are never authored on behalf of someone else; the event creator owns the wallet declared in `w` and receives the donations. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate.
The kind is addressable so the creator can edit the story, banner, goal, deadline, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
The kind is addressable so the creator can edit the story, banner, goal, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
### Event Structure
@@ -315,7 +315,6 @@ The kind is addressable so the creator can edit the story, banner, goal, deadlin
["w", "sp1qq...verylongsilentpaymentcode..."],
["goal", "25000"],
["deadline", "1735689600"],
["i", "iso3166:US"],
["k", "iso3166"],
@@ -352,7 +351,6 @@ The `content` field is the **campaign story**, formatted as Markdown. Clients SH
| `banner` | Recommended | HTTPS URL of the wide banner image. Clients MUST sanitize the URL (see `sanitizeUrl()` in `nostr-security`) before rendering, and SHOULD pair the URL with a NIP-92 `imeta` tag for dimensions, blurhash, MIME type, and SHA-256. |
| `imeta` | Recommended | NIP-92 media metadata for the banner. The first `url <value>` pair MUST match the `banner` URL; clients SHOULD ignore an `imeta` whose URL does not match. |
| `goal` | Optional | Fundraising goal in **integer US Dollars** (no unit suffix, no decimals). Clients MAY display an estimated sat-equivalent at view time using a live exchange rate. |
| `deadline`| Optional | Unix timestamp (seconds) at which the campaign closes for new donations. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
| `i` | Recommended | NIP-73 country identifier. SHOULD be `iso3166:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). |
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166`. |
| `t` | Optional | User-entered discovery/category tags. Agora also adds `t:agora` as the app marker; other `t` values are freeform topics such as `legal-defense` or `mutual-aid`. |
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.14.4"
versionName "2.8.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -1
View File
@@ -1,5 +1,5 @@
ext {
minSdkVersion = 24
minSdkVersion = 26
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
+2 -2
View File
@@ -323,7 +323,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.14.4;
MARKETING_VERSION = 2.8.3;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -347,7 +347,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.8.0;
MARKETING_VERSION = 2.8.3;
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+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": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "agora",
"private": true,
"version": "2.8.0",
"version": "2.8.3",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -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",
+32
View File
@@ -1,5 +1,37 @@
# Changelog
## [2.8.3] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.2] - 2026-06-02
A maintenance release that fixes the Android build pipeline so signed releases publish correctly. No user-facing changes.
## [2.8.1] - 2026-06-02
Agora becomes a home for putting your money where your heart is. Launch and back fundraising campaigns, rally around organizations with their own events and pledges, and send support straight from a built-in Bitcoin and Lightning wallet. Explore the world through immersive country pages, chat with a new AI agent, and move through a faster, cleaner app with a fresh look throughout.
### Added
- Fundraising campaigns as the new home surface — create, edit, and back campaigns, set goals, add beneficiaries, and follow progress.
- Organizations with their own events, pledges, members, and moderation tools.
- Built-in wallet for sending Bitcoin and Lightning payments, with transaction history and balances shown in USD.
- One-tap support: zap posts, profiles, campaigns, and organizations.
- AI agent chat with a model selector, tool-calling, and slash commands.
- Immersive country pages with anthems, flags, weather, and a country-scoped feed, plus a new Discover square for exploring the world.
- Comments and reactions on campaigns, and donation receipts shown inline.
### Changed
- Refreshed Agora branding, navigation, and app icons throughout.
- Streamlined onboarding with country and people follows.
- Polished campaign, organization, and donation flows end to end.
### Removed
- Direct messaging and ephemeral geo chat.
## [1.0.0] - 2026-04-30
### Added
-19
View File
@@ -17,17 +17,6 @@ import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
function getDeadlineLabel(unixSeconds: number): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
if (diff <= 0) return { label: 'Ended', isPast: true };
const days = Math.ceil(diff / 86_400);
if (days <= 1) return { label: 'Ends today', isPast: false };
if (days < 30) return { label: `${days} days left`, isPast: false };
const months = Math.round(days / 30);
return { label: `${months} mo left`, isPast: false };
}
function InlineShell({
image,
fallbackIcon,
@@ -76,7 +65,6 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
?? sanitizeUrl(authorMetadata?.picture);
const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: campaign.identifier });
const countryLabel = getCampaignCountryLabel(campaign);
const deadline = campaign.deadline ? getDeadlineLabel(campaign.deadline) : undefined;
const isSilentPayment = !campaign.wallets.onchain;
const goalLabel = campaign.goalUsd && campaign.goalUsd > 0 ? formatUsdGoal(campaign.goalUsd) : undefined;
const raisedSats = stats?.totalSats ?? 0;
@@ -113,16 +101,9 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
)}
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span className={cn('inline-flex items-center gap-1.5', deadline.isPast && 'text-destructive')}>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
</div>
)}
+37 -6
View File
@@ -7,6 +7,10 @@ interface BitcoinAmountPickerProps {
usdAmount: number | string;
onUsdAmountChange: (amount: number | string) => void;
presets: readonly number[];
maxLabel?: string;
maxSelected?: boolean;
maxDisabled?: boolean;
onMaxSelect?: () => void;
insufficient?: boolean;
satsLabel?: string;
onAmountChangeStart?: () => void;
@@ -16,6 +20,10 @@ export function BitcoinAmountPicker({
usdAmount,
onUsdAmountChange,
presets,
maxLabel = 'MAX',
maxSelected = false,
maxDisabled = false,
onMaxSelect,
insufficient = false,
satsLabel,
onAmountChangeStart,
@@ -74,14 +82,25 @@ export function BitcoinAmountPicker({
) : (
<button
type="button"
onClick={() => setEditingAmount(true)}
onClick={() => {
onAmountChangeStart?.();
setEditingAmount(true);
}}
aria-label="Edit amount"
className="flex items-baseline justify-center rounded-md px-2 -mx-2 hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
>
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
<span className={cn('text-4xl font-semibold tabular-nums', insufficient && 'text-destructive')}>
{Number.isFinite(currentUsd) && currentUsd > 0 ? currentUsd : 0}
</span>
{maxSelected ? (
<span className={cn('text-4xl font-semibold tracking-tight', insufficient && 'text-destructive')}>
{maxLabel}
</span>
) : (
<>
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
<span className={cn('text-4xl font-semibold tabular-nums', insufficient && 'text-destructive')}>
{Number.isFinite(currentUsd) && currentUsd > 0 ? currentUsd : 0}
</span>
</>
)}
</button>
)}
{satsLabel && (
@@ -93,10 +112,15 @@ export function BitcoinAmountPicker({
<ToggleGroup
type="single"
value={presets.includes(Number(usdAmount)) ? String(usdAmount) : ''}
value={maxSelected ? 'max' : presets.includes(Number(usdAmount)) ? String(usdAmount) : ''}
onValueChange={(value) => {
if (value) {
onAmountChangeStart?.();
if (value === 'max') {
onMaxSelect?.();
setEditingAmount(false);
return;
}
onUsdAmountChange(Number(value));
setEditingAmount(false);
}
@@ -112,6 +136,13 @@ export function BitcoinAmountPicker({
${preset}
</ToggleGroupItem>
))}
<ToggleGroupItem
value="max"
disabled={maxDisabled}
className="h-8 min-w-0 text-xs font-semibold px-1"
>
{maxLabel}
</ToggleGroupItem>
</ToggleGroup>
</>
);
+32 -31
View File
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { CalendarClock, HandHeart, MapPin, ShieldCheck } from 'lucide-react';
import { HandHeart, ShieldCheck } from 'lucide-react';
import { AuthorByline } from '@/components/AuthorByline';
import { Card } from '@/components/ui/card';
@@ -23,17 +23,6 @@ import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCamp
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
if (diff <= 0) return { label: 'Ended', isPast: true };
const days = Math.ceil(diff / 86_400);
if (days <= 1) return { label: 'Ends today', isPast: false };
if (days < 30) return { label: `${days} days left`, isPast: false };
const months = Math.round(days / 30);
return { label: `${months} mo left`, isPast: false };
}
/**
* Short helper rendered both inline (cards) and in the detail page.
*
@@ -47,13 +36,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 +160,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]);
@@ -159,7 +168,6 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
const cover = sanitizeUrl(displayCampaign.banner)
?? sanitizeUrl(authorMetadata?.banner)
?? sanitizeUrl(authorMetadata?.picture);
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
const raisedSats = stats?.totalSats ?? 0;
const countryLabel = getCampaignCountryLabel(campaign);
// SP-only campaigns hide aggregate totals; dual-endpoint campaigns
@@ -184,7 +192,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
isFeaturedVariant && 'sm:flex-row sm:items-stretch',
)}
>
{/* Cover image. Optional metadata (country, deadline) is
{/* Cover image. Optional metadata (country) is
overlaid on the banner as glass chips so the body below can
stay structurally deterministic. A bottom gradient keeps
the chips legible against any photo; a top scrim does the
@@ -211,7 +219,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
{/* Bottom gradient — only present when there are bottom chips
to display, so a banner with no overlays stays visually
clean. */}
{(countryLabel || deadline) && (
{(countryLabel) && (
<div
aria-hidden
className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/70 via-black/30 to-transparent"
@@ -223,26 +231,14 @@ 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"
/>
{/* Bottom-left meta chips — country + deadline. */}
{(countryLabel || deadline) && (
{/* Bottom-left meta chips — country. */}
{(countryLabel) && (
<div className="absolute bottom-3 left-3 z-10 flex flex-wrap items-center gap-1.5 [text-shadow:0_1px_2px_rgba(0,0,0,0.6)]">
{countryLabel && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-black/35 backdrop-blur-md px-2.5 py-1 text-[11px] font-medium text-white">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full bg-black/35 backdrop-blur-md px-2.5 py-1 text-[11px] font-medium text-white',
deadline.isPast && 'bg-destructive/60',
)}
>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
)}
@@ -287,7 +283,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">
+9 -4
View File
@@ -85,17 +85,22 @@ export function CampaignWalletDonatePanel({
Error-correction level H tolerates the centered occlusion
(~30% of modules can be missing and the code still scans). */}
<div className="flex justify-center">
<div className="relative rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas value={qrPayload} size={280} level="H" />
<div className="relative w-full max-w-[280px] rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas
value={qrPayload}
size={280}
level="H"
className="block h-auto w-full"
/>
<div
aria-hidden
className="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div className="rounded-full bg-primary p-2 ring-[6px] ring-white">
<div className="flex aspect-square w-[28%] items-center justify-center rounded-full bg-primary ring-[6px] ring-white">
<img
src="/logo.svg"
alt=""
className="size-16 object-contain brightness-0 invert"
className="aspect-square w-3/5 object-contain brightness-0 invert"
draggable={false}
/>
</div>
+90 -32
View File
@@ -48,17 +48,19 @@ import {
classifyBroadcastError,
type BroadcastErrorKind,
} from '@/lib/bitcoinBroadcastError';
import { isLargeAmount, satsToUSD } from '@/lib/bitcoin';
import { formatSats, isLargeAmount, satsToUSD } from '@/lib/bitcoin';
import {
broadcastBlockbookTx,
fetchFeeRates,
} from '@/lib/hdwallet/blockbook';
import {
buildHdSpendPsbt,
buildHdMaxSpendPsbt,
finalizeHdPsbt,
type HdInput,
type HdSpendableSpUtxo,
type HdSpendableUtxo,
previewHdMaxSpend,
previewHdFee,
signHdPsbt,
} from '@/lib/hdwallet/transaction';
@@ -68,7 +70,7 @@ import { useQuery } from '@tanstack/react-query';
// Constants
// ---------------------------------------------------------------------------
const USD_PRESETS = [1, 5, 10, 25, 100];
const USD_PRESETS = [5, 10, 25, 100];
type FeeSpeed = BitcoinFeeSpeed;
@@ -154,6 +156,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
// recipient (or null) to us. We only see the final picked destination.
const [recipient, setRecipient] = useState<ResolvedRecipient | null>(null);
const [usdAmount, setUsdAmount] = useState<number | string>(5);
const [sendMax, setSendMax] = useState(false);
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
/** Raw text for the custom sat/vB rate input (only used when feeSpeed === 'custom'). */
const [customFeeRate, setCustomFeeRate] = useState('');
@@ -228,6 +231,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
return Math.round((usd / btcPrice) * 100_000_000);
}, [usdAmount, btcPrice]);
const maxSpend = useMemo(
() => (currentFeeRate ? previewHdMaxSpend(ownedInputs, currentFeeRate) : null),
[ownedInputs, currentFeeRate],
);
// ── Fee estimate (matches the actual coin selection) ────────
//
// Crucially we do NOT use `ownedInputs.length` as the input count: an HD
@@ -240,17 +248,21 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
return previewHdFee(ownedInputs, amountSats, currentFeeRate);
}, [ownedInputs, currentFeeRate, amountSats]);
const totalSats = amountSats + estimatedFeeSats;
const effectiveAmountSats = sendMax ? (maxSpend?.amountSats ?? 0) : amountSats;
const effectiveFeeSats = sendMax ? (maxSpend?.fee ?? 0) : estimatedFeeSats;
const totalSats = effectiveAmountSats + effectiveFeeSats;
// `previewHdFee` returns 0 when the coin selector can't cover `amount + fee`.
// Treat that as insufficient so the UI doesn't claim a 0-sat fee is fine.
const selectionFailed =
amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
const selectionFailed = sendMax
? !!currentFeeRate && ownedInputs.length > 0 && !maxSpend
: amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
const insufficient = selectionFailed || (totalBalance > 0 && totalSats > totalBalance);
// Auto-tune fee speed to keep fees < 40% of the send amount, unless the
// user has manually overridden.
useEffect(() => {
if (feeSpeedUserChanged.current) return;
if (sendMax) return;
if (!ownedInputs.length || !feeRates || amountSats <= 0) return;
const uniqueSpeeds = getUniqueBitcoinFeeSpeeds(feeRates);
@@ -263,7 +275,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
if (fee > 0 && fee <= threshold) { target = speed; break; }
}
setFeeSpeed((prev) => (prev === target ? prev : target));
}, [amountSats, feeRates, ownedInputs, totalBalance]);
}, [amountSats, feeRates, ownedInputs, sendMax, totalBalance]);
const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
feeSpeedUserChanged.current = true;
@@ -284,7 +296,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
useEffect(() => {
setConfirmArmed(false);
}, [amountSats, currentFeeRate, btcPrice, recipient?.address]);
}, [effectiveAmountSats, currentFeeRate, btcPrice, recipient?.address]);
// Track open transitions so we can re-key the picker on each
// closed → open transition. Re-keying remounts the picker with a fresh
@@ -312,31 +324,56 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
if (!recipient) throw new Error(t('walletSend.errors.enterRecipient'));
if (!ownedInputs.length) throw new Error(t('walletSend.errors.noSpendable'));
if (feeSpeed !== 'custom' && !feeRates) throw new Error(t('walletSend.errors.feesNotLoaded'));
if (amountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
if (effectiveAmountSats <= 0) throw new Error(t('walletSend.errors.enterAmount'));
if (insufficient) throw new Error(t('walletSend.errors.insufficient'));
const rate = resolveBitcoinFeeRate(feeSpeed, feeRates, customFeeRate);
if (!rate || rate < 1) throw new Error(t('walletSend.errors.feeRateTooLow'));
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
const resolvedRecipient = recipient.kind === 'sp'
? { kind: 'sp' as const, spAddress: recipient.address }
: { kind: 'address' as const, address: recipient.address };
setProgress('building');
const built = buildHdSpendPsbt({
account: availability.account,
inputs: ownedInputs,
recipient:
recipient.kind === 'sp'
? { kind: 'sp', spAddress: recipient.address }
: { kind: 'address', address: recipient.address },
amountSats,
feeRate: rate,
nextChangeIndex,
seed: availability.seed,
});
let psbtHex: string;
let fee: number;
let sentAmountSats = effectiveAmountSats;
let inputDescriptors: Parameters<typeof signHdPsbt>[1];
let consumedSpUtxos: Array<{ txid: string; vout: number }>;
if (sendMax) {
const built = buildHdMaxSpendPsbt({
account: availability.account,
inputs: ownedInputs,
recipient: resolvedRecipient,
feeRate: rate,
seed: availability.seed,
});
psbtHex = built.psbtHex;
fee = built.fee;
sentAmountSats = built.amountSats;
inputDescriptors = built.inputDescriptors;
consumedSpUtxos = built.consumedSpUtxos;
} else {
const built = buildHdSpendPsbt({
account: availability.account,
inputs: ownedInputs,
recipient: resolvedRecipient,
amountSats: effectiveAmountSats,
feeRate: rate,
nextChangeIndex,
seed: availability.seed,
});
psbtHex = built.psbtHex;
fee = built.fee;
inputDescriptors = built.inputDescriptors;
consumedSpUtxos = built.consumedSpUtxos;
}
setProgress('signing');
const signedHex = signHdPsbt(
built.psbtHex,
built.inputDescriptors,
psbtHex,
inputDescriptors,
availability.account,
availability.seed,
);
@@ -345,12 +382,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
setProgress('broadcasting');
const txid = await broadcastBlockbookTx(blockbookBaseUrl, txHex);
return { txid, amountSats, fee: built.fee, consumedSpUtxos: built.consumedSpUtxos };
return { txid, amountSats: sentAmountSats, fee, consumedSpUtxos };
},
onSuccess: (result) => {
notificationSuccess();
setSuccess(result);
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
// Remove the SP UTXOs we just spent from local storage and
// republish the NIP-78 doc. Blockbook's xpub scan can't see SP
// outputs, so without this the spent UTXOs would linger forever:
@@ -362,6 +398,9 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
if (result.consumedSpUtxos.length > 0) {
pruneSpentSilentPaymentUtxos(result.consumedSpUtxos);
}
// Refresh after pruning so transaction history can classify mixed
// BIP-86 + SP sends with the spent SP outpoints already archived.
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
void refetchWallet();
},
onError: (err) => {
@@ -393,7 +432,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
// because that's just a passive refresh.
useEffect(() => {
setBroadcastError(null);
}, [recipient?.address, amountSats, feeSpeed, customFeeRate]);
}, [recipient?.address, effectiveAmountSats, feeSpeed, customFeeRate]);
/**
* Recovery action for fee-related broadcast failures.
@@ -466,7 +505,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
}
if (!recipient) { setError(t('walletSend.errors.enterRecipient')); return; }
if (!btcPrice) { setError(t('walletSend.errors.waitingPrice')); return; }
if (amountSats <= 0) { setError(t('walletSend.errors.enterAmount')); return; }
if (effectiveAmountSats <= 0) { setError(t('walletSend.errors.enterAmount')); return; }
if (!ownedInputs.length) { setError(t('walletSend.errors.noneYet')); return; }
if (!currentFeeRate || currentFeeRate < 1) {
setError(
@@ -484,7 +523,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
availability,
recipient,
btcPrice,
amountSats,
effectiveAmountSats,
ownedInputs.length,
currentFeeRate,
feeSpeed,
@@ -502,6 +541,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
setTimeout(() => {
setRecipient(null);
setUsdAmount(5);
setSendMax(false);
setError('');
setFeeSpeed('halfHour');
setCustomFeeRate('');
@@ -531,12 +571,16 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
sendMutation.isPending ||
!recipient ||
!btcPrice ||
amountSats <= 0 ||
effectiveAmountSats <= 0 ||
insufficient ||
!ownedInputs.length ||
!currentFeeRate ||
currentFeeRate < 1;
const maxAmountLabel = sendMax && effectiveAmountSats > 0 && btcPrice
? `${satsToUSD(effectiveAmountSats, btcPrice)} · ${t('walletSend.success.satsAmount', { sats: formatSats(effectiveAmountSats) })}`
: undefined;
// ── Render ───────────────────────────────────────────────────
return (
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
@@ -570,10 +614,24 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
<div className="grid gap-4 px-4 py-4 w-full overflow-y-auto">
<BitcoinAmountPicker
usdAmount={usdAmount}
onUsdAmountChange={setUsdAmount}
onUsdAmountChange={(amount) => {
setSendMax(false);
setUsdAmount(amount);
}}
presets={USD_PRESETS}
maxLabel={t('walletSend.max')}
maxSelected={sendMax}
maxDisabled={!ownedInputs.length || !currentFeeRate || !maxSpend}
onMaxSelect={() => {
setError('');
setSendMax(true);
}}
insufficient={insufficient}
onAmountChangeStart={() => setError('')}
satsLabel={maxAmountLabel}
onAmountChangeStart={() => {
setError('');
setSendMax(false);
}}
/>
{/* Recipient — text input + Popover dropdown surfacing the
@@ -648,8 +706,8 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
type="button"
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-muted-foreground tabular-nums"
>
{estimatedFeeSats > 0 && btcPrice ? (
<> {satsToUSD(estimatedFeeSats, btcPrice)}</>
{effectiveFeeSats > 0 && btcPrice ? (
<> {satsToUSD(effectiveFeeSats, btcPrice)}</>
) : currentFeeRate ? (
<>{t('walletSend.satPerVB', { rate: currentFeeRate })}</>
) : feeRatesLoading && feeSpeed !== 'custom' ? (
+41 -1
View File
@@ -44,9 +44,11 @@ import { EmojifiedText } from '@/components/CustomEmoji';
import { ReportDialog } from '@/components/ReportDialog';
import { CommunityReportDialog } from '@/components/CommunityReportDialog';
import { AddToListDialog } from '@/components/AddToListDialog';
import { CampaignListMembershipDialog } from '@/components/campaign-lists/CampaignListMembershipDialog';
import { useNostr } from '@nostrify/react';
import { useBookmarks } from '@/hooks/useBookmarks';
import { usePinnedNotes } from '@/hooks/usePinnedNotes';
import { useCampaignListActions } from '@/hooks/useCampaignListActions';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useMuteList } from '@/hooks/useMuteList';
@@ -211,6 +213,7 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
const [reportOpen, setReportOpen] = useState(false);
const [banContentOpen, setBanContentOpen] = useState(false);
const [addToListOpen, setAddToListOpen] = useState(false);
const [addToCampaignListOpen, setAddToCampaignListOpen] = useState(false);
const [eventJsonOpen, setEventJsonOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -233,6 +236,16 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
const nip19Id = encodeEventNip19(event);
// Campaign-specific membership-dialog inputs. Only meaningful when
// `event.kind === CAMPAIGN_KIND`; the dialog row that uses them is
// gated inside the menu content the same way.
const isCampaign = event.kind === CAMPAIGN_KIND;
const campaignDTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
const campaignCoord = isCampaign
? `${CAMPAIGN_KIND}:${event.pubkey}:${campaignDTag}`
: '';
const campaignTitle = event.tags.find(([n]) => n === 'title')?.[1] ?? '';
const handleDelete = () => {
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
deleteEvent(
@@ -269,6 +282,10 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
onOpenChange(false);
setTimeout(() => setAddToListOpen(true), 150);
}}
onAddToCampaignList={() => {
onOpenChange(false);
setTimeout(() => setAddToCampaignListOpen(true), 150);
}}
onViewEventJson={() => {
onOpenChange(false);
setTimeout(() => setEventJsonOpen(true), 150);
@@ -307,6 +324,15 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
onOpenChange={setAddToListOpen}
/>
{isCampaign && (
<CampaignListMembershipDialog
open={addToCampaignListOpen}
onOpenChange={setAddToCampaignListOpen}
campaignCoord={campaignCoord}
campaignTitle={campaignTitle}
/>
)}
<EventJsonDialog
event={event}
nip19Id={nip19Id}
@@ -347,11 +373,12 @@ interface NoteMoreMenuContentProps extends NoteMoreMenuProps {
onReport: () => void;
onBanContent: () => void;
onAddToList: () => void;
onAddToCampaignList: () => void;
onViewEventJson: () => void;
onDelete: () => void;
}
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onAddToCampaignList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { user } = useCurrentUser();
@@ -365,6 +392,12 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
// (kind 33863 — addressable, with their own dedicated UI). Hide them there.
const isCampaign = event.kind === CAMPAIGN_KIND;
// Campaign moderators get a dedicated "Add to list" row that toggles
// the campaign's membership in the curated topic lists. `isMod` is a
// synchronous boolean — no loading state to handle.
const campaignListActions = useCampaignListActions();
const canManageCampaignLists = isCampaign && campaignListActions.isMod;
// Country-feed pin/unpin context (organizer/admin action). `useCountryFeed`
// returns null outside of a country page; we only enable usePinnedPosts when
// the viewer is actually authorized to pin so we avoid extra relay traffic
@@ -551,6 +584,13 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
onClick={() => { onAddToList(); }}
/>
)}
{canManageCampaignLists && (
<MenuItem
icon={<ListPlus className="size-5" />}
label={t('campaigns.lists.membershipTitle')}
onClick={() => { onAddToCampaignList(); }}
/>
)}
{!isCampaign && (
<MenuItem
icon={isInSidebar ? <Trash2 className="size-5" /> : <PanelLeft className="size-5" />}
@@ -4,6 +4,8 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowUpToLine,
ChevronDown,
ChevronUp,
MoreVertical,
Pencil,
Plus,
@@ -39,6 +41,9 @@ import { cn } from '@/lib/utils';
const DRAG_MIME = 'text/x-agora-campaign-list-coord';
/** How many pills to show before collapsing the rest behind a "Show more". */
const COLLAPSED_COUNT = 5;
/**
* Horizontal scrollable strip of moderator-curated campaign list pills.
*
@@ -69,6 +74,7 @@ export function CampaignListsStrip() {
const [editTarget, setEditTarget] = useState<ParsedCampaignList | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ParsedCampaignList | null>(null);
const [optimisticOrder, setOptimisticOrder] = useState<readonly string[] | null>(null);
const [expanded, setExpanded] = useState(false);
const lists = useMemo(() => data?.lists ?? [], [data]);
const authoritativeCoords = useMemo(() => lists.map((l) => l.aTag), [lists]);
@@ -180,6 +186,28 @@ export function CampaignListsStrip() {
return null;
}
const visible = displayed.slice(0, COLLAPSED_COUNT);
const overflow = displayed.slice(COLLAPSED_COUNT);
const canExpand = overflow.length > 0;
const renderPill = (list: ParsedCampaignList, idx: number) => (
<ListPill
key={list.aTag}
list={list}
index={idx}
isMod={actions.isMod}
isMobile={isMobile}
onDropAt={(coord) => moveTo(coord, idx)}
onEdit={() => setEditTarget(list)}
onDelete={() => setDeleteTarget(list)}
onMoveUp={() => moveTo(list.aTag, Math.max(0, idx - 1))}
onMoveDown={() => moveTo(list.aTag, idx + 1)}
onMoveToStart={() => moveTo(list.aTag, 0)}
canMoveUp={idx > 0}
canMoveDown={idx < displayed.length - 1}
/>
);
return (
<>
<section
@@ -187,23 +215,35 @@ export function CampaignListsStrip() {
aria-label={t('campaigns.lists.stripAria')}
>
<div className="flex flex-wrap gap-2">
{displayed.map((list, idx) => (
<ListPill
key={list.aTag}
list={list}
index={idx}
isMod={actions.isMod}
isMobile={isMobile}
onDropAt={(coord) => moveTo(coord, idx)}
onEdit={() => setEditTarget(list)}
onDelete={() => setDeleteTarget(list)}
onMoveUp={() => moveTo(list.aTag, Math.max(0, idx - 1))}
onMoveDown={() => moveTo(list.aTag, idx + 1)}
onMoveToStart={() => moveTo(list.aTag, 0)}
canMoveUp={idx > 0}
canMoveDown={idx < displayed.length - 1}
/>
))}
{visible.map((list, i) => renderPill(list, i))}
{expanded &&
overflow.map((list, i) => renderPill(list, i + COLLAPSED_COUNT))}
{canExpand && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm whitespace-nowrap shrink-0',
'border-border bg-background hover:border-primary/40 hover:bg-primary/5 text-muted-foreground hover:text-foreground',
'motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
)}
>
{expanded ? (
<>
<ChevronUp className="size-4 shrink-0" aria-hidden />
<span>{t('campaigns.lists.showLess')}</span>
</>
) : (
<>
<ChevronDown className="size-4 shrink-0" aria-hidden />
<span>
{t('campaigns.lists.showMore', { count: overflow.length })}
</span>
</>
)}
</button>
)}
{actions.isMod && (
<button
type="button"
+10 -1
View File
@@ -14,8 +14,10 @@ export function QRCodeCanvas({ value, size = 256, level = 'M', className }: QRCo
useEffect(() => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
QRCode.toCanvas(
canvasRef.current,
canvas,
value,
{
width: size,
@@ -26,6 +28,13 @@ export function QRCodeCanvas({ value, size = 256, level = 'M', className }: QRCo
if (error) console.error('QR Code generation error:', error);
}
);
// The qrcode library hard-codes inline `width`/`height` pixel styles on
// the canvas, which override Tailwind sizing classes and cause the QR to
// overflow its container on narrow viewports. Clear them so the caller's
// className (e.g. `h-auto w-full`) controls the rendered size responsively.
canvas.style.removeProperty('width');
canvas.style.removeProperty('height');
}, [value, size, level]);
return <canvas ref={canvasRef} className={className} />;
+29 -32
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,7 +9,7 @@ import {
type ParsedCampaignList,
foldCampaignLists,
} from '@/lib/campaignLists';
import { DITTO_RELAY } from '@/lib/appRelays';
import { LIST_CURATOR_PUBKEY } from '@/lib/agoraDefaults';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -22,15 +21,32 @@ 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.
*
* **Relay fan-out.** This used to query `relay.ditto.pub` directly (a
* single-relay `nostr.relay(...)` call) to avoid a fast empty EOSE from a
* less-populated relay racing the surface to "no lists." But this query
* sits at the *head* of the home-page waterfall — every hero campaign is
* gated on its result (see `CampaignsPage`/`useCampaigns`) — so a slow
* `relay.ditto.pub` stalled the entire first paint. We now fan out to the
* whole read pool via `nostr.query`. The `authors: [LIST_CURATOR_PUBKEY]`
* filter is what enforces the trust model; correctness no longer depends
* on hitting one specific relay, and the curated relay is still in the
* fan-out so its events are found. The pool accumulates events across
* relays until first EOSE (+ the pool's eoseTimeout), so a late event from
* the curated relay still folds in on the next tick.
*
* Lists *and* the index are pulled in a single filter via
* `'#t': [LIST_HASHTAG, LIST_INDEX_HASHTAG]` so there's only one
@@ -38,46 +54,27 @@ 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
// "no lists" while the curated relay still holds them.
const relay = nostr.relay(DITTO_RELAY);
const events = await relay.query(
const events = await nostr.query(
[
{
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)]) },
{ signal },
);
return foldCampaignLists(events);
},
staleTime: 30_000,
});
return {
...query,
isLoading: query.isLoading || moderatorsLoading,
};
return query;
}
/** Lookup a single list by slug from the cached collection. */
+7 -4
View File
@@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from './useNostrPublish';
import { useCampaignModerators } from './useCampaignModerators';
import { DITTO_RELAY } from '@/lib/appRelays';
import { CAMPAIGN_KIND } from '@/lib/campaign';
import {
AGORA_MODERATION_NAMESPACE,
@@ -62,8 +61,12 @@ export function useCampaignModeration() {
if (!moderators || moderators.length === 0) {
return { ...EMPTY_MODERATION_DATA, moderators: [] };
}
const relay = nostr.relay(DITTO_RELAY);
const events = await relay.query(
// Fan out to the whole read pool rather than pinning a single relay.
// The `authors: moderators` filter enforces the trust model, so
// querying more relays only improves coverage — and it keeps this
// moderation surface off the single-relay critical path that was
// serializing the home page behind relay.ditto.pub.
const events = await nostr.query(
[
{
kinds: [LABEL_KIND],
@@ -76,7 +79,7 @@ export function useCampaignModeration() {
limit: 2000,
},
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
{ signal },
);
return foldModerationLabels(events, moderators, CAMPAIGN_KIND);
},
+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,
});
}
+4 -3
View File
@@ -2,7 +2,6 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { DITTO_RELAY } from '@/lib/appRelays';
import {
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
@@ -38,13 +37,15 @@ interface UseDiscoverCommunitiesOptions {
export function useDiscoverCommunities(options: UseDiscoverCommunitiesOptions = {}) {
const { limit = 24, enabled = true } = options;
const { nostr } = useNostr();
const relay = nostr.relay(DITTO_RELAY);
return useQuery<ParsedCommunity[]>({
queryKey: ['discover-communities', limit],
enabled,
queryFn: async ({ signal }) => {
const events = await relay.query(
// Global discovery (no `authors:` filter), so fan out to the whole
// read pool: more relays means broader community coverage, and it
// keeps Discover off the single-relay critical path.
const events = await nostr.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], limit }],
{ signal },
);
+5 -4
View File
@@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import { DITTO_RELAY } from '@/lib/appRelays';
import {
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
@@ -50,7 +49,6 @@ function parseCoord(coord: string): { pubkey: string; dTag: string } | null {
*/
export function useFeaturedOrganizations() {
const { nostr } = useNostr();
const relay = nostr.relay(DITTO_RELAY);
const { data: moderation, isReady: moderationReady } = useOrganizationModeration();
// Derive the curated coord set: featured minus hidden, sorted by the
@@ -102,8 +100,11 @@ export function useFeaturedOrganizations() {
}),
);
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const events = await relay.query(filters, { signal: combinedSignal });
// Fan out to the whole read pool. Each filter pins `authors:`, so the
// curation is still enforced by the moderation labels (the `featured`
// labels are themselves moderator-authored) — querying more relays only
// improves coverage and keeps this off the single-relay critical path.
const events = await nostr.query(filters, { signal });
// Latest-wins dedupe of addressable revisions, then index by coord so
// we can return them in the moderator-controlled `featuredOrder`.
+4 -1
View File
@@ -591,10 +591,13 @@ export function useHdWalletSp(): UseHdWalletSpResult {
}
const opt = optimisticRef.current!;
const spentKeys = new Set(freshArchive.map((u) => `${u.txid}:${u.vout}`));
optimisticRef.current = {
version: SP_STORAGE_VERSION,
scanHeight: opt.scanHeight,
utxos: mergeUtxos(opt.utxos, freshActive),
utxos: mergeUtxos(opt.utxos, freshActive).filter(
(u) => !spentKeys.has(`${u.txid}:${u.vout}`),
),
spent: mergeUtxos(opt.spent ?? [], freshArchive),
};
matchesFound += blockMatches.length;
+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;
-3
View File
@@ -90,8 +90,6 @@ export interface ParsedCampaign {
wallets: CampaignWallets;
/** Fundraising goal in **integer US Dollars**, or `undefined` if not set. */
goalUsd?: number;
/** Deadline (Unix seconds), or `undefined` if not set. */
deadline?: number;
/** ISO 3166-1 alpha-2 country code parsed from a NIP-73 `i` tag. */
countryCode?: string;
/** Created-at from the event. */
@@ -259,7 +257,6 @@ export function parseCampaign(event: NostrEvent): ParsedCampaign | null {
bannerImeta,
wallets,
goalUsd: parsePositiveInt(getTag(event, 'goal')),
deadline: parsePositiveInt(getTag(event, 'deadline')),
countryCode: getCountryCode(event),
createdAt: event.created_at,
};
+24 -5
View File
@@ -454,6 +454,16 @@ export function buildHdTransactions(
const out: HdTransaction[] = [];
for (const tx of result.rawTransactions) {
const spValuesByTxid = new Map<string, number[]>();
for (const [outpoint, value] of spOutpoints ?? []) {
const sep = outpoint.lastIndexOf(':');
if (sep <= 0) continue;
const txid = outpoint.slice(0, sep);
const values = spValuesByTxid.get(txid);
if (values) values.push(value);
else spValuesByTxid.set(txid, [value]);
}
let inflowsBip86 = 0;
let outflowsBip86 = 0;
let outflowsSp = 0;
@@ -483,17 +493,26 @@ export function buildHdTransactions(
}
}
if (attributed) continue;
if (
spOutpoints &&
typeof v.txid === 'string' &&
typeof v.vout === 'number'
) {
if (spOutpoints && typeof v.txid === 'string' && typeof v.vout === 'number') {
const spValue = spOutpoints.get(`${v.txid}:${v.vout}`);
if (spValue !== undefined) {
// Trust the wallet's own stored value for SP inputs — Blockbook
// doesn't always populate `vin.value` for taproot inputs.
outflowsSp += spValue || value;
}
} else if (typeof v.txid === 'string') {
// Blockbook's tx rows often omit the previous output index on inputs
// (they expose `n`, the input index, instead). Fall back to matching
// archived SP outpoints by prev txid + value so historical mixed
// BIP-86/SP sends can still be attributed after an include-spent scan.
const candidates = spValuesByTxid.get(v.txid);
if (candidates?.length) {
const idx = value > 0 ? candidates.findIndex((candidate) => candidate === value) : 0;
if (idx >= 0) {
outflowsSp += candidates[idx] || value;
candidates.splice(idx, 1);
}
}
}
}
+212 -2
View File
@@ -120,7 +120,7 @@ function inputId(input: HdInput): string {
}
/** Recipient parsing result. */
type HdRecipient =
export type HdRecipient =
| { kind: 'address'; address: string }
| { kind: 'sp'; spAddress: string };
@@ -667,6 +667,217 @@ export function finalizeHdPsbt(psbtHex: string): string {
// Max-sendable
// ---------------------------------------------------------------------------
/** Preview of the maximum spendable amount at a fee rate. */
export interface HdMaxSpendPreview {
/** Sats actually sent to the recipient after subtracting fee. */
amountSats: number;
/** Network fee in satoshis. */
fee: number;
/** Total sats across all consumed inputs. */
totalInput: number;
}
/** Arguments accepted by {@link buildHdMaxSpendPsbt}. */
export interface BuildHdMaxSpendArgs {
/** HD account whose keys can sign all `inputs`. */
account: HdAccount;
/** Every UTXO the wallet should drain. */
inputs: readonly HdInput[];
/** Where to send the max amount. */
recipient: HdRecipient;
/** Fee rate in sat/vB. */
feeRate: number;
/** Required iff any input is a silent-payment UTXO. */
seed?: Uint8Array;
}
/** Result of {@link buildHdMaxSpendPsbt}. */
export interface HdMaxSpendPsbt extends HdMaxSpendPreview {
/** Hex-encoded unsigned PSBT, ready for `signHdPsbt`. */
psbtHex: string;
/** Per-input descriptor, aligned 1:1 with PSBT inputs. */
inputDescriptors: HdInputDescriptor[];
/** Resolved recipient address. SP sends resolve to the derived P2TR address. */
resolvedRecipientAddress: string;
/** SP UTXOs consumed by this max spend, for post-broadcast bookkeeping. */
consumedSpUtxos: Array<{ txid: string; vout: number }>;
}
function dedupeInputs(inputs: readonly HdInput[]): HdInput[] {
const seen = new Set<string>();
const dedup: HdInput[] = [];
for (const i of inputs) {
const id = inputId(i);
if (seen.has(id)) continue;
seen.add(id);
dedup.push(i);
}
return dedup;
}
/**
* Preview a wallet-draining send: all inputs, one recipient output, no change.
* Returns `null` when the wallet cannot produce a non-dust recipient output.
*/
export function previewHdMaxSpend(
inputs: readonly HdInput[],
feeRate: number,
): HdMaxSpendPreview | null {
if (!Number.isFinite(feeRate) || feeRate <= 0) return null;
if (!inputs.length) return null;
const dedup = dedupeInputs(inputs);
if (!dedup.length) return null;
const totalInput = dedup.reduce((s, i) => s + inputValue(i), 0);
const fee = estimateFee(dedup.length, 1, feeRate);
const amountSats = totalInput - fee;
if (amountSats < BITCOIN_DUST_LIMIT) return null;
return { amountSats, fee, totalInput };
}
/**
* Build a one-output PSBT that sends the maximum possible amount to a normal
* Bitcoin address or silent-payment address. The fee is deducted from the
* wallet balance and no change output is created.
*/
export function buildHdMaxSpendPsbt(args: BuildHdMaxSpendArgs): HdMaxSpendPsbt {
const { account, inputs, recipient, feeRate, seed } = args;
if (!inputs.length) throw new Error('Max spend requires at least one input.');
if (!Number.isFinite(feeRate) || feeRate <= 0) {
throw new Error('Fee rate must be positive.');
}
if (recipient.kind === 'address' && !validateBitcoinAddress(recipient.address)) {
throw new Error(`Invalid Bitcoin address: ${recipient.address}`);
}
const dedup = dedupeInputs(inputs);
const preview = previewHdMaxSpend(dedup, feeRate);
if (!preview) {
const totalInput = dedup.reduce((s, i) => s + inputValue(i), 0);
const fee = dedup.length ? estimateFee(dedup.length, 1, feeRate) : 0;
throw new Error(
`Max spend amount below dust limit after fee. Total: ${totalInput}, fee: ${fee}.`,
);
}
const hasSp = dedup.some((i) => i.kind === 'sp');
if (hasSp && !seed) {
throw new Error('Max spend with SP inputs requires the source wallet seed.');
}
const bSpend = hasSp && seed ? deriveSilentPaymentSpendKey(seed) : undefined;
const wipeAfterBuild: Uint8Array[] = [];
if (bSpend) wipeAfterBuild.push(bSpend);
try {
const tx = new btc.Transaction();
const inputDescriptors: HdInputDescriptor[] = [];
const consumedSpUtxos: Array<{ txid: string; vout: number }> = [];
const spSenderInputs: SpSenderInput[] = [];
for (const input of dedup) {
if (input.kind === 'bip86') {
const utxo = input.utxo;
const derived = deriveAddress(
utxo.chain === CHANGE_CHAIN ? account.changeNode : account.receiveNode,
utxo.chain,
utxo.index,
);
if (derived.address !== utxo.address) {
throw new Error(
`UTXO address mismatch at ${utxo.chain}/${utxo.index}: ` +
`expected ${derived.address}, got ${utxo.address}`,
);
}
const internalPubkey = hex.decode(derived.internalPubkeyHex);
const payment = btc.p2tr(internalPubkey, undefined, HD_WALLET_NETWORK);
tx.addInput({
txid: utxo.txid,
index: utxo.vout,
witnessUtxo: { script: payment.script, amount: BigInt(utxo.value) },
tapInternalKey: internalPubkey,
});
inputDescriptors.push({ kind: 'bip86', chain: utxo.chain, index: utxo.index });
if (recipient.kind === 'sp') {
const leaf = deriveLeafPrivateKey(account, utxo.chain, utxo.index);
const tweaked = bip86TweakedPrivateKey(leaf);
leaf.fill(0);
wipeAfterBuild.push(tweaked);
spSenderInputs.push({
txid: utxo.txid,
vout: utxo.vout,
privateKey: tweaked,
isTaproot: true,
});
}
} else {
if (!bSpend) {
throw new Error('SP input requires b_spend (unreachable).');
}
const utxo = input.utxo;
const tweak = hexToBytes(utxo.tweakHex);
const xonly = deriveSpUtxoXOnly(bSpend, tweak);
const script = spP2trScriptPubKey(xonly);
tx.addInput({
txid: utxo.txid,
index: utxo.vout,
witnessUtxo: { script, amount: BigInt(utxo.value) },
});
inputDescriptors.push({ kind: 'sp', tweakHex: utxo.tweakHex });
consumedSpUtxos.push({ txid: utxo.txid, vout: utxo.vout });
if (recipient.kind === 'sp') {
const dk = deriveSpUtxoSigningKey(bSpend, tweak);
wipeAfterBuild.push(dk);
spSenderInputs.push({
txid: utxo.txid,
vout: utxo.vout,
privateKey: dk,
isTaproot: true,
});
}
}
}
let resolvedRecipientAddress: string;
if (recipient.kind === 'address') {
resolvedRecipientAddress = recipient.address;
tx.addOutputAddress(recipient.address, BigInt(preview.amountSats), HD_WALLET_NETWORK);
} else {
if (spSenderInputs.length === 0) {
throw new Error('Silent-payment max spend needs at least one input.');
}
const outputs = deriveSilentPaymentOutputs(
spSenderInputs,
[{ address: decodeSilentPaymentAddress(recipient.spAddress), raw: recipient.spAddress }],
{ network: 'mainnet' },
);
if (outputs.length !== 1) {
throw new Error('Silent-payment derivation returned unexpected number of outputs.');
}
const out: SpSenderOutput = outputs[0];
tx.addOutput({ script: spP2trScriptPubKey(out.xOnlyPubKey), amount: BigInt(preview.amountSats) });
resolvedRecipientAddress = out.address;
}
return {
psbtHex: txToPsbtHex(tx),
fee: preview.fee,
amountSats: preview.amountSats,
totalInput: preview.totalInput,
inputDescriptors,
resolvedRecipientAddress,
consumedSpUtxos,
};
} finally {
for (const buf of wipeAfterBuild) buf.fill(0);
}
}
// ---------------------------------------------------------------------------
// Sweep — drain every input into one output
// ---------------------------------------------------------------------------
@@ -822,4 +1033,3 @@ export function buildHdSweepPsbt(args: BuildHdSweepArgs): HdSweepPsbt {
if (bSpend) bSpend.fill(0);
}
}
+19 -20
View File
@@ -496,20 +496,23 @@
"wallet": "محفظة بيتكوين",
"myWalletLabel": "محفظة {{name}}",
"myWalletDefault": "محفظتي",
"walletHeroNote": "تتدفّق التبرّعات مباشرةً إلى محفظة Agora الخاصة بك. لا وسيط، ولا إعداد للدفعات، ولا انتظار.",
"walletHeroReassurance": "أنت تملك المفتاح، إذًا أنت تملك الأموال. اسحبها في أي وقت من تبويب المحفظة.",
"walletChoose": "اختر محفظة",
"walletCustom": "مخصصة",
"walletUseCustom": "استخدم محفظة مخصصة بدلاً من ذلك",
"walletUseMine": "استخدم محفظة Agora الخاصة بي",
"acceptAll": "قبول جميع أنواع الدفع",
"acceptPublic": "قبول الدفعات العامة فقط",
"acceptPrivate": "قبول الدفعات الخاصة فقط",
"acceptAllShort": "قبول الكل",
"acceptPublicShort": "عامة فقط",
"acceptPrivateShort": "خاصة فقط",
"acceptAllHint": "قبول الدفعات العامة على السلسلة والدفعات الصامتة الخاصة.",
"acceptPublicHint": "قبول التبرعات على السلسلة إلى عنوان عام فقط.",
"acceptPrivateHint": "قبول الدفعات الصامتة فقط — تبقى عناوين المتبرعين خاصة.",
"customWalletIntro": "أدخل عنوان بيتكوين، رمز دفع صامت، أو كليهما. يلزم واحد على الأقل.",
"acceptHeading": "ما نوع التبرعات التي ستقبلها؟",
"acceptUnavailable": "غير متاح مع تسجيل الدخول هذا.",
"acceptAllTitle": "أي تبرع",
"acceptPublicTitle": "التبرعات العامة فقط",
"acceptPrivateTitle": "التبرعات الخاصة فقط",
"acceptAllHint": "استقبل التبرعات العامة والخاصة معًا.",
"acceptPublicHint": "يتبرع المانحون إلى عنوان Bitcoin عادي. هذه التبرعات مرئية للجميع.",
"acceptPrivateHint": "يتبرع المانحون بشكل خاص، لذا تبقى هويتهم مخفية عن الجميع.",
"customWalletIntro": "املأ أي تبرعات ترغب في قبولها: عنوان عام، أو رمز خاص، أو كليهما. يلزم واحد على الأقل.",
"customOnchainMeaning": "عام. يمكن لأي شخص رؤية هذه التبرعات.",
"customSpMeaning": "خاص. تبقى هوية المتبرع مخفية.",
"bitcoinAddress": "عنوان بيتكوين",
"bitcoinAddressPlaceholder": "bc1q… أو bc1p…",
"silentPaymentCode": "رمز الدفع الصامت",
@@ -544,7 +547,6 @@
"goal": "الهدف",
"goalPlaceholder": "25,000",
"goalNote": "دولارات أمريكية صحيحة. المتبرعون يدفعون بالبيتكوين؛ يقدّر العملاء المعادل بالدولار وقت العرض.",
"deadline": "الموعد النهائي",
"submitCreate": "إطلاق الحملة",
"submitEdit": "تحديث الحملة",
"publishing": "جارٍ النشر…",
@@ -569,8 +571,6 @@
"errorSpInvalid": "رمز الدفع الصامت ليس رمز BIP-352 معروفًا (sp1…).",
"errorWalletRequired": "أدخل نقطة محفظة واحدة على الأقل — عنوان بيتكوين شبكة رئيسية (bc1q… / bc1p…) أو رمز دفع صامت (sp1…).",
"errorGoalInvalid": "يجب أن يكون الهدف مبلغًا موجبًا بالدولار الصحيح.",
"errorDeadlinePast": "لا يمكن أن يكون الموعد النهائي في الماضي.",
"errorDeadlineInvalid": "الموعد النهائي ليس تاريخًا صالحًا.",
"errorEditLatestMissing": "تعذّر العثور على أحدث نسخة لهذه الحملة لتحديثها.",
"errorSlugCollision": "لديك بالفعل حملة بالمعرّف «{{slug}}». اختر معرّفًا آخر.",
"errorBannerInvalid": "يجب أن يكون البانر رابط https:// صالحًا.",
@@ -587,6 +587,8 @@
"bannerStepSubtitle": "صورة واحدة لافتة تُمثّل الحملة في كل بطاقة.",
"storyStepTitle": "احكِ قصتك",
"storyStepSubtitle": "من المستفيد وكيف ستُستخدم الأموال.",
"goalStepTitle": "الهدف",
"goalStepSubtitle": "اختياري — اتركه فارغًا لحملة مفتوحة دون موعد نهائي.",
"next": "التالي",
"back": "رجوع",
"skip": "تخطٍّ",
@@ -596,11 +598,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | حملات {{appName}}",
"seoDescriptionFallback": "ادعم {{title}} على {{appName}}.",
"deadlineEndedOn": "انتهى في {{date}}",
"deadlineEndsToday": "ينتهي اليوم",
"deadlineDaysLeft_one": "بقي {{count}} يوم",
"deadlineDaysLeft_other": "بقي {{count}} يومًا",
"deadlineEndsOn": "ينتهي في {{date}}",
"back": "رجوع",
"edit": "تعديل",
"delete": "حذف",
@@ -643,7 +640,6 @@
"deleteDialogTitle": "حذف هذه الحملة؟",
"deleteDialogBody": "هذا ينشر طلب حذف NIP-09. ستزيل المرحّلات المتعاونة الحملة من الخلاصات والروابط المباشرة. تبقى إيصالات التبرعات السابقة على السلسلة بغض النظر. لا يمكن التراجع عن هذا الإجراء — لمواصلة قبول التبرعات، عدّل الحملة بدلًا من ذلك.",
"storyHeading": "القصة",
"campaignEnded": "انتهت الحملة",
"donate": "تبرّع",
"share": "مشاركة",
"target": "الهدف: {{amount}}",
@@ -744,7 +740,7 @@
"wlcDesc": "حملات منتقاة من World Liberty Congress.",
"allCampaigns": "كل الحملات",
"allCampaignsDesc": "كل الحملات على الشبكة، بالترتيب الزمني.",
"browseAll": "تصفّح كل الحملات",
"browseAll": "تصفّح كل الحملات",
"hidden": "مخفية",
"hiddenDesc": "حملات أُزيلت من الصفحة الرئيسية العامة. استخدم قائمة البطاقة لإلغاء الإخفاء.",
"hiddenEmpty": "لا توجد حملات مخفية حالياً.",
@@ -810,6 +806,8 @@
"lists": {
"stripAria": "قوائم مواضيع منتقاة للحملات",
"create": "قائمة جديدة",
"showMore": "إظهار {{count}} أخرى",
"showLess": "إظهار أقل",
"createDesc": "أنشئ قائمة مواضيع جديدة. ثم انتقِ إليها حملات من أي صفحة حملة.",
"createSubmit": "إنشاء القائمة",
"createFailed": "فشل إنشاء القائمة",
@@ -1228,6 +1226,7 @@
"walletSend": {
"title": "إرسال البيتكوين",
"send": "إرسال البيتكوين",
"max": "MAX",
"tapAgainToConfirm": "انقر مرة أخرى للتأكيد",
"satPerVB": "{{rate}} ساتوشي/vB",
"notEnoughBitcoin": "لا يوجد بيتكوين كافٍ",
+19 -22
View File
@@ -934,20 +934,23 @@
"wallet": "Bitcoin wallet",
"myWalletLabel": "{{name}}'s wallet",
"myWalletDefault": "My wallet",
"walletHeroNote": "Donations flow straight into your own Agora wallet. No middleman, no payout setup, no waiting.",
"walletHeroReassurance": "You hold the key, so you hold the funds. Withdraw any time from the wallet tab.",
"walletChoose": "Choose a wallet",
"walletCustom": "Custom wallet",
"walletUseCustom": "Use a custom wallet instead",
"walletUseMine": "Use my Agora wallet",
"acceptAll": "Accept all payment types",
"acceptPublic": "Accept public payments only",
"acceptPrivate": "Accept private payments only",
"acceptAllShort": "Accept All",
"acceptPublicShort": "Public Only",
"acceptPrivateShort": "Private Only",
"acceptAllHint": "Accept both public on-chain and private silent payments.",
"acceptPublicHint": "Only accept on-chain donations to a public address.",
"acceptPrivateHint": "Only accept silent payments — donor addresses stay private.",
"customWalletIntro": "Enter a Bitcoin address, a silent-payment code, or both. At least one is required.",
"acceptHeading": "What donations will you accept?",
"acceptUnavailable": "Not available with this login.",
"acceptAllTitle": "Any donation",
"acceptPublicTitle": "Public donations only",
"acceptPrivateTitle": "Private donations only",
"acceptAllHint": "Take both public and private donations.",
"acceptPublicHint": "Donors give to a regular Bitcoin address. These donations are visible to anyone.",
"acceptPrivateHint": "Donors give privately, so their identity stays hidden from the public.",
"customWalletIntro": "Fill in whichever donations you want to accept: a public address, a private code, or both. At least one is required.",
"customOnchainMeaning": "Public. Anyone can see these donations.",
"customSpMeaning": "Private. The donor's identity stays hidden.",
"bitcoinAddress": "Bitcoin address",
"bitcoinAddressPlaceholder": "bc1q… or bc1p…",
"silentPaymentCode": "Silent-payment code",
@@ -982,7 +985,6 @@
"goal": "Goal",
"goalPlaceholder": "25,000",
"goalNote": "Whole US Dollars. Donors pay in Bitcoin; clients estimate the USD-equivalent at view time.",
"deadline": "Deadline",
"submitCreate": "Launch campaign",
"submitEdit": "Update campaign",
"publishing": "Publishing…",
@@ -1007,8 +1009,6 @@
"errorSpInvalid": "The silent-payment code is not a recognized BIP-352 code (sp1…).",
"errorWalletRequired": "Provide at least one wallet endpoint — a Bitcoin mainnet address (bc1q… / bc1p…) or a silent-payment code (sp1…).",
"errorGoalInvalid": "Goal must be a positive whole-dollar amount.",
"errorDeadlinePast": "Deadline cannot be in the past.",
"errorDeadlineInvalid": "Deadline is not a valid date.",
"errorEditLatestMissing": "Could not find the latest version of this campaign to update.",
"errorSlugCollision": "You already have a campaign with the identifier \"{{slug}}\". Choose another.",
"errorBannerInvalid": "Banner must be a valid https:// URL.",
@@ -1025,8 +1025,8 @@
"bannerStepSubtitle": "One striking image carries the campaign on every card.",
"storyStepTitle": "Tell your story",
"storyStepSubtitle": "Who benefits and how the funds will be used.",
"goalStepTitle": "Goal and deadline",
"goalStepSubtitle": "Both optional — leave blank for an open-ended campaign.",
"goalStepTitle": "Goal",
"goalStepSubtitle": "Optional — leave blank for an open-ended campaign.",
"tagsStepTitle": "Country and categories",
"tagsStepSubtitle": "Help the right people find your campaign.",
"next": "Next",
@@ -1038,11 +1038,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} Fundraisers",
"seoDescriptionFallback": "Support {{title}} on {{appName}}.",
"deadlineEndedOn": "Ended {{date}}",
"deadlineEndsToday": "Ends today",
"deadlineDaysLeft_one": "{{count}} day left",
"deadlineDaysLeft_other": "{{count}} days left",
"deadlineEndsOn": "Ends {{date}}",
"back": "Back",
"edit": "Edit",
"delete": "Delete",
@@ -1085,7 +1080,6 @@
"deleteDialogTitle": "Delete this campaign?",
"deleteDialogBody": "This publishes a NIP-09 deletion request. Well-behaved relays will drop the campaign from feeds and direct links. Past donation receipts stay on-chain regardless. This action cannot be undone — to keep accepting donations, edit the campaign instead.",
"storyHeading": "The story",
"campaignEnded": "Campaign ended",
"donate": "Donate",
"share": "Share",
"target": "Target: {{amount}}",
@@ -1186,7 +1180,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",
@@ -1256,6 +1250,8 @@
"lists": {
"stripAria": "Curated campaign topic lists",
"create": "New list",
"showMore": "Show {{count}} more",
"showLess": "Show less",
"createDesc": "Create a new topic list. Curate campaigns into it from any campaign page.",
"createSubmit": "Create list",
"createFailed": "Failed to create list",
@@ -1742,6 +1738,7 @@
"walletSend": {
"title": "Send Bitcoin",
"send": "Send Bitcoin",
"max": "MAX",
"notEnoughBitcoin": "Not enough Bitcoin",
"tapAgainToConfirm": "Tap again to confirm",
"satPerVB": "{{rate}} sat/vB",
+17 -20
View File
@@ -508,20 +508,23 @@
"wallet": "Cartera Bitcoin",
"myWalletLabel": "Cartera de {{name}}",
"myWalletDefault": "Mi cartera",
"walletHeroNote": "Las donaciones llegan directamente a tu propia cartera de Agora. Sin intermediarios, sin configurar pagos, sin esperas.",
"walletHeroReassurance": "Tú tienes la clave, así que tú tienes los fondos. Retíralos en cualquier momento desde la pestaña de la cartera.",
"walletChoose": "Elige una cartera",
"walletCustom": "Personalizada",
"walletUseCustom": "Usar una cartera personalizada",
"walletUseMine": "Usar mi cartera de Agora",
"acceptAll": "Aceptar todos los pagos",
"acceptPublic": "Aceptar solo pagos públicos",
"acceptPrivate": "Aceptar solo pagos privados",
"acceptAllShort": "Todos",
"acceptPublicShort": "Solo públicos",
"acceptPrivateShort": "Solo privados",
"acceptAllHint": "Acepta pagos públicos on-chain y pagos silenciosos privados.",
"acceptPublicHint": "Solo acepta donaciones on-chain a una dirección pública.",
"acceptPrivateHint": "Solo acepta pagos silenciosos — las direcciones de los donantes permanecen privadas.",
"customWalletIntro": "Ingresa una dirección de Bitcoin, un código de pago silencioso o ambos. Se requiere al menos uno.",
"acceptHeading": "¿Qué donaciones aceptarás?",
"acceptUnavailable": "No disponible con este inicio de sesión.",
"acceptAllTitle": "Cualquier donación",
"acceptPublicTitle": "Solo donaciones públicas",
"acceptPrivateTitle": "Solo donaciones privadas",
"acceptAllHint": "Recibe donaciones tanto públicas como privadas.",
"acceptPublicHint": "Los donantes envían a una dirección de Bitcoin normal. Estas donaciones son visibles para cualquier persona.",
"acceptPrivateHint": "Los donantes dan de forma privada, así su identidad permanece oculta del público.",
"customWalletIntro": "Completa las donaciones que quieras aceptar: una dirección pública, un código privado o ambas. Se requiere al menos una.",
"customOnchainMeaning": "Pública. Cualquier persona puede ver estas donaciones.",
"customSpMeaning": "Privada. La identidad del donante permanece oculta.",
"bitcoinAddress": "Dirección de Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… o bc1p…",
"silentPaymentCode": "Código de pago silencioso",
@@ -556,7 +559,6 @@
"goal": "Meta",
"goalPlaceholder": "25.000",
"goalNote": "Dólares estadounidenses enteros. Las personas donan en Bitcoin; los clientes calculan el equivalente en USD al momento de ver.",
"deadline": "Fecha límite",
"submitCreate": "Lanzar campaña",
"submitEdit": "Actualizar campaña",
"publishing": "Publicando…",
@@ -581,8 +583,6 @@
"errorSpInvalid": "El código de pago silencioso no es un código BIP-352 reconocido (sp1…).",
"errorWalletRequired": "Proporciona al menos un punto de cartera: una dirección Bitcoin mainnet (bc1q… / bc1p…) o un código de pago silencioso (sp1…).",
"errorGoalInvalid": "La meta debe ser una cantidad positiva en dólares enteros.",
"errorDeadlinePast": "La fecha límite no puede estar en el pasado.",
"errorDeadlineInvalid": "La fecha límite no es una fecha válida.",
"errorEditLatestMissing": "No se pudo encontrar la última versión de esta campaña para actualizarla.",
"errorSlugCollision": "Ya tienes una campaña con el identificador «{{slug}}». Elige otro.",
"errorBannerInvalid": "La portada debe ser una URL https:// válida.",
@@ -608,11 +608,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | Recaudaciones de {{appName}}",
"seoDescriptionFallback": "Apoya {{title}} en {{appName}}.",
"deadlineEndedOn": "Finalizó el {{date}}",
"deadlineEndsToday": "Finaliza hoy",
"deadlineDaysLeft_one": "Queda {{count}} día",
"deadlineDaysLeft_other": "Quedan {{count}} días",
"deadlineEndsOn": "Finaliza el {{date}}",
"back": "Atrás",
"edit": "Editar",
"delete": "Eliminar",
@@ -655,7 +650,6 @@
"deleteDialogTitle": "¿Eliminar esta campaña?",
"deleteDialogBody": "Esto publica una solicitud de eliminación NIP-09. Los relés que se comportan bien quitarán la campaña de los feeds y los enlaces directos. Los recibos de donaciones pasadas quedan en cadena de todos modos. Esta acción no se puede deshacer — para seguir aceptando donaciones, edita la campaña en su lugar.",
"storyHeading": "La historia",
"campaignEnded": "Campaña finalizada",
"donate": "Donar",
"share": "Compartir",
"target": "Meta: {{amount}}",
@@ -756,7 +750,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.",
@@ -826,6 +820,8 @@
"lists": {
"stripAria": "Listas temáticas de campañas curadas",
"create": "Nueva lista",
"showMore": "Mostrar {{count}} más",
"showLess": "Mostrar menos",
"createDesc": "Crea una nueva lista temática. Cura campañas en ella desde cualquier página de campaña.",
"createSubmit": "Crear lista",
"createFailed": "No se pudo crear la lista",
@@ -1244,6 +1240,7 @@
"walletSend": {
"title": "Enviar Bitcoin",
"send": "Enviar Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Toca de nuevo para confirmar",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "No hay suficiente Bitcoin",
+17 -20
View File
@@ -508,20 +508,23 @@
"wallet": "کیف پول بیت‌کوین",
"myWalletLabel": "کیف پول {{name}}",
"myWalletDefault": "کیف پول من",
"walletHeroNote": "کمک‌های مالی مستقیماً به کیف پول Agora خودت سرازیر می‌شوند. بدون واسطه، بدون راه‌اندازی پرداخت، بدون انتظار.",
"walletHeroReassurance": "کلید در دست توست، پس پول هم در دست توست. هر زمان که خواستی از بخش کیف پول برداشت کن.",
"walletChoose": "یک کیف پول انتخاب کن",
"walletCustom": "سفارشی",
"walletUseCustom": "به جای آن از کیف پول سفارشی استفاده کن",
"walletUseMine": "از کیف پول Agora من استفاده کن",
"acceptAll": "پذیرش همهٔ نوع‌های پرداخت",
"acceptPublic": "پذیرش فقط پرداخت‌های عمومی",
"acceptPrivate": "پذیرش فقط پرداخت‌های خصوصی",
"acceptAllShort": "همه",
"acceptPublicShort": "فقط عمومی",
"acceptPrivateShort": "فقط خصوصی",
"acceptAllHint": "هم پرداخت‌های عمومی روی زنجیره و هم پرداخت‌های بی‌صدای خصوصی پذیرفته می‌شوند.",
"acceptPublicHint": "فقط اهداهای روی زنجیره به یک نشانی عمومی پذیرفته می‌شوند.",
"acceptPrivateHint": "فقط پرداخت‌های بی‌صدا — نشانی اهداکنندگان خصوصی می‌ماند.",
"customWalletIntro": "یک نشانی بیت‌کوین، یک کد پرداخت بی‌صدا یا هر دو را وارد کن. حداقل یکی الزامی است.",
"acceptHeading": "چه نوع کمک‌های مالی را می‌پذیری؟",
"acceptUnavailable": "با این ورود در دسترس نیست.",
"acceptAllTitle": "هر نوع کمک مالی",
"acceptPublicTitle": "فقط کمک‌های مالی عمومی",
"acceptPrivateTitle": "فقط کمک‌های مالی خصوصی",
"acceptAllHint": "هم کمک‌های مالی عمومی و هم خصوصی را بپذیر.",
"acceptPublicHint": "اهداکنندگان به یک نشانی معمولی Bitcoin پرداخت می‌کنند. این کمک‌های مالی برای همه قابل مشاهده‌اند.",
"acceptPrivateHint": "اهداکنندگان به‌صورت خصوصی پرداخت می‌کنند، بنابراین هویت‌شان از دید عموم پنهان می‌ماند.",
"customWalletIntro": "هر نوع کمک مالی را که می‌خواهی بپذیری وارد کن: یک نشانی عمومی، یک کد خصوصی، یا هر دو. دست‌کم یکی لازم است.",
"customOnchainMeaning": "عمومی. همه می‌توانند این کمک‌های مالی را ببینند.",
"customSpMeaning": "خصوصی. هویت اهداکننده پنهان می‌ماند.",
"bitcoinAddress": "نشانی بیت‌کوین",
"bitcoinAddressPlaceholder": "bc1q… یا bc1p…",
"silentPaymentCode": "کد پرداخت بی‌صدا",
@@ -556,7 +559,6 @@
"goal": "هدف",
"goalPlaceholder": "۲۵٬۰۰۰",
"goalNote": "دلار آمریکای صحیح. اهداکنندگان با بیت‌کوین می‌پردازند؛ کلاینت‌ها معادل دلاری را در زمان نمایش تخمین می‌زنند.",
"deadline": "مهلت",
"submitCreate": "راه‌اندازی کمپین",
"submitEdit": "به‌روزرسانی کمپین",
"publishing": "در حال انتشار…",
@@ -581,8 +583,6 @@
"errorSpInvalid": "کد پرداخت بی‌صدا یک کد BIP-352 شناخته‌شده نیست (sp1…).",
"errorWalletRequired": "حداقل یک نقطهٔ کیف پول وارد کن — یک نشانی بیت‌کوین شبکهٔ اصلی (bc1q… / bc1p…) یا یک کد پرداخت بی‌صدا (sp1…).",
"errorGoalInvalid": "هدف باید یک مقدار مثبت به دلار صحیح باشد.",
"errorDeadlinePast": "مهلت نمی‌تواند در گذشته باشد.",
"errorDeadlineInvalid": "مهلت یک تاریخ معتبر نیست.",
"errorEditLatestMissing": "آخرین نسخهٔ این کمپین برای به‌روزرسانی یافت نشد.",
"errorSlugCollision": "از قبل کمپینی با شناسهٔ «{{slug}}» داری. شناسهٔ دیگری انتخاب کن.",
"errorBannerInvalid": "بنر باید یک نشانی https:// معتبر باشد.",
@@ -608,11 +608,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | کمپین‌های {{appName}}",
"seoDescriptionFallback": "از {{title}} در {{appName}} حمایت کن.",
"deadlineEndedOn": "در {{date}} پایان یافت",
"deadlineEndsToday": "امروز پایان می‌یابد",
"deadlineDaysLeft_one": "{{count}} روز باقی",
"deadlineDaysLeft_other": "{{count}} روز باقی",
"deadlineEndsOn": "در {{date}} پایان می‌یابد",
"back": "بازگشت",
"edit": "ویرایش",
"delete": "حذف",
@@ -655,7 +650,6 @@
"deleteDialogTitle": "این کمپین حذف شود؟",
"deleteDialogBody": "این یک درخواست حذف NIP-09 منتشر می‌کند. رله‌های همکار کمپین را از فیدها و پیوندهای مستقیم حذف خواهند کرد. رسیدهای کمک‌های گذشته در زنجیره باقی می‌مانند. این کار قابل بازگشت نیست — برای ادامهٔ دریافت کمک، به‌جای حذف کمپین را ویرایش کن.",
"storyHeading": "داستان",
"campaignEnded": "کمپین پایان یافت",
"donate": "کمک کنید",
"share": "هم‌رسانی",
"target": "هدف: {{amount}}",
@@ -756,7 +750,7 @@
"wlcDesc": "کمپین‌های گزینش‌شده توسط کنگرهٔ آزادی جهانی (World Liberty Congress).",
"allCampaigns": "همه کمپین‌ها",
"allCampaignsDesc": "همه کمپین‌های شبکه، به ترتیب زمانی.",
"browseAll": "مرور همه کمپین‌ها",
"browseAll": "مرور همه کمپین‌ها",
"hidden": "پنهان‌شده",
"hiddenDesc": "کمپین‌هایی که از صفحه اصلی عمومی حذف شده‌اند. برای آشکار کردن دوباره از منوی کارت استفاده کنید.",
"hiddenEmpty": "در حال حاضر هیچ کمپینی پنهان نشده است.",
@@ -826,6 +820,8 @@
"lists": {
"stripAria": "فهرست‌های منتخب موضوعی کمپین‌ها",
"create": "فهرست جدید",
"showMore": "نمایش {{count}} مورد بیشتر",
"showLess": "نمایش کمتر",
"createDesc": "یک فهرست موضوعی جدید بسازید. از هر صفحهٔ کمپین، کمپین‌ها را به آن اضافه کنید.",
"createSubmit": "ساخت فهرست",
"createFailed": "ساخت فهرست ناموفق بود",
@@ -1244,6 +1240,7 @@
"walletSend": {
"title": "ارسال بیت‌کوین",
"send": "ارسال بیت‌کوین",
"max": "MAX",
"tapAgainToConfirm": "برای تأیید دوباره ضربه بزنید",
"satPerVB": "{{rate}} ساتوشی/vB",
"notEnoughBitcoin": "بیت‌کوین کافی نیست",
+14 -19
View File
@@ -939,19 +939,20 @@
"wallet": "Portefeuille Bitcoin",
"myWalletLabel": "Portefeuille de {{name}}",
"myWalletDefault": "Mon portefeuille",
"walletHeroNote": "Les dons arrivent directement dans votre propre portefeuille Agora. Pas d'intermédiaire, pas de configuration de versement, pas d'attente.",
"walletHeroReassurance": "Vous détenez la clé, donc vous détenez les fonds. Retirez à tout moment depuis l'onglet portefeuille.",
"walletChoose": "Choisir un portefeuille",
"walletCustom": "Personnalisé",
"walletUseCustom": "Utiliser un portefeuille personnalisé",
"walletUseMine": "Utiliser mon portefeuille Agora",
"acceptAll": "Accepter tous les types de paiement",
"acceptPublic": "Accepter uniquement les paiements publics",
"acceptPrivate": "Accepter uniquement les paiements privés",
"acceptAllShort": "Tous",
"acceptPublicShort": "Publics uniquement",
"acceptPrivateShort": "Privés uniquement",
"acceptAllHint": "Accepter les paiements publics on-chain et les paiements silencieux privés.",
"acceptPublicHint": "N'accepter que les dons on-chain vers une adresse publique.",
"acceptPrivateHint": "N'accepter que les paiements silencieux — les adresses des donateurs restent privées.",
"acceptHeading": "Quels dons souhaitez-vous accepter ?",
"acceptUnavailable": "Non disponible avec cette connexion.",
"acceptAllTitle": "Tout don",
"acceptPublicTitle": "Dons publics uniquement",
"acceptPrivateTitle": "Dons privés uniquement",
"acceptAllHint": "Recevez à la fois les dons publics et les dons privés.",
"acceptPublicHint": "Les donateurs versent sur une adresse Bitcoin classique. Ces dons sont visibles par tout le monde.",
"acceptPrivateHint": "Les donateurs versent en privé, afin que leur identité reste cachée du public.",
"customWalletIntro": "Saisissez une adresse Bitcoin, un code de paiement silencieux, ou les deux. Au moins un est obligatoire.",
"bitcoinAddress": "Adresse Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… ou bc1p…",
@@ -987,7 +988,6 @@
"goal": "Objectif",
"goalPlaceholder": "25 000",
"goalNote": "Dollars américains entiers. Les donateurs paient en Bitcoin ; les clients estiment l'équivalent en USD au moment de la consultation.",
"deadline": "Échéance",
"submitCreate": "Lancer la campagne",
"submitEdit": "Mettre à jour la campagne",
"publishing": "Publication…",
@@ -1012,8 +1012,6 @@
"errorSpInvalid": "Le code de paiement silencieux n'est pas un code BIP-352 reconnu (sp1…).",
"errorWalletRequired": "Fournissez au moins un point de terminaison de portefeuille — une adresse mainnet Bitcoin (bc1q… / bc1p…) ou un code de paiement silencieux (sp1…).",
"errorGoalInvalid": "L'objectif doit être un montant entier positif en dollars.",
"errorDeadlinePast": "L'échéance ne peut pas être dans le passé.",
"errorDeadlineInvalid": "L'échéance n'est pas une date valide.",
"errorEditLatestMissing": "Impossible de trouver la dernière version de cette campagne à mettre à jour.",
"errorSlugCollision": "Vous avez déjà une campagne avec l'identifiant « {{slug}} ». Choisissez-en une autre.",
"errorBannerInvalid": "La bannière doit être une URL https:// valide.",
@@ -1039,11 +1037,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | Collectes de fonds {{appName}}",
"seoDescriptionFallback": "Soutenez {{title}} sur {{appName}}.",
"deadlineEndedOn": "Terminée le {{date}}",
"deadlineEndsToday": "Se termine aujourd'hui",
"deadlineDaysLeft_one": "{{count}} jour restant",
"deadlineDaysLeft_other": "{{count}} jours restants",
"deadlineEndsOn": "Se termine le {{date}}",
"back": "Retour",
"edit": "Modifier",
"delete": "Supprimer",
@@ -1086,7 +1079,6 @@
"deleteDialogTitle": "Supprimer cette campagne ?",
"deleteDialogBody": "Cela publie une demande de suppression NIP-09. Les relais bien intentionnés retireront la campagne des fils et des liens directs. Les reçus de dons passés restent sur la chaîne quoi qu'il arrive. Cette action est irréversible — pour continuer à accepter les dons, modifiez la campagne à la place.",
"storyHeading": "L'histoire",
"campaignEnded": "Campagne terminée",
"donate": "Faire un don",
"share": "Partager",
"target": "Objectif : {{amount}}",
@@ -1187,7 +1179,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.",
@@ -1257,6 +1249,8 @@
"lists": {
"stripAria": "Listes thématiques de campagnes",
"create": "Nouvelle liste",
"showMore": "Afficher {{count}} de plus",
"showLess": "Afficher moins",
"createDesc": "Créez une nouvelle liste thématique. Ajoutez-y des campagnes depuis n'importe quelle page de campagne.",
"createSubmit": "Créer la liste",
"createFailed": "Échec de la création de la liste",
@@ -1676,6 +1670,7 @@
"walletSend": {
"title": "Envoyer du Bitcoin",
"send": "Envoyer du Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Appuyez à nouveau pour confirmer",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "Bitcoin insuffisant",
+17 -20
View File
@@ -940,20 +940,23 @@
"wallet": "Bitcoin वॉलेट",
"myWalletLabel": "{{name}} का वॉलेट",
"myWalletDefault": "मेरा वॉलेट",
"walletHeroNote": "दान सीधे आपके अपने Agora वॉलेट में आता है। कोई बिचौलिया नहीं, कोई पेआउट सेटअप नहीं, कोई इंतज़ार नहीं।",
"walletHeroReassurance": "चाबी आपके पास रहती है, इसलिए पैसा भी आपके पास रहता है। वॉलेट टैब से किसी भी समय निकासी करें।",
"walletChoose": "वॉलेट चुनें",
"walletCustom": "कस्टम",
"walletUseCustom": "इसके बजाय कस्टम वॉलेट का उपयोग करें",
"walletUseMine": "मेरे Agora वॉलेट का उपयोग करें",
"acceptAll": "सभी भुगतान प्रकार स्वीकार करें",
"acceptPublic": "केवल सार्वजनिक भुगतान स्वीकार करें",
"acceptPrivate": "केवल निजी भुगतान स्वीकार करें",
"acceptAllShort": "सभी स्वीकारें",
"acceptPublicShort": "केवल सार्वजनिक",
"acceptPrivateShort": "केवल निजी",
"acceptAllHint": "सार्वजनिक ऑन-चेन और निजी साइलेंट पेमेंट दोनों स्वीकार करें।",
"acceptPublicHint": "केवल सार्वजनिक एड्रेस पर ऑन-चेन दान स्वीकार करें।",
"acceptPrivateHint": "केवल साइलेंट पेमेंट स्वीकार करें — दानदाता के एड्रेस निजी रहते है।",
"customWalletIntro": "एक Bitcoin एड्रेस, एक साइलेंट-पेमेंट कोड,ोनों दर्ज करें। कम से कम एक ज़रूरी है।",
"acceptHeading": "आप किस तरह के दान स्वीकार करेंगे?",
"acceptUnavailable": "इस लॉगिन के साथ उपलब्ध नहीं है।",
"acceptAllTitle": "कोई भी दान",
"acceptPublicTitle": "केवल सार्वजनिक दान",
"acceptPrivateTitle": "केवल निजी दान",
"acceptAllHint": "सार्वजनिक और निजी, दोनों तरह के दान लें।",
"acceptPublicHint": "दानदाता एक सामान्य Bitcoin एड्रेस पर देते हैं। ये दान किसी को भी दिखाई देते हैं।",
"acceptPrivateHint": "दानदाता निजी तौर पर देते हैं, ताकि उनकी पहचान सबसे छिपी रहे।",
"customWalletIntro": "जो भी दान आप स्वीकार करना चाहते हैं, उसे भरें: एक सार्वजनिक पता, एक निजी कोड, या दोनों। कम से कम एक ज़रूरी है।",
"customOnchainMeaning": "सार्वजनिक।ान कोई भी देख सकता है।",
"customSpMeaning": "निजी। दानदाता की पहचान छिपी रहती है।",
"bitcoinAddress": "Bitcoin एड्रेस",
"bitcoinAddressPlaceholder": "bc1q… या bc1p…",
"silentPaymentCode": "साइलेंट-पेमेंट कोड",
@@ -988,7 +991,6 @@
"goal": "लक्ष्य",
"goalPlaceholder": "25,000",
"goalNote": "पूरे US डॉलर। डोनर Bitcoin में भुगतान करते हैं; क्लाइंट देखने के समय USD-समकक्ष का अनुमान लगाते हैं।",
"deadline": "अंतिम तारीख़",
"submitCreate": "कैंपेन लॉन्च करें",
"submitEdit": "कैंपेन अपडेट करें",
"publishing": "पब्लिश हो रहा है…",
@@ -1013,8 +1015,6 @@
"errorSpInvalid": "साइलेंट-पेमेंट कोड कोई पहचाना BIP-352 कोड नहीं है (sp1…)।",
"errorWalletRequired": "कम से कम एक वॉलेट endpoint दें — एक Bitcoin mainnet एड्रेस (bc1q… / bc1p…) या एक साइलेंट-पेमेंट कोड (sp1…)।",
"errorGoalInvalid": "लक्ष्य एक धनात्मक पूर्ण-डॉलर राशि होनी चाहिए।",
"errorDeadlinePast": "अंतिम तारीख़ बीते समय की नहीं हो सकती।",
"errorDeadlineInvalid": "अंतिम तारीख़ मान्य तारीख़ नहीं है।",
"errorEditLatestMissing": "इस कैंपेन का सबसे नया संस्करण अपडेट करने के लिए नहीं मिला।",
"errorSlugCollision": "आपके पास पहले से \"{{slug}}\" पहचानकर्ता वाला कैंपेन है। दूसरा चुनें।",
"errorBannerInvalid": "बैनर एक मान्य https:// URL होना चाहिए।",
@@ -1040,11 +1040,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} फंडरेज़र",
"seoDescriptionFallback": "{{appName}} पर {{title}} का समर्थन करें।",
"deadlineEndedOn": "{{date}} को समाप्त",
"deadlineEndsToday": "आज समाप्त",
"deadlineDaysLeft_one": "{{count}} दिन बाक़ी",
"deadlineDaysLeft_other": "{{count}} दिन बाक़ी",
"deadlineEndsOn": "{{date}} को समाप्त",
"back": "वापस",
"edit": "एडिट करें",
"delete": "डिलीट करें",
@@ -1087,7 +1082,6 @@
"deleteDialogTitle": "इस कैंपेन को डिलीट करें?",
"deleteDialogBody": "इससे एक NIP-09 डिलीशन रिक्वेस्ट पब्लिश होती है। सही तरीक़े से चलने वाले रिले कैंपेन को फ़ीड और सीधे लिंक से हटा देंगे। पिछली डोनेशन रसीदें ऑन-चेन वैसे भी रहेंगी। यह क्रिया वापस नहीं ली जा सकती — डोनेशन लेना जारी रखने के लिए इसके बजाय कैंपेन एडिट करें।",
"storyHeading": "कहानी",
"campaignEnded": "कैंपेन समाप्त",
"donate": "डोनेट करें",
"share": "शेयर करें",
"target": "लक्ष्य: {{amount}}",
@@ -1188,7 +1182,7 @@
"wlcDesc": "World Liberty Congress द्वारा चुने गए कैंपेन।",
"allCampaigns": "सभी कैंपेन",
"allCampaignsDesc": "नेटवर्क के सभी कैंपेन, कालक्रम के अनुसार।",
"browseAll": "सभी कैंपेन देखें",
"browseAll": "सभी कैंपेन देखें",
"hidden": "छुपा हुआ",
"hiddenDesc": "सार्वजनिक होमपेज से दबाए गए कैंपेन। कार्ड के kebab मेन्यू से अनहाइड करें।",
"hiddenEmpty": "अभी कोई कैंपेन छुपा हुआ नहीं है।",
@@ -1230,6 +1224,8 @@
"lists": {
"stripAria": "क्यूरेटेड कैंपेन टॉपिक सूचियाँ",
"create": "नई सूची",
"showMore": "{{count}} और दिखाएँ",
"showLess": "कम दिखाएँ",
"createDesc": "एक नई टॉपिक सूची बनाएँ। किसी भी कैंपेन पेज से कैंपेन उसमें जोड़कर क्यूरेट करें।",
"createSubmit": "सूची बनाएँ",
"createFailed": "सूची नहीं बनाई जा सकी",
@@ -1585,6 +1581,7 @@
"walletSend": {
"title": "Bitcoin भेजें",
"send": "Bitcoin भेजें",
"max": "MAX",
"tapAgainToConfirm": "पुष्टि के लिए फिर टैप करें",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "पर्याप्त बिटकॉइन नहीं",
+17 -20
View File
@@ -940,20 +940,23 @@
"wallet": "Dompet Bitcoin",
"myWalletLabel": "Dompet {{name}}",
"myWalletDefault": "Dompet saya",
"walletHeroNote": "Donasi langsung masuk ke dompet Agora Anda sendiri. Tanpa perantara, tanpa pengaturan pembayaran, tanpa menunggu.",
"walletHeroReassurance": "Anda yang memegang kuncinya, jadi Anda yang memegang dananya. Tarik kapan saja dari tab dompet.",
"walletChoose": "Pilih dompet",
"walletCustom": "Kustom",
"walletUseCustom": "Gunakan dompet kustom",
"walletUseMine": "Gunakan dompet Agora saya",
"acceptAll": "Terima semua jenis pembayaran",
"acceptPublic": "Hanya terima pembayaran publik",
"acceptPrivate": "Hanya terima pembayaran privat",
"acceptAllShort": "Semua",
"acceptPublicShort": "Hanya Publik",
"acceptPrivateShort": "Hanya Privat",
"acceptAllHint": "Terima pembayaran publik on-chain maupun silent-payment privat.",
"acceptPublicHint": "Hanya terima donasi on-chain ke alamat publik.",
"acceptPrivateHint": "Hanya terima silent-payment — alamat donatur tetap privat.",
"customWalletIntro": "Masukkan alamat Bitcoin, kode silent-payment, atau keduanya. Setidaknya satu wajib diisi.",
"acceptHeading": "Donasi apa yang akan Anda terima?",
"acceptUnavailable": "Tidak tersedia dengan login ini.",
"acceptAllTitle": "Donasi apa pun",
"acceptPublicTitle": "Hanya donasi publik",
"acceptPrivateTitle": "Hanya donasi privat",
"acceptAllHint": "Terima donasi publik maupun privat.",
"acceptPublicHint": "Donatur memberi ke alamat Bitcoin biasa. Donasi ini terlihat oleh siapa saja.",
"acceptPrivateHint": "Donatur memberi secara privat, sehingga identitas mereka tetap tersembunyi dari publik.",
"customWalletIntro": "Isi donasi mana saja yang ingin Anda terima: alamat publik, kode privat, atau keduanya. Setidaknya satu wajib diisi.",
"customOnchainMeaning": "Publik. Siapa saja dapat melihat donasi ini.",
"customSpMeaning": "Privat. Identitas donatur tetap tersembunyi.",
"bitcoinAddress": "Alamat Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… atau bc1p…",
"silentPaymentCode": "Kode silent-payment",
@@ -988,7 +991,6 @@
"goal": "Target",
"goalPlaceholder": "25,000",
"goalNote": "Dalam USD utuh. Donatur membayar dalam Bitcoin; klien memperkirakan nilai setara USD saat dilihat.",
"deadline": "Tenggat",
"submitCreate": "Luncurkan kampanye",
"submitEdit": "Perbarui kampanye",
"publishing": "Memublikasikan…",
@@ -1013,8 +1015,6 @@
"errorSpInvalid": "Kode silent-payment bukan kode BIP-352 yang dikenal (sp1…).",
"errorWalletRequired": "Sediakan setidaknya satu titik dompet — alamat Bitcoin mainnet (bc1q… / bc1p…) atau kode silent-payment (sp1…).",
"errorGoalInvalid": "Target harus berupa nilai dolar utuh yang positif.",
"errorDeadlinePast": "Tenggat tidak boleh di masa lalu.",
"errorDeadlineInvalid": "Tenggat bukan tanggal yang valid.",
"errorEditLatestMissing": "Tidak dapat menemukan versi terbaru kampanye ini untuk diperbarui.",
"errorSlugCollision": "Anda sudah memiliki kampanye dengan pengenal \"{{slug}}\". Pilih yang lain.",
"errorBannerInvalid": "Banner harus berupa URL https:// yang valid.",
@@ -1040,11 +1040,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | Penggalangan Dana {{appName}}",
"seoDescriptionFallback": "Dukung {{title}} di {{appName}}.",
"deadlineEndedOn": "Berakhir {{date}}",
"deadlineEndsToday": "Berakhir hari ini",
"deadlineDaysLeft_one": "{{count}} hari lagi",
"deadlineDaysLeft_other": "{{count}} hari lagi",
"deadlineEndsOn": "Berakhir {{date}}",
"back": "Kembali",
"edit": "Ubah",
"delete": "Hapus",
@@ -1087,7 +1082,6 @@
"deleteDialogTitle": "Hapus kampanye ini?",
"deleteDialogBody": "Ini akan memublikasikan permintaan penghapusan NIP-09. Relay yang berperilaku baik akan menghapus kampanye dari feed dan tautan langsung. Tanda terima donasi sebelumnya tetap on-chain apa pun yang terjadi. Tindakan ini tidak dapat dibatalkan — untuk tetap menerima donasi, ubah kampanye sebagai gantinya.",
"storyHeading": "Ceritanya",
"campaignEnded": "Kampanye berakhir",
"donate": "Donasi",
"share": "Bagikan",
"target": "Target: {{amount}}",
@@ -1188,7 +1182,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.",
@@ -1230,6 +1224,8 @@
"lists": {
"stripAria": "Daftar topik kampanye terkurasi",
"create": "Daftar baru",
"showMore": "Tampilkan {{count}} lagi",
"showLess": "Tampilkan lebih sedikit",
"createDesc": "Buat daftar topik baru. Kurasi kampanye ke dalamnya dari halaman kampanye mana pun.",
"createSubmit": "Buat daftar",
"createFailed": "Gagal membuat daftar",
@@ -1585,6 +1581,7 @@
"walletSend": {
"title": "Kirim Bitcoin",
"send": "Kirim Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Ketuk lagi untuk konfirmasi",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "Bitcoin tidak mencukupi",
+17 -20
View File
@@ -508,20 +508,23 @@
"wallet": "កាបូបប៊ីតខញ",
"myWalletLabel": "កាបូបរបស់ {{name}}",
"myWalletDefault": "កាបូបរបស់ខ្ញុំ",
"walletHeroNote": "ការបរិច្ចាគហូរចូលដោយផ្ទាល់ទៅក្នុងកាបូប Agora ផ្ទាល់ខ្លួនរបស់អ្នក។ គ្មានអ្នកកណ្ដាល គ្មានការរៀបចំការទូទាត់ គ្មានការរង់ចាំ។",
"walletHeroReassurance": "អ្នកកាន់កូនសោ ដូច្នេះអ្នកកាន់មូលនិធិ។ ដកប្រាក់បានគ្រប់ពេលពីផ្ទាំងកាបូប។",
"walletChoose": "ជ្រើសរើសកាបូប",
"walletCustom": "ផ្ទាល់ខ្លួន",
"walletUseCustom": "ប្រើកាបូបផ្ទាល់ខ្លួនជំនួសវិញ",
"walletUseMine": "ប្រើកាបូប Agora របស់ខ្ញុំ",
"acceptAll": "ទទួលយកការទូទាត់គ្រប់ប្រភេទ",
"acceptPublic": "ទទួលយការទូទាត់សាធារណៈតែប៉ុណ្ណោះ",
"acceptPrivate": "ទទួលយកការទូទាត់ឯកជនតែប៉ុណ្ណោះ",
"acceptAllShort": "ទាំងអស់",
"acceptPublicShort": "សាធារណៈតែប៉ុណ្ណោះ",
"acceptPrivateShort": "ឯកជនតែប៉ុណ្ណោះ",
"acceptAllHint": "ទទួលយកទាំងការទូទាត់ on-chain សាធារណៈ និងការបង់ប្រាក់ស្ងាត់ឯកជន។",
"acceptPublicHint": "ទទួលយកតែការបរិច្ចាគ on-chain ទៅកាន់អាសយដ្ឋានសាធារណៈប៉ុណ្ណោះ។",
"acceptPrivateHint": "ទទួលយកតែការបង់ប្រាក់ស្ងាត់ប៉ុណ្ណោះ — អាសយដ្ឋានរបស់អ្នកបរិច្ចាគនៅតែឯកជន។",
"customWalletIntro": "បញ្ចូលអាសយដ្ឋានប៊ីតខញ លេខកូដបង់ប្រាក់ស្ងាត់ ឬទាំងពីរ។ ត្រូវការយ៉ាងហោចណាស់មួយ។",
"acceptHeading": "តើអ្នកនឹងទទួលការបរិច្ចាគបែបណាខ្លះ?",
"acceptUnavailable": "មិនអាចប្រើបានជាមួយការចូលគណនីនេះទេ។",
"acceptAllTitle": "ការបរិច្ចាគគ្រប់ប្រភេទ",
"acceptPublicTitle": "ការបរិច្ចាគសាធារណៈតែប៉ុណ្ណោះ",
"acceptPrivateTitle": "ការបរិច្ចាគឯកជនតែប៉ុណ្ណោះ",
"acceptAllHint": "ទទួលយកការបរិច្ចាគទាំងសាធារណៈ និងឯកជន។",
"acceptPublicHint": "អ្នកបរិច្ចាគផ្ញើទៅកាន់អាសយដ្ឋាន Bitcoin ធម្មតា។ ការបរិច្ចាគទាំងនេះអ្នករាល់គ្នាអាចមើលឃើញ។",
"acceptPrivateHint": "អ្នកបរិច្ចាគផ្ញើដោយឯកជន ដូច្នេះអត្តសញ្ញាណរបស់ពួកគេនៅតែលាក់បាំងពីសាធារណៈ។",
"customWalletIntro": "បំពេញការបរិច្ចាគណាមួយដែលអ្នកចង់ទទួល៖ អាសយដ្ឋានសាធារណៈ លេខកូដឯកជន ឬទាំងពីរ។ ត្រូវការយ៉ាងហោចណាស់មួយ។",
"customOnchainMeaning": "សាធារណៈ។ អ្នករាល់គ្នាអាចមើលឃើញការបរិច្ចាគទាំងនេះ។",
"customSpMeaning": "ឯកជន។ អត្តសញ្ញាណរបស់អ្នកបរិច្ចាគនៅតែលាក់បាំង។",
"bitcoinAddress": "អាសយដ្ឋានប៊ីតខញ",
"bitcoinAddressPlaceholder": "bc1q… ឬ bc1p…",
"silentPaymentCode": "លេខកូដបង់ប្រាក់ស្ងាត់",
@@ -556,7 +559,6 @@
"goal": "គោលដៅ",
"goalPlaceholder": "25,000",
"goalNote": "ដុល្លារអាមេរិកពេញ។ អ្នកបរិច្ចាគបង់ប្រាក់ជាប៊ីតខញ; អតិថិជនប៉ាន់ប្រមាណសមមូល USD នៅពេលមើល។",
"deadline": "ពេលកំណត់",
"submitCreate": "បើកដំណើរយុទ្ធនាការ",
"submitEdit": "ធ្វើបច្ចុប្បន្នភាពយុទ្ធនាការ",
"publishing": "កំពុងផ្សព្វផ្សាយ…",
@@ -581,8 +583,6 @@
"errorSpInvalid": "លេខកូដបង់ប្រាក់ស្ងាត់មិនមែនជាលេខកូដ BIP-352 ដែលត្រូវបានទទួលស្គាល់ទេ (sp1…)។",
"errorWalletRequired": "ផ្តល់យ៉ាងហោចណាស់ចំណុចកាបូបមួយ — អាសយដ្ឋានប៊ីតខញ mainnet (bc1q… / bc1p…) ឬលេខកូដបង់ប្រាក់ស្ងាត់ (sp1…)។",
"errorGoalInvalid": "គោលដៅត្រូវតែជាចំនួនវិជ្ជមានជា USD ពេញ។",
"errorDeadlinePast": "ពេលកំណត់មិនអាចស្ថិតក្នុងអតីតកាល។",
"errorDeadlineInvalid": "ពេលកំណត់មិនមែនជាកាលបរិច្ឆេទត្រឹមត្រូវ។",
"errorEditLatestMissing": "មិនអាចស្វែងរកកំណែចុងក្រោយរបស់យុទ្ធនាការនេះដើម្បីធ្វើបច្ចុប្បន្នភាព។",
"errorSlugCollision": "អ្នកមានយុទ្ធនាការដែលមានកំណត់អត្តសញ្ញាណ «{{slug}}» រួចហើយ។ ជ្រើសរើសផ្សេង។",
"errorBannerInvalid": "បដាត្រូវតែជា URL https:// ត្រឹមត្រូវ។",
@@ -608,11 +608,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | យុទ្ធនាការរបស់ {{appName}}",
"seoDescriptionFallback": "គាំទ្រ {{title}} នៅលើ {{appName}}។",
"deadlineEndedOn": "បានបញ្ចប់នៅ {{date}}",
"deadlineEndsToday": "បញ្ចប់នៅថ្ងៃនេះ",
"deadlineDaysLeft_one": "នៅសល់ {{count}} ថ្ងៃ",
"deadlineDaysLeft_other": "នៅសល់ {{count}} ថ្ងៃ",
"deadlineEndsOn": "បញ្ចប់នៅ {{date}}",
"back": "ត្រឡប់",
"edit": "កែសម្រួល",
"delete": "លុប",
@@ -655,7 +650,6 @@
"deleteDialogTitle": "លុបយុទ្ធនាការនេះមែនទេ?",
"deleteDialogBody": "នេះផ្សព្វផ្សាយសំណើលុប NIP-09។ Relay ដែលអនុលោមនឹងលុបយុទ្ធនាការចេញពី feed និងតំណផ្ទាល់។ បង្កាន់ដៃនៃការបរិច្ចាគកន្លងមកនៅតែស្ថិតលើខ្សែសង្វាក់។ សកម្មភាពនេះមិនអាចត្រឡប់វិញបានទេ — ដើម្បីបន្តទទួលការបរិច្ចាគ កែសម្រួលយុទ្ធនាការជំនួស។",
"storyHeading": "រឿង",
"campaignEnded": "យុទ្ធនាការបានបញ្ចប់",
"donate": "បរិច្ចាគ",
"share": "ចែករំលែក",
"target": "គោលដៅ៖ {{amount}}",
@@ -756,7 +750,7 @@
"wlcDesc": "យុទ្ធនាការដែលបានសម្រិតសម្រាំងដោយសភាសេរីភាពពិភពលោក (World Liberty Congress)។",
"allCampaigns": "យុទ្ធនាការទាំងអស់",
"allCampaignsDesc": "យុទ្ធនាការទាំងអស់នៅលើបណ្តាញ តាមលំដាប់កាលប្បវត្តិ។",
"browseAll": "មើលយុទ្ធនាការទាំងអស់",
"browseAll": "មើលយុទ្ធនាការទាំងអស់",
"hidden": "បានលាក់",
"hiddenDesc": "យុទ្ធនាការដែលត្រូវបានដកចេញពីទំព័រដើមសាធារណៈ។ ប្រើម៉ឺនុយលើកាតដើម្បីឈប់លាក់។",
"hiddenEmpty": "បច្ចុប្បន្នគ្មានយុទ្ធនាការត្រូវបានលាក់។",
@@ -826,6 +820,8 @@
"lists": {
"stripAria": "បញ្ជីប្រធានបទយុទ្ធនាការដែលបានសម្រិតសម្រាំង",
"create": "បញ្ជីថ្មី",
"showMore": "បង្ហាញ {{count}} ទៀត",
"showLess": "បង្ហាញតិច",
"createDesc": "បង្កើតបញ្ជីប្រធានបទថ្មី។ សម្រិតសម្រាំងយុទ្ធនាការទៅក្នុងវាពីទំព័រយុទ្ធនាការណាមួយ។",
"createSubmit": "បង្កើតបញ្ជី",
"createFailed": "បរាជ័យក្នុងការបង្កើតបញ្ជី",
@@ -1244,6 +1240,7 @@
"walletSend": {
"title": "ផ្ញើ Bitcoin",
"send": "ផ្ញើ Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "ប៉ះម្ដងទៀតដើម្បីបញ្ជាក់",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "មិនមាន Bitcoin គ្រប់គ្រាន់",
+19 -20
View File
@@ -508,20 +508,23 @@
"wallet": "د بټ‌کوین پاکټ",
"myWalletLabel": "د {{name}} پاکټ",
"myWalletDefault": "زما پاکټ",
"walletHeroNote": "بسپنې مستقیماً ستاسو په خپل اګورا (Agora) پاکټ کې راځي. نه منځګړی، نه د تادیې جوړونه، نه انتظار.",
"walletHeroReassurance": "تاسو کلی لرئ، نو پیسې هم تاسو لرئ. کله هم چې وغواړئ د پاکټ له ټوپ څخه یې وباسئ.",
"walletChoose": "پاکټ وټاکئ",
"walletCustom": "ګمرکي",
"walletUseCustom": "ګمرکي پاکټ وکاروئ",
"walletUseMine": "زما د اګورا پاکټ وکاروئ",
"acceptAll": "د ټولو پیسو ډولونو منل",
"acceptPublic": "یوازې د عامه پیسو منل",
"acceptPrivate": "یوازې د خصوصي پیسو منل",
"acceptAllShort": "ټول ومنه",
"acceptPublicShort": "یوازې عامه",
"acceptPrivateShort": "یوازې خصوصي",
"acceptAllHint": "د عامه آن‌چین او خصوصي چپ پیسو دواړه ومنه.",
"acceptPublicHint": "یوازې عامه پته ته آن‌چین مرستې ومنه.",
"acceptPrivateHint": "یوازې چپ پیسې ومنه — د مرسته‌کوونکو پتې پټې پاتې کیږي.",
"customWalletIntro": "د بټ‌کوین پته، د چپ پیسو کوډ، یا دواړه دننه کړئ. لږ تر لږه یو ته اړتیا ده.",
"acceptHeading": "کومې بسپنې به ومنئ؟",
"acceptUnavailable": "د دې لاگ‌ین سره شتون نه لري.",
"acceptAllTitle": "هره بسپنه",
"acceptPublicTitle": "یوازې عامه بسپنې",
"acceptPrivateTitle": "یوازې پټې بسپنې",
"acceptAllHint": "عامه او پټې بسپنې دواړه ومنئ.",
"acceptPublicHint": "بسپنه‌ورکوونکي یوې عادي Bitcoin پتې ته ورکوي. دا بسپنې هر چا ته ښکاري.",
"acceptPrivateHint": "بسپنه‌ورکوونکي په پټه توګه ورکوي، نو د دوی پېژندنه له عامو خلکو پټه پاتې کیږي.",
"customWalletIntro": "هره بسپنه چې غواړئ ومنئ ډکه کړئ: یوه عامه پته، یو پټ کوډ، یا دواړه. لږ تر لږه یو ته اړتیا ده.",
"customOnchainMeaning": "عامه. هر څوک کولی شي دا بسپنې وویني.",
"customSpMeaning": "پټه. د بسپنه‌ورکوونکي پېژندنه پټه پاتې کیږي.",
"bitcoinAddress": "د بټ‌کوین پته",
"bitcoinAddressPlaceholder": "bc1q… یا bc1p…",
"silentPaymentCode": "د چپ پیسو کوډ",
@@ -556,7 +559,6 @@
"goal": "هدف",
"goalPlaceholder": "25,000",
"goalNote": "ټول امریکايي ډالر. ډونرز په بټ‌کوین پیسې ورکوي؛ کلائنټونه د لیدلو په وخت کې د ډالر معادل اټکل کوي.",
"deadline": "وروستۍ نېټه",
"submitCreate": "کمپاین پیلول",
"submitEdit": "د کمپاین تازه کول",
"publishing": "خپرول…",
@@ -581,8 +583,6 @@
"errorSpInvalid": "د چپ پیسو کوډ د پېژندل شوي BIP-352 کوډ نه دی (sp1…).",
"errorWalletRequired": "لږ تر لږه یوه د پاکټ نقطه ورکړئ — د بټ‌کوین mainnet پته (bc1q… / bc1p…) یا د چپ پیسو کوډ (sp1…).",
"errorGoalInvalid": "هدف باید د ډالر مثبته بشپړه اندازه وي.",
"errorDeadlinePast": "وروستۍ نېټه نشي کولی په تېر وخت کې وي.",
"errorDeadlineInvalid": "وروستۍ نېټه سمه نېټه نه ده.",
"errorEditLatestMissing": "د دې کمپاین وروستۍ نسخه د تازه کولو لپاره ونه موندل شوه.",
"errorSlugCollision": "تاسو لا دمخه «{{slug}}» پېژندونکی کمپاین لرئ. بل وټاکئ.",
"errorBannerInvalid": "بنر باید د https:// سمه نښه وي.",
@@ -599,6 +599,8 @@
"bannerStepSubtitle": "یو زړه‌راښکونکی انځور په هر کارت کې کمپاین ښیي.",
"storyStepTitle": "خپله کیسه ووایاست",
"storyStepSubtitle": "څوک ګټه اخلي او بسپنې به څنګه ولګول شي.",
"goalStepTitle": "هدف",
"goalStepSubtitle": "اختیاري — د بې‌مهاله کمپاین لپاره یې تش پرېږدئ.",
"next": "بل",
"back": "شاته",
"skip": "تېرول",
@@ -608,11 +610,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | د {{appName}} کمپاینونه",
"seoDescriptionFallback": "په {{appName}} کې د {{title}} ملاتړ وکړئ.",
"deadlineEndedOn": "په {{date}} پای ته ورسیده",
"deadlineEndsToday": "نن پای ته رسي",
"deadlineDaysLeft_one": "{{count}} ورځ پاتې",
"deadlineDaysLeft_other": "{{count}} ورځې پاتې",
"deadlineEndsOn": "په {{date}} پای ته رسي",
"back": "شاته",
"edit": "سمول",
"delete": "ړنګول",
@@ -655,7 +652,6 @@
"deleteDialogTitle": "دا کمپاین ړنګ کړئ؟",
"deleteDialogBody": "دا د NIP-09 د ړنګولو غوښتنه خپروي. ښه چلند کوونکي ریلې به کمپاین له فیدونو او مستقیمو لینکونو څخه لرې کړي. د تېرو مرستو رسیدونه پر چین پاتې کیږي. دا کار بیرته نه راګرځول کیږي — د مرستو د منلو لپاره، کمپاین سم کړئ.",
"storyHeading": "کیسه",
"campaignEnded": "کمپاین پای ته ورسید",
"donate": "مرسته",
"share": "شریکول",
"target": "هدف: {{amount}}",
@@ -756,7 +752,7 @@
"wlcDesc": "د World Liberty Congress لخوا غوره شوي کمپاینونه.",
"allCampaigns": "ټول کمپاینونه",
"allCampaignsDesc": "د شبکې ټول کمپاینونه، د وخت په ترتیب.",
"browseAll": "ټول کمپاینونه وګورئ",
"browseAll": "ټول کمپاینونه وګورئ",
"hidden": "پټ شوي",
"hiddenDesc": "هغه کمپاینونه چې د عمومي کور پاڼې څخه پټ شوي. د بېرته ښودلو لپاره د کارت مینو وکاروئ.",
"hiddenEmpty": "اوس مهال هیڅ کمپاین پټ نه دی.",
@@ -826,6 +822,8 @@
"lists": {
"stripAria": "د کمپاین موضوعاتو ترتیب شوي لیستونه",
"create": "نوی لیست",
"showMore": "{{count}} نور وښایه",
"showLess": "لږ وښایه",
"createDesc": "د موضوع یو نوی لیست جوړ کړئ. د هر کمپاین له پاڼې څخه کمپاینونه پکې ترتیب کړئ.",
"createSubmit": "لیست جوړ کړئ",
"createFailed": "د لیست جوړول ناکام شول",
@@ -1244,6 +1242,7 @@
"walletSend": {
"title": "بټکوین لیږل",
"send": "بټکوین لیږل",
"max": "MAX",
"tapAgainToConfirm": "د تایید لپاره بیا ټک ووهئ",
"satPerVB": "{{rate}} ساتوشي/vB",
"notEnoughBitcoin": "کافی بټکوین نشته",
+19 -20
View File
@@ -940,20 +940,23 @@
"wallet": "Carteira Bitcoin",
"myWalletLabel": "Carteira de {{name}}",
"myWalletDefault": "Minha carteira",
"walletHeroNote": "As doações vão direto para a sua própria carteira Agora. Sem intermediários, sem configuração de pagamento, sem espera.",
"walletHeroReassurance": "Você guarda a chave, então você guarda os fundos. Saque a qualquer momento na aba da carteira.",
"walletChoose": "Escolher uma carteira",
"walletCustom": "Personalizada",
"walletUseCustom": "Usar uma carteira personalizada",
"walletUseMine": "Usar minha carteira Agora",
"acceptAll": "Aceitar todos os tipos de pagamento",
"acceptPublic": "Aceitar apenas pagamentos públicos",
"acceptPrivate": "Aceitar apenas pagamentos privados",
"acceptAllShort": "Aceitar todos",
"acceptPublicShort": "Apenas públicos",
"acceptPrivateShort": "Apenas privados",
"acceptAllHint": "Aceitar pagamentos públicos on-chain e pagamentos silenciosos privados.",
"acceptPublicHint": "Aceitar apenas doações on-chain para um endereço público.",
"acceptPrivateHint": "Aceitar apenas pagamentos silenciosos — os endereços dos doadores permanecem privados.",
"customWalletIntro": "Digite um endereço Bitcoin, um código de pagamento silencioso, ou ambos. Pelo menos um é obrigatório.",
"acceptHeading": "Quais doações você vai aceitar?",
"acceptUnavailable": "Não disponível com este login.",
"acceptAllTitle": "Qualquer doação",
"acceptPublicTitle": "Somente doações públicas",
"acceptPrivateTitle": "Somente doações privadas",
"acceptAllHint": "Receber doações tanto públicas quanto privadas.",
"acceptPublicHint": "Os doadores enviam para um endereço Bitcoin comum. Essas doações ficam visíveis para qualquer pessoa.",
"acceptPrivateHint": "Os doadores enviam de forma privada, então a identidade deles fica oculta do público.",
"customWalletIntro": "Preencha as doações que você quiser aceitar: um endereço público, um código privado, ou ambos. Pelo menos um é obrigatório.",
"customOnchainMeaning": "Público. Qualquer pessoa pode ver essas doações.",
"customSpMeaning": "Privado. A identidade do doador fica oculta.",
"bitcoinAddress": "Endereço Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… ou bc1p…",
"silentPaymentCode": "Código de pagamento silencioso",
@@ -988,7 +991,6 @@
"goal": "Meta",
"goalPlaceholder": "25.000",
"goalNote": "Dólares americanos inteiros. Doadores pagam em Bitcoin; clientes estimam o equivalente em USD na hora da visualização.",
"deadline": "Prazo",
"submitCreate": "Lançar campanha",
"submitEdit": "Atualizar campanha",
"publishing": "Publicando…",
@@ -1013,8 +1015,6 @@
"errorSpInvalid": "O código de pagamento silencioso não é um código BIP-352 reconhecido (sp1…).",
"errorWalletRequired": "Forneça pelo menos um endpoint de carteira — um endereço Bitcoin mainnet (bc1q… / bc1p…) ou um código de pagamento silencioso (sp1…).",
"errorGoalInvalid": "A meta deve ser um valor inteiro positivo em dólares.",
"errorDeadlinePast": "O prazo não pode estar no passado.",
"errorDeadlineInvalid": "O prazo não é uma data válida.",
"errorEditLatestMissing": "Não foi possível encontrar a versão mais recente desta campanha para atualizar.",
"errorSlugCollision": "Você já tem uma campanha com o identificador \"{{slug}}\". Escolha outro.",
"errorBannerInvalid": "O banner deve ser uma URL https:// válida.",
@@ -1031,6 +1031,8 @@
"bannerStepSubtitle": "Uma imagem marcante representa a campanha em cada card.",
"storyStepTitle": "Conte sua história",
"storyStepSubtitle": "Quem se beneficia e como os recursos serão usados.",
"goalStepTitle": "Meta",
"goalStepSubtitle": "Opcional — deixe em branco para uma campanha sem prazo definido.",
"next": "Próximo",
"back": "Voltar",
"skip": "Pular",
@@ -1040,11 +1042,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | Arrecadações {{appName}}",
"seoDescriptionFallback": "Apoie {{title}} no {{appName}}.",
"deadlineEndedOn": "Encerrada em {{date}}",
"deadlineEndsToday": "Encerra hoje",
"deadlineDaysLeft_one": "{{count}} dia restante",
"deadlineDaysLeft_other": "{{count}} dias restantes",
"deadlineEndsOn": "Encerra em {{date}}",
"back": "Voltar",
"edit": "Editar",
"delete": "Excluir",
@@ -1087,7 +1084,6 @@
"deleteDialogTitle": "Excluir esta campanha?",
"deleteDialogBody": "Isso publica uma solicitação de exclusão NIP-09. Relays bem-comportados retirarão a campanha dos feeds e links diretos. Recibos de doações passadas permanecem on-chain de qualquer forma. Esta ação não pode ser desfeita — para continuar aceitando doações, edite a campanha.",
"storyHeading": "A história",
"campaignEnded": "Campanha encerrada",
"donate": "Doar",
"share": "Compartilhar",
"target": "Meta: {{amount}}",
@@ -1188,7 +1184,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.",
@@ -1258,6 +1254,8 @@
"lists": {
"stripAria": "Listas de tópicos de campanhas curadas",
"create": "Nova lista",
"showMore": "Mostrar mais {{count}}",
"showLess": "Mostrar menos",
"createDesc": "Crie uma nova lista de tópicos. Curadoria de campanhas para ela a partir de qualquer página de campanha.",
"createSubmit": "Criar lista",
"createFailed": "Falha ao criar lista",
@@ -1677,6 +1675,7 @@
"walletSend": {
"title": "Enviar Bitcoin",
"send": "Enviar Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Toque novamente para confirmar",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "Bitcoin insuficiente",
+16 -19
View File
@@ -940,19 +940,20 @@
"wallet": "Bitcoin-кошелёк",
"myWalletLabel": "Кошелёк {{name}}",
"myWalletDefault": "Мой кошелёк",
"walletHeroNote": "Пожертвования поступают напрямую в ваш собственный кошелёк Agora. Без посредников, без настройки выплат, без ожидания.",
"walletHeroReassurance": "Ключ у вас, а значит, и средства у вас. Выводите их в любой момент на вкладке кошелька.",
"walletChoose": "Выбрать кошелёк",
"walletCustom": "Пользовательский",
"walletUseCustom": "Использовать пользовательский кошелёк",
"walletUseMine": "Использовать мой кошелёк Agora",
"acceptAll": "Принимать все типы платежей",
"acceptPublic": "Принимать только публичные платежи",
"acceptPrivate": "Принимать только приватные платежи",
"acceptAllShort": "Принимать все",
"acceptPublicShort": "Только публичные",
"acceptPrivateShort": "Только приватные",
"acceptAllHint": "Принимать как публичные ончейн-платежи, так и приватные тихие платежи.",
"acceptPublicHint": "Принимать только ончейн-пожертвования на публичный адрес.",
"acceptPrivateHint": "Принимать только тихие платежи — адреса донаторов остаются приватными.",
"acceptHeading": "Какие пожертвования вы готовы принимать?",
"acceptUnavailable": "Недоступно при этом способе входа.",
"acceptAllTitle": "Любые пожертвования",
"acceptPublicTitle": "Только публичные пожертвования",
"acceptPrivateTitle": "Только приватные пожертвования",
"acceptAllHint": "Принимать как публичные, так и приватные пожертвования.",
"acceptPublicHint": "Жертвователи отправляют средства на обычный Bitcoin-адрес. Такие пожертвования видны всем.",
"acceptPrivateHint": "Жертвователи отправляют средства приватно, поэтому их личность остаётся скрытой от посторонних.",
"customWalletIntro": "Введите Bitcoin-адрес, код тихого платежа или оба. Требуется хотя бы один.",
"bitcoinAddress": "Bitcoin-адрес",
"bitcoinAddressPlaceholder": "bc1q… или bc1p…",
@@ -988,7 +989,6 @@
"goal": "Цель",
"goalPlaceholder": "25 000",
"goalNote": "Целые доллары США. Жертвователи платят в Bitcoin; клиенты оценивают эквивалент в USD во время просмотра.",
"deadline": "Срок",
"submitCreate": "Запустить кампанию",
"submitEdit": "Обновить кампанию",
"publishing": "Публикация…",
@@ -1013,8 +1013,6 @@
"errorSpInvalid": "Код тихого платежа не является распознанным кодом BIP-352 (sp1…).",
"errorWalletRequired": "Укажите хотя бы один эндпойнт кошелька — Bitcoin-адрес mainnet (bc1q… / bc1p…) или код тихого платежа (sp1…).",
"errorGoalInvalid": "Цель должна быть положительной целой суммой в долларах.",
"errorDeadlinePast": "Срок не может быть в прошлом.",
"errorDeadlineInvalid": "Срок не является действительной датой.",
"errorEditLatestMissing": "Не удалось найти последнюю версию этой кампании для обновления.",
"errorSlugCollision": "У вас уже есть кампания с идентификатором «{{slug}}». Выберите другой.",
"errorBannerInvalid": "Баннер должен быть валидной URL https://.",
@@ -1031,6 +1029,8 @@
"bannerStepSubtitle": "Одно яркое изображение представит кампанию на каждой карточке.",
"storyStepTitle": "Расскажите свою историю",
"storyStepSubtitle": "Кому это поможет и как будут использованы средства.",
"goalStepTitle": "Цель",
"goalStepSubtitle": "Необязательно — оставьте пустым для бессрочной кампании.",
"next": "Далее",
"back": "Назад",
"skip": "Пропустить",
@@ -1040,11 +1040,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | Сборы средств {{appName}}",
"seoDescriptionFallback": "Поддержите {{title}} на {{appName}}.",
"deadlineEndedOn": "Завершено {{date}}",
"deadlineEndsToday": "Завершается сегодня",
"deadlineDaysLeft_one": "Остался {{count}} день",
"deadlineDaysLeft_other": "Осталось {{count}} дней",
"deadlineEndsOn": "Завершается {{date}}",
"back": "Назад",
"edit": "Изменить",
"delete": "Удалить",
@@ -1087,7 +1082,6 @@
"deleteDialogTitle": "Удалить эту кампанию?",
"deleteDialogBody": "Это публикует запрос на удаление NIP-09. Корректно работающие реле уберут кампанию из лент и прямых ссылок. Прошлые квитанции о пожертвованиях остаются в блокчейне независимо. Это действие нельзя отменить — чтобы продолжать принимать пожертвования, отредактируйте кампанию.",
"storyHeading": "История",
"campaignEnded": "Кампания завершена",
"donate": "Пожертвовать",
"share": "Поделиться",
"target": "Цель: {{amount}}",
@@ -1188,7 +1182,7 @@
"wlcDesc": "Кампании, отобранные World Liberty Congress.",
"allCampaigns": "Все кампании",
"allCampaignsDesc": "Все кампании в сети, в хронологическом порядке.",
"browseAll": "Просмотреть все кампании",
"browseAll": "Просмотреть все кампании",
"hidden": "Скрытые",
"hiddenDesc": "Кампании, скрытые с публичной главной страницы. Используйте меню с тремя точками на карточке, чтобы вернуть.",
"hiddenEmpty": "В настоящее время нет скрытых кампаний.",
@@ -1258,6 +1252,8 @@
"lists": {
"stripAria": "Кураторские тематические списки кампаний",
"create": "Новый список",
"showMore": "Показать ещё {{count}}",
"showLess": "Скрыть",
"createDesc": "Создайте новый тематический список. Добавляйте в него кампании с любой страницы кампании.",
"createSubmit": "Создать список",
"createFailed": "Не удалось создать список",
@@ -1677,6 +1673,7 @@
"walletSend": {
"title": "Отправить Bitcoin",
"send": "Отправить Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Нажмите ещё раз для подтверждения",
"satPerVB": "{{rate}} сат/vB",
"notEnoughBitcoin": "Недостаточно биткоинов",
+19 -20
View File
@@ -508,20 +508,23 @@
"wallet": "Chikwama cheBitcoin",
"myWalletLabel": "Chikwama cha{{name}}",
"myWalletDefault": "Chikwama changu",
"walletHeroNote": "Zvipo zvinopinda zvakananga muchikwama chako cheAgora. Hapana munhu ari pakati, hapana kugadzirira kubhadharwa, hapana kumirira.",
"walletHeroReassurance": "Iwe ndiwe une kiyi, saka ndiwe une mari. Bvisa mari nguva ipi zvayo kubva mutabhu yechikwama.",
"walletChoose": "Sarudza chikwama",
"walletCustom": "Chenyu",
"walletUseCustom": "Shandisa chikwama chako pachako",
"walletUseMine": "Shandisa chikwama changu cheAgora",
"acceptAll": "Gamuchira mhando dzese dzemubhadharo",
"acceptPublic": "Gamuchira chete mibhadharo yepachena",
"acceptPrivate": "Gamuchira chete mibhadharo yakavanzika",
"acceptAllShort": "Zvose",
"acceptPublicShort": "Zvepachena Chete",
"acceptPrivateShort": "Zvakavanzika Chete",
"acceptAllHint": "Gamuchira mibhadharo yepachena yepa-on-chain neyakavanzika yemubhadharo unyararo.",
"acceptPublicHint": "Gamuchira chete zvipo zvepa-on-chain kukero yepachena.",
"acceptPrivateHint": "Gamuchira chete mubhadharo unyararo — makero evapi anoramba akavanzika.",
"customWalletIntro": "Isa kero yeBitcoin, kodhi yemubhadharo unyararo, kana zvose. Imwechete inodikanwa zvirinani.",
"acceptHeading": "Ndezvipi zvipo zvauchagamuchira?",
"acceptUnavailable": "Hazviwanikwi nekupinda uku.",
"acceptAllTitle": "Chipo chero chipi zvacho",
"acceptPublicTitle": "Zvipo zvepachena chete",
"acceptPrivateTitle": "Zvipo zvakavanzika chete",
"acceptAllHint": "Gamuchira zvipo zvepachena nezvakavanzika.",
"acceptPublicHint": "Vanopa vanopa kukero yeBitcoin yenguva dzose. Zvipo izvi zvinoonekwa nemunhu wese.",
"acceptPrivateHint": "Vanopa vanopa muchivande, saka zita ravo rinoramba rakavanzika kuruzhinji.",
"customWalletIntro": "Zadza chero zvipo zvauinazvo zvaunoda kugamuchira: kero yeruzhinji, kodhi yakavanzika, kana zvose zviri zviviri. Pakuita zvirinani imwechete inodikanwa.",
"customOnchainMeaning": "Zveruzhinji. Munhu wese anogona kuona zvipo izvi.",
"customSpMeaning": "Zvakavanzika. Zita reanopa rinoramba rakavanzika.",
"bitcoinAddress": "Kero yeBitcoin",
"bitcoinAddressPlaceholder": "bc1q… kana bc1p…",
"silentPaymentCode": "Kodhi yemubhadharo unyararo",
@@ -556,7 +559,6 @@
"goal": "Chinangwa",
"goalPlaceholder": "25,000",
"goalNote": "Madhora eUS chete. Vapi vebhadhara muBitcoin; vatengi vanofungidzira mucherechedzo weUSD panguva yokutarisa.",
"deadline": "Mugumo",
"submitCreate": "Burisa campaign",
"submitEdit": "Vandudza campaign",
"publishing": "Kuburitsa…",
@@ -581,8 +583,6 @@
"errorSpInvalid": "Kodhi yemubhadharo unyararo haisi kodhi yeBIP-352 inozivikanwa (sp1…).",
"errorWalletRequired": "Ipa zvirinani imwechete chinangwa chechikwama — kero yeBitcoin mainnet (bc1q… / bc1p…) kana kodhi yemubhadharo unyararo (sp1…).",
"errorGoalInvalid": "Chinangwa chinofanira kunge chiri madhora akakwana anopfuura zero.",
"errorDeadlinePast": "Mugumo haungavi munguva yapfuura.",
"errorDeadlineInvalid": "Mugumo hausi zuva rakanaka.",
"errorEditLatestMissing": "Hatina kuwana shanduro yazvino yecampaign iyi kuti tirivandudze.",
"errorSlugCollision": "Une kare campaign ine kupiwa zita kwe«{{slug}}». Sarudza rimwe.",
"errorBannerInvalid": "Bhana inofanira kuva URL ye https:// chaiyo.",
@@ -599,6 +599,8 @@
"bannerStepSubtitle": "Mufananidzo mumwe chete unotapira unotakura campaign pakadhi rega rega.",
"storyStepTitle": "Taura nyaya yako",
"storyStepSubtitle": "Vanobatsirwa ndivanaani uye mari ichashandiswa sei.",
"goalStepTitle": "Chinangwa",
"goalStepSubtitle": "Zvisina kumanikidzwa — siya pasina kuti uite campaign isina mugumo.",
"next": "Inotevera",
"back": "Dzokera",
"skip": "Darika",
@@ -608,11 +610,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | Macampaign e{{appName}}",
"seoDescriptionFallback": "Tsigira {{title}} pa{{appName}}.",
"deadlineEndedOn": "Zvakapera pa{{date}}",
"deadlineEndsToday": "Zvinopera nhasi",
"deadlineDaysLeft_one": "{{count}} zuva rasara",
"deadlineDaysLeft_other": "Mazuva {{count}} asara",
"deadlineEndsOn": "Zvinopera pa{{date}}",
"back": "Dzokera",
"edit": "Gadzirisa",
"delete": "Bvisa",
@@ -655,7 +652,6 @@
"deleteDialogTitle": "Bvisa campaign iyi?",
"deleteDialogBody": "Izvi zvinoburitsa chikumbiro chekubvisa cheNIP-09. Marelay anozvibata zvakanaka achabvisa campaign mufeeds nezvirink zvakananga. Rezvi dzezvipo dzapfuura dzinoramba dzakachengetwa pachain. Izvi hazvigoni kudzosererwa — kuti urambe uchigamuchira zvipo, gadzirisa campaign panzvimbo yokuti uibvise.",
"storyHeading": "Nyaya",
"campaignEnded": "Campaign yapera",
"donate": "Ipa",
"share": "Govera",
"target": "Chinangwa: {{amount}}",
@@ -756,7 +752,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.",
@@ -826,6 +822,8 @@
"lists": {
"stripAria": "Manjuriro emisoro yemishandirapamwe yakasarudzwa",
"create": "Rondedzero itsva",
"showMore": "Ratidza {{count}} dzimwe",
"showLess": "Ratidza zvishoma",
"createDesc": "Gadzira rondedzero itsva yemisoro. Sarudza mishandirapamwe muiri kubva papeji ipi neipi yemushandirapamwe.",
"createSubmit": "Gadzira rondedzero",
"createFailed": "Kugadzira rondedzero hakuna kubudirira",
@@ -1244,6 +1242,7 @@
"walletSend": {
"title": "Tumira Bitcoin",
"send": "Tumira Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Dzvanya zvakare kuti usimbise",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "Bitcoin haina kukwana",
+17 -20
View File
@@ -939,20 +939,23 @@
"wallet": "Pochi ya Bitcoin",
"myWalletLabel": "Pochi ya {{name}}",
"myWalletDefault": "Pochi yangu",
"walletHeroNote": "Michango huingia moja kwa moja kwenye pochi yako mwenyewe ya Agora. Hakuna mtu wa kati, hakuna usanidi wa malipo, hakuna kusubiri.",
"walletHeroReassurance": "Wewe ndiye unayeshikilia ufunguo, kwa hivyo wewe ndiye unayeshikilia pesa. Toa wakati wowote kupitia kichupo cha pochi.",
"walletChoose": "Chagua pochi",
"walletCustom": "Maalum",
"walletUseCustom": "Tumia pochi maalum badala yake",
"walletUseMine": "Tumia pochi yangu ya Agora",
"acceptAll": "Kubali aina zote za malipo",
"acceptPublic": "Kubali malipo ya umma pekee",
"acceptPrivate": "Kubali malipo ya faragha pekee",
"acceptAllShort": "Zote",
"acceptPublicShort": "Umma Pekee",
"acceptPrivateShort": "Faragha Pekee",
"acceptAllHint": "Kubali malipo ya umma kwenye mnyororo na malipo ya kimya ya faragha.",
"acceptPublicHint": "Kubali tu michango ya kwenye mnyororo kwa anwani ya umma.",
"acceptPrivateHint": "Kubali tu malipo ya kimya — anwani za wachangiaji zinabaki za faragha.",
"customWalletIntro": "Weka anwani ya Bitcoin, msimbo wa malipo ya kimya, au zote mbili. Angalau moja inahitajika.",
"acceptHeading": "Utakubali michango ya aina gani?",
"acceptUnavailable": "Haipatikani kwa kuingia huku.",
"acceptAllTitle": "Mchango wowote",
"acceptPublicTitle": "Michango ya umma pekee",
"acceptPrivateTitle": "Michango ya faragha pekee",
"acceptAllHint": "Pokea michango ya umma na ya faragha vyote.",
"acceptPublicHint": "Wachangiaji hutoa kwa anwani ya kawaida ya Bitcoin. Michango hii inaonekana kwa mtu yeyote.",
"acceptPrivateHint": "Wachangiaji hutoa kwa faragha, kwa hivyo utambulisho wao unabaki umefichwa kutoka kwa umma.",
"customWalletIntro": "Jaza michango yoyote unayotaka kupokea: anwani ya umma, msimbo wa siri, au zote mbili. Angalau moja inahitajika.",
"customOnchainMeaning": "Ya umma. Mtu yeyote anaweza kuona michango hii.",
"customSpMeaning": "Ya faragha. Utambulisho wa mchangiaji unabaki umefichwa.",
"bitcoinAddress": "Anwani ya Bitcoin",
"bitcoinAddressPlaceholder": "bc1q… au bc1p…",
"silentPaymentCode": "Msimbo wa malipo ya kimya",
@@ -987,7 +990,6 @@
"goal": "Lengo",
"goalPlaceholder": "25,000",
"goalNote": "Dola za Marekani kamili. Wafadhili hulipa kwa Bitcoin; wateja hukadiria kiwango sawa cha USD wakati wa kutazama.",
"deadline": "Tarehe ya mwisho",
"submitCreate": "Zindua kampeni",
"submitEdit": "Sasisha kampeni",
"publishing": "Inachapisha…",
@@ -1012,8 +1014,6 @@
"errorSpInvalid": "Msimbo wa malipo ya kimya si msimbo wa BIP-352 unaotambulika (sp1…).",
"errorWalletRequired": "Toa angalau ncha moja ya pochi — anwani ya mtandao mkuu wa Bitcoin (bc1q… / bc1p…) au msimbo wa malipo ya kimya (sp1…).",
"errorGoalInvalid": "Lengo lazima liwe kiasi chanya cha dola kamili.",
"errorDeadlinePast": "Tarehe ya mwisho haiwezi kuwa iliyopita.",
"errorDeadlineInvalid": "Tarehe ya mwisho si tarehe halali.",
"errorEditLatestMissing": "Haikuweza kupata toleo la hivi karibuni la kampeni hii kusasisha.",
"errorSlugCollision": "Tayari una kampeni yenye kitambulisho \"{{slug}}\". Chagua nyingine.",
"errorBannerInvalid": "Bango lazima liwe URL halali ya https://.",
@@ -1039,11 +1039,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | Kampeni za {{appName}}",
"seoDescriptionFallback": "Unga mkono {{title}} kwenye {{appName}}.",
"deadlineEndedOn": "Imeisha {{date}}",
"deadlineEndsToday": "Inaisha leo",
"deadlineDaysLeft_one": "siku {{count}} imebaki",
"deadlineDaysLeft_other": "siku {{count}} zimebaki",
"deadlineEndsOn": "Inaisha {{date}}",
"back": "Rudi",
"edit": "Hariri",
"delete": "Futa",
@@ -1086,7 +1081,6 @@
"deleteDialogTitle": "Futa kampeni hii?",
"deleteDialogBody": "Hii inachapisha ombi la kufutwa la NIP-09. Relei zinazoendesha vyema zitaiondoa kampeni kutoka kwa milisho na viungo vya moja kwa moja. Risiti za michango zilizopita zinabaki katika mnyororo bila kujali. Kitendo hiki hakiwezi kutenduliwa — ili kuendelea kupokea michango, hariri kampeni badala yake.",
"storyHeading": "Hadithi",
"campaignEnded": "Kampeni imeisha",
"donate": "Changia",
"share": "Shiriki",
"target": "Lengo: {{amount}}",
@@ -1187,7 +1181,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.",
@@ -1229,6 +1223,8 @@
"lists": {
"stripAria": "Orodha za mada za kampeni zilizoratibiwa",
"create": "Orodha mpya",
"showMore": "Onyesha {{count}} zaidi",
"showLess": "Onyesha kidogo",
"createDesc": "Tengeneza orodha mpya ya mada. Iratibu kampeni ndani yake kutoka ukurasa wowote wa kampeni.",
"createSubmit": "Tengeneza orodha",
"createFailed": "Imeshindikana kutengeneza orodha",
@@ -1544,6 +1540,7 @@
"walletSend": {
"title": "Tuma Bitcoin",
"send": "Tuma Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Bonyeza tena kuthibitisha",
"satPerVB": "sat/vB {{rate}}",
"notEnoughBitcoin": "Bitcoin haitoshi",
+19 -20
View File
@@ -939,20 +939,23 @@
"wallet": "Bitcoin cüzdanı",
"myWalletLabel": "{{name}} cüzdanı",
"myWalletDefault": "Cüzdanım",
"walletHeroNote": "Bağışlar doğrudan kendi Agora cüzdanınıza akar. Aracı yok, ödeme ayarı yok, bekleme yok.",
"walletHeroReassurance": "Anahtar sizde olduğu için para da sizde. Cüzdan sekmesinden istediğiniz zaman çekebilirsiniz.",
"walletChoose": "Bir cüzdan seçin",
"walletCustom": "Özel",
"walletUseCustom": "Bunun yerine özel bir cüzdan kullan",
"walletUseMine": "Agora cüzdanımı kullan",
"acceptAll": "Tüm ödeme türlerini kabul et",
"acceptPublic": "Yalnızca açık ödemeleri kabul et",
"acceptPrivate": "Yalnızca gizli ödemeleri kabul et",
"acceptAllShort": "Tümünü Kabul Et",
"acceptPublicShort": "Yalnızca Açık",
"acceptPrivateShort": "Yalnızca Gizli",
"acceptAllHint": "Hem açık zincir üstü hem de gizli sessiz ödemeleri kabul edin.",
"acceptPublicHint": "Yalnızca açık bir adrese yapılan zincir üstü bağışları kabul edin.",
"acceptPrivateHint": "Yalnızca sessiz ödemeleri kabul edin — bağışçı adresleri gizli kalır.",
"customWalletIntro": "Bir Bitcoin adresi, bir sessiz ödeme kodu ya da her ikisini birden girin. En az biri zorunludur.",
"acceptHeading": "Hangi bağışları kabul edeceksiniz?",
"acceptUnavailable": "Bu girişle kullanılamaz.",
"acceptAllTitle": "Her türlü bağış",
"acceptPublicTitle": "Yalnızca açık bağışlar",
"acceptPrivateTitle": "Yalnızca gizli bağışlar",
"acceptAllHint": "Hem açık hem de gizli bağışları kabul edin.",
"acceptPublicHint": "Bağışçılar normal bir Bitcoin adresine gönderir. Bu bağışlar herkese görünür.",
"acceptPrivateHint": "Bağışçılar gizlice gönderir, böylece kimlikleri herkesten gizli kalır.",
"customWalletIntro": "Kabul etmek istediğiniz bağışları girin: açık bir adres, gizli bir kod ya da her ikisi. En az biri gereklidir.",
"customOnchainMeaning": "Açık. Bu bağışları herkes görebilir.",
"customSpMeaning": "Gizli. Bağışçının kimliği gizli kalır.",
"bitcoinAddress": "Bitcoin adresi",
"bitcoinAddressPlaceholder": "bc1q… veya bc1p…",
"silentPaymentCode": "Sessiz ödeme kodu",
@@ -987,7 +990,6 @@
"goal": "Hedef",
"goalPlaceholder": "25.000",
"goalNote": "Tam ABD Doları. Bağışçılar Bitcoin ile öder; istemciler görüntüleme anında USD karşılığını tahmin eder.",
"deadline": "Son tarih",
"submitCreate": "Kampanyayı başlat",
"submitEdit": "Kampanyayı güncelle",
"publishing": "Yayımlanıyor…",
@@ -1012,8 +1014,6 @@
"errorSpInvalid": "Sessiz ödeme kodu tanınan bir BIP-352 kodu değil (sp1…).",
"errorWalletRequired": "En az bir cüzdan uç noktası sağlayın — bir Bitcoin mainnet adresi (bc1q… / bc1p…) ya da bir sessiz ödeme kodu (sp1…).",
"errorGoalInvalid": "Hedef pozitif bir tam dolar tutarı olmalıdır.",
"errorDeadlinePast": "Son tarih geçmişte olamaz.",
"errorDeadlineInvalid": "Son tarih geçerli bir tarih değil.",
"errorEditLatestMissing": "Bu kampanyanın güncellemek için en son sürümü bulunamadı.",
"errorSlugCollision": "\"{{slug}}\" tanımlayıcısına sahip bir kampanyanız zaten var. Başkasını seçin.",
"errorBannerInvalid": "Pankart geçerli bir https:// URL'i olmalıdır.",
@@ -1030,6 +1030,8 @@
"bannerStepSubtitle": "Çarpıcı tek bir görsel, kampanyayı her kartta öne çıkarır.",
"storyStepTitle": "Hikâyenizi anlatın",
"storyStepSubtitle": "Kimin yararlanacağını ve fonların nasıl kullanılacağını anlatın.",
"goalStepTitle": "Hedef",
"goalStepSubtitle": "İsteğe bağlı — açık uçlu bir kampanya için boş bırakın.",
"next": "İleri",
"back": "Geri",
"skip": "Atla",
@@ -1039,11 +1041,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} Fon Toplama",
"seoDescriptionFallback": "{{appName}}'da {{title}} kampanyasını destekleyin.",
"deadlineEndedOn": "{{date}} tarihinde bitti",
"deadlineEndsToday": "Bugün bitiyor",
"deadlineDaysLeft_one": "{{count}} gün kaldı",
"deadlineDaysLeft_other": "{{count}} gün kaldı",
"deadlineEndsOn": "{{date}} tarihinde bitiyor",
"back": "Geri",
"edit": "Düzenle",
"delete": "Sil",
@@ -1086,7 +1083,6 @@
"deleteDialogTitle": "Bu kampanya silinsin mi?",
"deleteDialogBody": "Bu, bir NIP-09 silme isteği yayımlar. Düzgün davranan röleler kampanyayı akışlardan ve doğrudan bağlantılardan kaldırır. Geçmiş bağış makbuzları yine zincir üstünde kalır. Bu işlem geri alınamaz — bağış almaya devam etmek için kampanyayı düzenleyin.",
"storyHeading": "Hikaye",
"campaignEnded": "Kampanya bitti",
"donate": "Bağışla",
"share": "Paylaş",
"target": "Hedef: {{amount}}",
@@ -1187,7 +1183,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.",
@@ -1229,6 +1225,8 @@
"lists": {
"stripAria": "Özenle seçilmiş kampanya konu listeleri",
"create": "Yeni liste",
"showMore": "{{count}} tane daha göster",
"showLess": "Daha az göster",
"createDesc": "Yeni bir konu listesi oluşturun. Herhangi bir kampanya sayfasından kampanyaları listeye ekleyin.",
"createSubmit": "Liste oluştur",
"createFailed": "Liste oluşturulamadı",
@@ -1584,6 +1582,7 @@
"walletSend": {
"title": "Bitcoin Gönder",
"send": "Bitcoin Gönder",
"max": "MAX",
"tapAgainToConfirm": "Onaylamak için tekrar dokunun",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "Yetersiz Bitcoin",
+19 -20
View File
@@ -508,20 +508,23 @@
"wallet": "比特幣錢包",
"myWalletLabel": "{{name}} 的錢包",
"myWalletDefault": "我的錢包",
"walletHeroNote": "捐款會直接進入您自己的 Agora 錢包。沒有中間人,不必設定提領,也不用等待。",
"walletHeroReassurance": "您持有金鑰,因此您持有資金。隨時都可以從錢包分頁提領。",
"walletChoose": "選擇錢包",
"walletCustom": "自定義",
"walletUseCustom": "改用自定義錢包",
"walletUseMine": "使用我的 Agora 錢包",
"acceptAll": "接受所有支付型別",
"acceptPublic": "僅接受公開支付",
"acceptPrivate": "僅接受私密支付",
"acceptAllShort": "全部接受",
"acceptPublicShort": "僅公開",
"acceptPrivateShort": "僅私密",
"acceptAllHint": "同時接受公開的鏈上支付與私密的靜默支付。",
"acceptPublicHint": "僅接受發送至公開地址的鏈上捐款。",
"acceptPrivateHint": "僅接受靜默支付——捐款者的地址將保持私密。",
"customWalletIntro": "輸入比特幣地址、靜默支付代碼或兩者皆可。至少需要一個。",
"acceptHeading": "您願意接受哪些捐款?",
"acceptUnavailable": "此登入方式無法使用。",
"acceptAllTitle": "任何捐款",
"acceptPublicTitle": "僅接受公開捐款",
"acceptPrivateTitle": "僅接受私密捐款",
"acceptAllHint": "同時接受公開與私密的捐款。",
"acceptPublicHint": "捐款者捐到一個一般的比特幣地址。這些捐款任何人都看得到。",
"acceptPrivateHint": "捐款者以私密方式捐款,因此他們的身分不會對外公開。",
"customWalletIntro": "填入您想要接受的任何捐款方式:公開地址、私密代碼,或兩者皆可。至少需要填寫一項。",
"customOnchainMeaning": "公開。任何人都能看到這些捐款。",
"customSpMeaning": "私密。捐款者的身分將保持隱藏。",
"bitcoinAddress": "比特幣地址",
"bitcoinAddressPlaceholder": "bc1q… 或 bc1p…",
"silentPaymentCode": "靜默支付代碼",
@@ -556,7 +559,6 @@
"goal": "目標",
"goalPlaceholder": "25,000",
"goalNote": "整數美元。捐贈者以比特幣支付;客戶端在檢視時估算美元等值。",
"deadline": "截止日期",
"submitCreate": "發起活動",
"submitEdit": "更新活動",
"publishing": "釋出中……",
@@ -581,8 +583,6 @@
"errorSpInvalid": "靜默支付代碼不是已識別的 BIP-352 程式碼(sp1…)。",
"errorWalletRequired": "至少提供一個錢包端點 — 一個比特幣主網地址(bc1q… / bc1p…)或一個靜默支付代碼(sp1…)。",
"errorGoalInvalid": "目標必須是正整數美元金額。",
"errorDeadlinePast": "截止日期不能在過去。",
"errorDeadlineInvalid": "截止日期不是有效的日期。",
"errorEditLatestMissing": "找不到此活動的最新版本以更新。",
"errorSlugCollision": "你已有一個識別符號為「{{slug}}」的活動。請選擇其他。",
"errorBannerInvalid": "橫幅必須是有效的 https:// URL。",
@@ -599,6 +599,8 @@
"bannerStepSubtitle": "一張搶眼的圖片,讓你的活動在每張卡片上脫穎而出。",
"storyStepTitle": "說說你的故事",
"storyStepSubtitle": "誰是受益者,以及這些資金將如何運用。",
"goalStepTitle": "目標",
"goalStepSubtitle": "選填——留空即為不設期限的活動。",
"next": "下一步",
"back": "返回",
"skip": "跳過",
@@ -608,11 +610,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} 募款",
"seoDescriptionFallback": "在 {{appName}} 上支持 {{title}}。",
"deadlineEndedOn": "{{date}} 已結束",
"deadlineEndsToday": "今天截止",
"deadlineDaysLeft_one": "還剩 {{count}} 天",
"deadlineDaysLeft_other": "還剩 {{count}} 天",
"deadlineEndsOn": "{{date}} 截止",
"back": "返回",
"edit": "編輯",
"delete": "刪除",
@@ -655,7 +652,6 @@
"deleteDialogTitle": "刪除此活動?",
"deleteDialogBody": "這將釋出一個 NIP-09 刪除請求。行為良好的中繼會將活動從資訊流和直接連結中移除。過往的捐贈收據無論如何都會保留在鏈上。此操作無法撤銷 — 若要繼續接受捐贈,請改為編輯活動。",
"storyHeading": "故事",
"campaignEnded": "活動已結束",
"donate": "捐贈",
"share": "分享",
"target": "目標:{{amount}}",
@@ -756,7 +752,7 @@
"wlcDesc": "由世界自由大會(World Liberty Congress)精選的活動。",
"allCampaigns": "所有活動",
"allCampaignsDesc": "網絡上的所有活動,按時間順序排列。",
"browseAll": "瀏覽所有活動",
"browseAll": "瀏覽所有活動",
"hidden": "已隱藏",
"hiddenDesc": "已從公共首頁隱藏的活動。使用卡片上的選單可以取消隱藏。",
"hiddenEmpty": "當前沒有被隱藏的活動。",
@@ -798,6 +794,8 @@
"lists": {
"stripAria": "精選活動主題清單",
"create": "新清單",
"showMore": "再顯示 {{count}} 個",
"showLess": "顯示較少",
"createDesc": "建立一個新的主題清單。可從任何活動頁面將活動加入其中。",
"createSubmit": "建立清單",
"createFailed": "無法建立清單",
@@ -1152,6 +1150,7 @@
"walletSend": {
"title": "傳送比特幣",
"send": "傳送比特幣",
"max": "MAX",
"tapAgainToConfirm": "再次點選確認",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "比特幣不足",
+19 -20
View File
@@ -508,20 +508,23 @@
"wallet": "比特币钱包",
"myWalletLabel": "{{name}} 的钱包",
"myWalletDefault": "我的钱包",
"walletHeroNote": "捐款将直接进入你自己的 Agora 钱包。没有中间人,无需设置收款,也无需等待。",
"walletHeroReassurance": "你掌握密钥,就掌握资金。随时可在钱包标签页提现。",
"walletChoose": "选择钱包",
"walletCustom": "自定义",
"walletUseCustom": "改用自定义钱包",
"walletUseMine": "使用我的 Agora 钱包",
"acceptAll": "接受所有支付类型",
"acceptPublic": "仅接受公开支付",
"acceptPrivate": "仅接受私密支付",
"acceptAllShort": "全部接受",
"acceptPublicShort": "仅公开",
"acceptPrivateShort": "仅私密",
"acceptAllHint": "同时接受公开链上支付和私密静默支付。",
"acceptPublicHint": "仅接受发送至公开地址的链上捐款。",
"acceptPrivateHint": "仅接受静默支付——捐赠者地址保持私密。",
"customWalletIntro": "输入比特币地址、静默支付代码或两者皆可。至少需要一个。",
"acceptHeading": "你愿意接受哪些捐款?",
"acceptUnavailable": "此登录方式无法使用。",
"acceptAllTitle": "任何捐款",
"acceptPublicTitle": "仅接受公开捐款",
"acceptPrivateTitle": "仅接受私密捐款",
"acceptAllHint": "公开和私密捐款都接受。",
"acceptPublicHint": "捐赠者将款项发送到一个普通的 Bitcoin 地址。这些捐款任何人都能看到。",
"acceptPrivateHint": "捐赠者以私密方式捐款,因此他们的身份不会对外公开。",
"customWalletIntro": "填写您愿意接受的任意捐赠方式:公开地址、私密代码,或两者皆可。至少需要填写一项。",
"customOnchainMeaning": "公开。任何人都能看到这些捐赠。",
"customSpMeaning": "私密。捐赠者的身份将保持隐藏。",
"bitcoinAddress": "比特币地址",
"bitcoinAddressPlaceholder": "bc1q… 或 bc1p…",
"silentPaymentCode": "静默支付代码",
@@ -556,7 +559,6 @@
"goal": "目标",
"goalPlaceholder": "25,000",
"goalNote": "整数美元。捐赠者以比特币支付;客户端在查看时估算美元等值。",
"deadline": "截止日期",
"submitCreate": "发起活动",
"submitEdit": "更新活动",
"publishing": "发布中……",
@@ -581,8 +583,6 @@
"errorSpInvalid": "静默支付代码不是已识别的 BIP-352 代码(sp1…)。",
"errorWalletRequired": "至少提供一个钱包端点 — 一个比特币主网地址(bc1q… / bc1p…)或一个静默支付代码(sp1…)。",
"errorGoalInvalid": "目标必须是正整数美元金额。",
"errorDeadlinePast": "截止日期不能在过去。",
"errorDeadlineInvalid": "截止日期不是有效的日期。",
"errorEditLatestMissing": "找不到此活动的最新版本以更新。",
"errorSlugCollision": "你已有一个标识符为「{{slug}}」的活动。请选择其他。",
"errorBannerInvalid": "横幅必须是有效的 https:// URL。",
@@ -599,6 +599,8 @@
"bannerStepSubtitle": "一张醒目的图片,让活动在每张卡片上脱颖而出。",
"storyStepTitle": "讲述你的故事",
"storyStepSubtitle": "谁将从中受益,资金将如何使用。",
"goalStepTitle": "目标",
"goalStepSubtitle": "可选 — 留空则为开放式活动。",
"next": "下一步",
"back": "返回",
"skip": "跳过",
@@ -608,11 +610,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} 募款",
"seoDescriptionFallback": "在 {{appName}} 上支持 {{title}}。",
"deadlineEndedOn": "{{date}} 已结束",
"deadlineEndsToday": "今天截止",
"deadlineDaysLeft_one": "还剩 {{count}} 天",
"deadlineDaysLeft_other": "还剩 {{count}} 天",
"deadlineEndsOn": "{{date}} 截止",
"back": "返回",
"edit": "编辑",
"delete": "删除",
@@ -655,7 +652,6 @@
"deleteDialogTitle": "删除此活动?",
"deleteDialogBody": "这将发布一个 NIP-09 删除请求。行为良好的中继会将活动从信息流和直接链接中移除。过往的捐赠收据无论如何都会保留在链上。此操作无法撤销 — 若要继续接受捐赠,请改为编辑活动。",
"storyHeading": "故事",
"campaignEnded": "活动已结束",
"donate": "捐赠",
"share": "分享",
"target": "目标:{{amount}}",
@@ -756,7 +752,7 @@
"wlcDesc": "由世界自由大会(World Liberty Congress)精选的活动。",
"allCampaigns": "所有活动",
"allCampaignsDesc": "网络上的所有活动,按时间顺序排列。",
"browseAll": "浏览所有活动",
"browseAll": "浏览所有活动",
"hidden": "已隐藏",
"hiddenDesc": "已从公共首页隐藏的活动。使用卡片上的菜单可以取消隐藏。",
"hiddenEmpty": "当前没有被隐藏的活动。",
@@ -826,6 +822,8 @@
"lists": {
"stripAria": "精选活动主题列表",
"create": "新建列表",
"showMore": "再显示 {{count}} 个",
"showLess": "显示较少",
"createDesc": "创建一个新的主题列表。可以从任意活动页面将活动收录其中。",
"createSubmit": "创建列表",
"createFailed": "创建列表失败",
@@ -1244,6 +1242,7 @@
"walletSend": {
"title": "发送比特币",
"send": "发送比特币",
"max": "MAX",
"tapAgainToConfirm": "再次点击确认",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "比特币不足",
+3 -47
View File
@@ -4,13 +4,10 @@ import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation, Trans } from 'react-i18next';
import type { TFunction } from 'i18next';
import type { NostrEvent } from '@nostrify/nostrify';
import {
CalendarClock,
ChevronLeft,
HandHeart,
MapPin,
Pencil,
Share2,
ShieldCheck,
@@ -91,18 +88,6 @@ function formatSatsFull(sats: number, btcPrice: number | undefined): string {
return `${sats.toLocaleString()} sats`;
}
function formatDeadline(unixSeconds: number, t: TFunction): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
if (diff <= 0) {
return { label: t('campaignsDetail.deadlineEndedOn', { date: new Date(unixSeconds * 1000).toLocaleDateString() }), isPast: true };
}
const days = Math.ceil(diff / 86_400);
if (days <= 1) return { label: t('campaignsDetail.deadlineEndsToday'), isPast: false };
if (days < 60) return { label: t('campaignsDetail.deadlineDaysLeft', { count: days }), isPast: false };
return { label: t('campaignsDetail.deadlineEndsOn', { date: new Date(unixSeconds * 1000).toLocaleDateString() }), isPast: false };
}
function collectReplyEvents(nodes: ReplyNode[], out = new Map<string, NostrEvent>()): Map<string, NostrEvent> {
for (const node of nodes) {
out.set(node.event.id, node.event);
@@ -254,7 +239,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
const authorMetadata = author.data?.metadata;
const cover = sanitizeUrl(campaign.banner) ?? sanitizeUrl(authorMetadata?.banner) ?? sanitizeUrl(authorMetadata?.picture);
const deadline = campaign.deadline ? formatDeadline(campaign.deadline, t) : null;
const countryLabel = getCampaignCountryLabel(campaign);
const raisedSats = stats?.totalSats ?? 0;
const pendingSats = stats?.pendingSats ?? 0;
@@ -347,7 +331,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
statsLoading={statsLoading}
btcPrice={btcPrice}
donations={donationReceipts}
deadline={deadline}
onShare={handleShare}
/>
);
@@ -371,7 +354,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
<CampaignHeading
campaign={displayCampaign}
creatorPubkey={campaign.pubkey}
deadline={deadline}
countryLabel={countryLabel}
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
@@ -944,7 +926,6 @@ function CampaignHero({
interface CampaignHeadingProps {
campaign: ParsedCampaign;
creatorPubkey: string;
deadline: { label: string; isPast: boolean } | null;
countryLabel: string | undefined;
onReply: () => void;
onMore: () => void;
@@ -954,7 +935,6 @@ interface CampaignHeadingProps {
function CampaignHeading({
campaign,
creatorPubkey,
deadline,
countryLabel,
onReply,
onMore,
@@ -983,20 +963,13 @@ function CampaignHeading({
<AuthorByline pubkey={creatorPubkey} />
</div>
{(countryLabel || deadline) && (
{(countryLabel) && (
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-xs sm:text-sm font-medium text-muted-foreground">
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-4" />
{countryLabel}
</span>
)}
{deadline && (
<span className="inline-flex items-center gap-1.5">
<CalendarClock className="size-4" />
{deadline.label}
</span>
)}
</div>
)}
@@ -1056,7 +1029,6 @@ interface DonateColumnProps {
btcPrice: number | undefined;
/** Aggregated kind 8333 donation events, newest first. */
donations: NostrEvent[];
deadline: { label: string; isPast: boolean } | null;
onShare: () => void;
}
@@ -1067,15 +1039,12 @@ function DonateColumn({
statsLoading,
btcPrice,
donations,
deadline,
onShare,
}: DonateColumnProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const hdAccess = useHdWalletAccess();
const [sendOpen, setSendOpen] = useState(false);
const ended = !!deadline?.isPast;
const endedLabel = ended ? t('campaignsDetail.campaignEnded') : null;
const isSilentPayment = !campaign.wallets.onchain;
// The in-app "Pay with Agora" button opens HDSendBitcoinDialog
@@ -1084,7 +1053,6 @@ function DonateColumn({
// they'd use from /wallet to send Bitcoin to anywhere else.
//
// Hide the button when:
// - the campaign has ended.
// - the donor is the campaign owner (paying yourself is a foot-gun).
// - the campaign is silent-payment-only (no on-chain address to
// prefill; SP donations require a BIP-352-aware wallet that derives
@@ -1094,7 +1062,6 @@ function DonateColumn({
// logins don't expose the secret key, so we can't derive child
// keys — see useHdWalletAccess).
const canPayInApp =
!ended &&
!!user &&
!isSilentPayment &&
user.pubkey !== campaign.pubkey &&
@@ -1168,18 +1135,7 @@ function DonateColumn({
)}
{/* Primary actions */}
{ended ? (
<div className="space-y-2">
<Button size="lg" className="w-full" disabled>
<HandHeart className="size-5 mr-2" />
{endedLabel ?? t('campaignsDetail.donate')}
</Button>
<Button variant="outline" size="lg" className="w-full" onClick={onShare}>
<Share2 className="size-4 mr-2" />
{t('campaignsDetail.share')}
</Button>
</div>
) : (
{
// Donors can either pay from their in-app Agora wallet (HD
// send dialog prefilled with the campaign address) or scan the
// QR from any external wallet. Both routes terminate at the
@@ -1207,7 +1163,7 @@ function DonateColumn({
{t('campaignsDetail.share')}
</Button>
</div>
)}
}
</CardContent>
{canPayInApp && campaign.wallets.onchain && (
<HDSendBitcoinDialog
+7 -12
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';
@@ -83,9 +82,9 @@ const WLC_NPUB = 'npub126e6hwd6a5std2upv9a22xwgvd8fyrhsx5wjjchv99g6nv3n4vhs5fr9g
* Hidden-campaign moderation lives entirely on `/campaigns` — the
* Show-hidden toggle there is available to every viewer, and the
* moderator-only Hidden collapsible there is the structured review
* surface. The home page deliberately carries no Hidden affordance
* so it never leads with suppressed content for anyone, moderators
* included.
* surface. The home page applies no label-based filtering of its own:
* the WLC hero row renders exactly what the curated list declares, in
* list order. Curation here is the list's membership, nothing more.
*
* Campaigns are the home page's sole focus. Groups and Pledges each
* have their own dedicated browse pages (`/groups`, `/pledges`).
@@ -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 curator'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}`,
+230 -167
View File
@@ -9,10 +9,16 @@ import { nip19 } from 'nostr-tools';
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Bitcoin,
Check,
ChevronDown,
EyeOff,
Globe,
HandHeart,
HelpCircle,
Loader2,
ShieldCheck,
Upload,
Wallet,
} from 'lucide-react';
@@ -31,7 +37,6 @@ import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAppContext } from '@/hooks/useAppContext';
import { useAuthor } from '@/hooks/useAuthor';
@@ -52,7 +57,6 @@ import {
parseCampaignWallet,
sanitizeCampaignTitle,
} from '@/lib/campaign';
import { getTodayDateInput } from '@/lib/dateInput';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { genUserName } from '@/lib/genUserName';
import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext';
@@ -86,11 +90,6 @@ function getEditTarget(value: string | null): EditTarget | null {
}
}
function formatDateInput(unixSeconds: number | undefined): string {
if (!unixSeconds) return '';
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
}
/**
* Build a NIP-92 `imeta` tag from a Blossom upload's NIP-94 tag array.
*
@@ -197,7 +196,6 @@ export function CreateCampaignPage() {
const [customOnchain, setCustomOnchain] = useState('');
const [customSp, setCustomSp] = useState('');
const [goalUsd, setGoalUsd] = useState('');
const [deadline, setDeadline] = useState('');
const [countryQuery, setCountryQuery] = useState('');
const [countryCode, setCountryCode] = useState('');
/**
@@ -322,7 +320,6 @@ export function CreateCampaignPage() {
// payload, so the slug's only audience is relays and other clients.
const derivedSlug = useMemo(() => buildCampaignSlug(title), [title]);
const activeIdentifier = editCampaign?.identifier ?? derivedSlug.slug;
const minDeadline = useMemo(() => getTodayDateInput(), []);
// Live-parsed custom inputs, used to drive disclaimers and inline
// validation. Empty strings parse to `null` (no inline error).
@@ -362,7 +359,6 @@ export function CreateCampaignPage() {
setCustomSp(editCampaign.wallets.sp?.value ?? '');
setWalletDefaultsApplied(true);
setGoalUsd(editCampaign.goalUsd !== undefined ? String(editCampaign.goalUsd) : '');
setDeadline(formatDateInput(editCampaign.deadline));
const editCountryCode = editCampaign.countryCode ?? '';
setCountryCode(editCountryCode);
setCountryQuery(editCountryCode ? (getCountryInfo(editCountryCode)?.subdivisionName ?? getCountryInfo(editCountryCode)?.name ?? editCountryCode) : '');
@@ -520,18 +516,6 @@ export function CreateCampaignPage() {
goalNum = n;
}
let deadlineNum: number | undefined;
if (deadline.trim()) {
if (deadline < minDeadline) {
throw new Error(t('campaignsCreate.errorDeadlinePast'));
}
const ts = Math.floor(new Date(deadline).getTime() / 1000);
if (!Number.isFinite(ts) || ts <= 0) {
throw new Error(t('campaignsCreate.errorDeadlineInvalid'));
}
deadlineNum = ts;
}
const resolvedCountryCode = countryCode;
// Iterate the canonical category list (not the Set) so the tag
// order on the event is stable and matches the picker's display
@@ -623,7 +607,6 @@ export function CreateCampaignPage() {
if (onchainWallet) tags.push(['w', onchainWallet.value]);
if (spWallet) tags.push(['w', spWallet.value]);
if (goalNum !== undefined) tags.push(['goal', String(goalNum)]);
if (deadlineNum !== undefined) tags.push(['deadline', String(deadlineNum)]);
if (resolvedCountryCode) {
tags.push(['i', createCountryIdentifier(resolvedCountryCode)]);
tags.push(['k', 'iso3166']);
@@ -989,62 +972,47 @@ export function CreateCampaignPage() {
</FormSection>
);
const goalDeadlineSection = (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{/* Goal — integer USD */}
<FormSection
title={(
<span className="inline-flex items-center gap-1.5">
{t('campaignsCreate.goal')}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label={t('campaignsCreate.goalNote')}
>
<HelpCircle className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
{t('campaignsCreate.goalNote')}
</TooltipContent>
</Tooltip>
</span>
)}
requirement="Optional"
>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="campaign-goal"
type="text"
inputMode="numeric"
placeholder={t('campaignsCreate.goalPlaceholder')}
value={goalUsd}
onChange={(e) => setGoalUsd(e.target.value)}
className="pl-7 pr-14"
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
USD
</span>
</div>
</FormSection>
{/* Deadline */}
<FormSection title={t('campaignsCreate.deadline')} requirement="Optional">
const goalSection = (
<FormSection
title={(
<span className="inline-flex items-center gap-1.5">
{t('campaignsCreate.goal')}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label={t('campaignsCreate.goalNote')}
>
<HelpCircle className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
{t('campaignsCreate.goalNote')}
</TooltipContent>
</Tooltip>
</span>
)}
requirement="Optional"
>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="campaign-deadline"
type="date"
min={minDeadline}
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
className="[color-scheme:light] dark:[color-scheme:dark] dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-80"
id="campaign-goal"
type="text"
inputMode="numeric"
placeholder={t('campaignsCreate.goalPlaceholder')}
value={goalUsd}
onChange={(e) => setGoalUsd(e.target.value)}
className="pl-7 pr-14"
/>
</FormSection>
</div>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
USD
</span>
</div>
</FormSection>
);
const header = (
@@ -1119,7 +1087,7 @@ export function CreateCampaignPage() {
{tagsSection}
{bannerSection}
{storySection}
{goalDeadlineSection}
{goalSection}
</div>
{errorAlert}
@@ -1181,7 +1149,7 @@ export function CreateCampaignPage() {
{
title: t('campaignsCreate.wizard.goalStepTitle'),
subtitle: t('campaignsCreate.wizard.goalStepSubtitle'),
body: goalDeadlineSection,
body: goalSection,
},
{
title: t('campaignsCreate.wizard.tagsStepTitle'),
@@ -1243,13 +1211,14 @@ export function CreateCampaignPage() {
* Two modes selectable via a single inline toggle:
*
* 1. **My wallet** (`'mine'`, default when nsec is available) — a
* compact identity card shows the user's avatar, display name and
* live USD/BTC balance, modelled on the wallet-page balance
* treatment. A small pencil affordance to the right is the entry
* point to swap into custom mode; mirror-link beneath the inputs
* swaps back. The HD-wallet mode also surfaces a segmented
* "Accept" picker (All / Public / Private) that picks which
* donation types the campaign accepts.
* primary-tinted hero card (modelled on the onboarding "Save your
* key" surface) whose centerpiece is a linked-icon trio
* (campaign ↔ key ↔ wallet) explaining that donations land in the
* creator's own Agora wallet. An avatar + live USD/BTC balance
* chip confirms the exact destination, and a "Use a custom wallet"
* sub-link swaps into custom mode. The HD-wallet mode also
* surfaces a segmented "Accept" picker (All / Public / Private)
* that picks which donation types the campaign accepts.
* 2. **Custom** (`'custom'`) — two address inputs (on-chain + silent
* payment). At least one must parse to a valid endpoint of its
* mode.
@@ -1337,32 +1306,60 @@ function WalletPicker({
<div className="space-y-4">
{walletSource === 'mine' ? (
<>
{/* Identity + balance row. Pure visual chrome — the swap to
custom mode is handled by the "Use a custom 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">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{myWalletLabel}</p>
{balanceLoading ? (
<Skeleton className="mt-1 h-4 w-24" />
) : btcPrice ? (
<p className="text-xs text-muted-foreground tabular-nums">
<span className="font-medium text-foreground">
{satsToUSD(totalBalance, btcPrice)}
</span>
<span className="mx-1.5 opacity-60">·</span>
<span>{formatBTC(totalBalance)} BTC</span>
</p>
) : (
<p className="text-xs text-muted-foreground tabular-nums">
{formatBTC(totalBalance)} BTC
</p>
)}
{/* Hero card. Modelled on the onboarding "Save your key"
surface: a primary-tinted card whose visual centerpiece
is an icon pair (the campaign -> the wallet) so a
first-time creator instantly grasps that donations land
in their own Agora wallet. The avatar + live balance
below confirm the exact destination. */}
<div className="rounded-xl border-2 border-primary/30 bg-primary/10 p-5 space-y-4">
<div className="flex items-center justify-center gap-3">
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-background shadow-sm ring-2 ring-primary/30">
<HandHeart className="size-7 text-primary" />
</div>
<ArrowRight className="size-5 shrink-0 text-primary rtl:rotate-180" />
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-background shadow-sm ring-2 ring-primary/30">
<Wallet className="size-7 text-primary" />
</div>
</div>
<p className="text-center text-sm leading-relaxed text-foreground">
{t('campaignsCreate.walletHeroNote')}
</p>
{/* Destination confirmation — avatar + live balance, framed
as a self-contained chip so it reads as "this exact
wallet" rather than incidental chrome. */}
<div className="flex items-center gap-3 rounded-lg border border-primary/20 bg-background/60 px-3 py-2.5">
<Avatar className="size-10 shrink-0">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{myWalletLabel}</p>
{balanceLoading ? (
<Skeleton className="mt-1 h-4 w-24" />
) : btcPrice ? (
<p className="text-xs text-muted-foreground tabular-nums">
<span className="font-medium text-foreground">
{satsToUSD(totalBalance, btcPrice)}
</span>
<span className="mx-1.5 opacity-60">·</span>
<span>{formatBTC(totalBalance)} BTC</span>
</p>
) : (
<p className="text-xs text-muted-foreground tabular-nums">
{formatBTC(totalBalance)} BTC
</p>
)}
</div>
</div>
<div className="flex items-start gap-2 border-t border-primary/20 pt-3">
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-primary" />
<p className="text-xs leading-relaxed text-muted-foreground">
{t('campaignsCreate.walletHeroReassurance')}
</p>
</div>
</div>
@@ -1411,6 +1408,13 @@ function WalletPicker({
</div>
</div>
{/* Restate the field-driven accept model in the same plain
voice as the "mine" branch's accept picker, so swapping to
custom mode doesn't drop the public/private hand-holding. */}
<p className="text-xs leading-relaxed text-muted-foreground">
{t('campaignsCreate.customWalletIntro')}
</p>
<CustomWalletInput
id="campaign-wallet-onchain"
label={t('campaignsCreate.bitcoinAddress')}
@@ -1436,11 +1440,14 @@ function WalletPicker({
}
/**
* Segmented "Accept" picker for the HD-wallet branch. Three pill
* buttons (Accept All / Public Only / Private Only) with a one-line
* caption beneath that explains the current selection. Public is
* always available; the All and Private buttons disable when SP isn't
* supported (extension / bunker logins).
* "What donations will you accept?" picker for the HD-wallet branch.
*
* Written for a first-time, possibly anxious creator: instead of three
* terse jargon pills (Accept All / Public Only / Private Only) it
* presents three full-width selectable cards, each with a friendly
* icon, a plain-language title, and a one-line reassurance. The two
* SP-dependent options are disabled (with a short note) when silent
* payments aren't supported on this login (extension / bunker).
*/
function AcceptModePicker({
value,
@@ -1453,58 +1460,102 @@ function AcceptModePicker({
}) {
const { t } = useTranslation();
const caption = {
all: t('campaignsCreate.acceptAllHint'),
public: t('campaignsCreate.acceptPublicHint'),
private: t('campaignsCreate.acceptPrivateHint'),
}[value];
const options: {
key: 'all' | 'public' | 'private';
icon: typeof Globe;
title: string;
description: string;
requiresSp?: boolean;
}[] = [
{
key: 'all',
icon: HandHeart,
title: t('campaignsCreate.acceptAllTitle'),
description: t('campaignsCreate.acceptAllHint'),
requiresSp: true,
},
{
key: 'public',
icon: Globe,
title: t('campaignsCreate.acceptPublicTitle'),
description: t('campaignsCreate.acceptPublicHint'),
},
{
key: 'private',
icon: EyeOff,
title: t('campaignsCreate.acceptPrivateTitle'),
description: t('campaignsCreate.acceptPrivateHint'),
requiresSp: true,
},
];
return (
<div className="space-y-2">
<ToggleGroup
type="single"
value={value}
// Radix ToggleGroup emits '' when the user toggles off the
// selected item. Required campaigns can never be in "no
// mode" state — coerce empty back to the previous value.
onValueChange={(next) => {
if (!next) return;
onChange(next as 'all' | 'public' | 'private');
}}
variant="outline"
className="grid w-full grid-cols-3 gap-1.5"
>
<ToggleGroupItem
value="all"
disabled={!silentPaymentSupported}
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
>
{t('campaignsCreate.acceptAllShort')}
</ToggleGroupItem>
<ToggleGroupItem
value="public"
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
>
{t('campaignsCreate.acceptPublicShort')}
</ToggleGroupItem>
<ToggleGroupItem
value="private"
disabled={!silentPaymentSupported}
className="h-auto justify-center rounded-full px-3 py-2 text-xs font-medium"
>
{t('campaignsCreate.acceptPrivateShort')}
</ToggleGroupItem>
</ToggleGroup>
<p className="text-xs text-muted-foreground">{caption}</p>
<div className="space-y-3">
<p className="text-sm font-medium">{t('campaignsCreate.acceptHeading')}</p>
<div className="space-y-2" role="radiogroup" aria-label={t('campaignsCreate.acceptHeading')}>
{options.map((option) => {
const Icon = option.icon;
const selected = value === option.key;
const disabled = option.requiresSp && !silentPaymentSupported;
return (
<button
key={option.key}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={() => onChange(option.key)}
className={cn(
'flex w-full items-start gap-3 rounded-xl border-2 p-4 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
selected
? 'border-primary bg-primary/10'
: 'border-border bg-background hover:border-primary/40 hover:bg-muted/40',
disabled && 'cursor-not-allowed opacity-50 hover:border-border hover:bg-background',
)}
>
<span
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground',
)}
>
<Icon className="size-5" />
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block text-sm font-semibold">{option.title}</span>
<span className="block text-xs leading-relaxed text-muted-foreground">
{disabled ? t('campaignsCreate.acceptUnavailable') : option.description}
</span>
</span>
<span
className={cn(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/30',
)}
aria-hidden="true"
>
{selected && <Check className="size-3" />}
</span>
</button>
);
})}
</div>
</div>
);
}
/**
* Single labeled custom-wallet input. The inline error fires only when
* a non-empty value either fails to parse OR parses to a mode that
* doesn't match {@link expectedMode} (e.g., an `sp1…` typed into the
* on-chain field).
* Single labeled custom-wallet input. Mirrors the accept-picker
* language so the field-driven custom flow keeps the same public /
* private framing: a {@link Bitcoin}/{@link EyeOff} icon next to the
* label and a one-line caption spell out what filling this field
* means, so the user never has to infer "address = public,
* code = private".
*
* The inline error fires only when a non-empty value either fails to
* parse OR parses to a mode that doesn't match {@link expectedMode}
* (e.g., an `sp1…` typed into the on-chain field).
*/
function CustomWalletInput({
id,
@@ -1530,11 +1581,19 @@ function CustomWalletInput({
expectedMode === 'onchain'
? t('campaignsCreate.onchainInvalid')
: t('campaignsCreate.spInvalid');
const MeaningIcon = expectedMode === 'onchain' ? Bitcoin : EyeOff;
const meaning =
expectedMode === 'onchain'
? t('campaignsCreate.customOnchainMeaning')
: t('campaignsCreate.customSpMeaning');
return (
<div className="space-y-1">
<label htmlFor={id} className="text-xs font-medium text-muted-foreground">
{label}
</label>
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<MeaningIcon className="size-3.5 shrink-0 text-muted-foreground" />
<label htmlFor={id} className="text-xs font-medium">
{label}
</label>
</div>
<div className="relative">
<Wallet className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -1549,7 +1608,11 @@ function CustomWalletInput({
aria-invalid={hasError}
/>
</div>
{hasError && <p className="text-xs text-destructive">{errorMessage}</p>}
{hasError ? (
<p className="text-xs text-destructive">{errorMessage}</p>
) : (
<p className="text-xs leading-relaxed text-muted-foreground">{meaning}</p>
)}
</div>
);
}
+1 -1
View File
@@ -368,7 +368,7 @@ export function ExternalContentPage() {
}
return (
<main className="">
<main className="w-full max-w-3xl mx-auto">
{/* Non-sticky transparent header skipped on country pages because
the country hero carries its own back arrow overlaid on the
photo, which lets the cinematic banner reach all the way to the
+1 -1
View File
@@ -472,7 +472,7 @@ export function PostDetailShell({
const navigate = useNavigate();
return (
<main className="">
<main className="w-full max-w-3xl mx-auto">
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
<button
onClick={() =>
+10
View File
@@ -44,6 +44,8 @@ export function WalletPage() {
availability,
currentReceiveAddress,
silentPaymentAddress,
scan,
silentPaymentStorage,
transactions,
totalBalance,
pendingBalance,
@@ -69,6 +71,13 @@ export function WalletPage() {
const address = currentReceiveAddress?.address ?? '';
const spAddress = silentPaymentAddress?.address ?? '';
// Whether the wallet holds any spendable inputs. Mirrors the Send dialog's
// `ownedInputs` set (BIP-86 UTXOs from the Blockbook scan + silent-payment
// UTXOs from local storage). When empty, sending is impossible, so the
// Send button is disabled just like the modal's "Send Bitcoin" button.
const hasSpendableBalance =
(scan?.utxos?.length ?? 0) > 0 || (silentPaymentStorage?.utxos?.length ?? 0) > 0;
// Combined BIP-21 payload: `bitcoin:<bc1>?sp=<sp1>` when both are
// available, falling back to the single endpoint that exists.
// Mirrors the campaign donate panel's QR payload so BIP-352-aware
@@ -278,6 +287,7 @@ export function WalletPage() {
<Button
size="lg"
onClick={() => setSendOpen(true)}
disabled={!hasSpendableBalance}
className="flex-1 rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
>
<ArrowUpRight className="mr-2" />