Compare commits

...

57 Commits

Author SHA1 Message Date
Chad Curtis 7a18d500ee release: v2.8.9 2026-06-02 09:15:30 -05:00
Chad Curtis 54c711b3be Point NIP-89 client tag to Agora handler naddr 2026-06-02 09:12:00 -05:00
Chad Curtis 79c6e7e516 Translate replies/comments header and count on post detail
The replies section heading ('Replies'/'Comments') and the count noun
('reply'/'replies'/'comment'/'comments') were hardcoded English literals
adjacent to the placeholder fixed in the previous commit. Move them to a
new postDetail locale section with i18next plural keys, translated across
all locales.
2026-06-02 09:03:20 -05:00
Chad Curtis 9717a6827f Fix untranslated reply composer placeholder on post detail
The placeholder used a hardcoded 'Write a reply...' literal and referenced
a nonexistent compose.activityPlaceholder key (which fell back to the raw
key string). Switch to translated replyModal.placeholder.writeReply /
writeComment keys and add writeReply to all locales.
2026-06-02 08:57:55 -05:00
Chad Curtis f6c7bc366d Add app download link to slide-out menu; tune nudge spacing
- Add a 'Get the app' external link to the TopNav drawer menu, hidden
  inside the native app.
- Move the home-page nudge out of the feed (it now lives on the actual
  home page, CampaignsPage) and give it pt-8 breathing room above.
- Remove redundant pb-16 on main and shrink the footer's top gap on
  mobile (pt-6 sm:pt-12) so spacing above the footer isn't doubled.
2026-06-02 08:54:43 -05:00
Chad Curtis 4c7d059b0b Add Start a campaign button to middle home CTA
Place a 'Start a campaign' button next to the centered 'Browse all'
CTA in the middle of the campaigns home page, stacking on mobile.
2026-06-02 08:51:38 -05:00
Chad Curtis eae5e1c3a7 Always show Explore campaigns button on home page
Remove the !loggedIn gate that hid the 'Explore campaigns' CTA from
logged-in users. The campaigns home page shouldn't gate this button
behind login state.
2026-06-02 08:46:45 -05:00
Chad Curtis e7c488af63 Add Zapstore app download nudge
Prompt mobile-web visitors to install the native Android app from
Zapstore. Shows a card at the bottom of the home feed and a link in
the account switcher menu. Both are hidden inside the native app
(Capacitor.isNativePlatform) and on desktop (sm:hidden for the banner).

Adds nav.getApp and feed.getApp.* strings across all locales.
2026-06-02 08:40:58 -05:00
lemon 4153792e54 Add missing Why Agora translations 2026-06-02 04:29:26 -07:00
lemon c731256efb Preserve Latin display font for balances 2026-06-02 04:18:04 -07:00
lemon 702d374a06 Sync lockfile version 2026-06-02 04:10:04 -07:00
lemon b174152566 Fix stale language switching 2026-06-02 04:09:44 -07:00
Chad Curtis 6a5c426648 release: v2.8.8 2026-06-02 04:57:32 -05:00
Chad Curtis f8547668b2 Replace old Ditto splash icon with Agora double-bolt
The Android 12 splash vector (splash_icon_vector.xml) and the legacy
splash PNGs still showed the old Ditto cat / single-bolt logo. Replace
the splash vector with the current double-bolt glyph from logo.svg and
regenerate splash_icon.png and all port/land splash PNGs as the white
double-bolt on the dark splash background.
2026-06-02 04:56:53 -05:00
Chad Curtis 49049f98e7 Fix squashed app icon: render logo SVG with preserved aspect ratio
The 720x880 logo.svg was force-rendered into a 512x512 square, stretching
the bolt horizontally. Render height-bounded (aspect-preserving) instead
and let the centered composite handle fit. Regenerate all Android, iOS,
and web/PWA/Zapstore icons.
2026-06-02 04:53:57 -05:00
Chad Curtis 048878b699 fix zapstore info v3 2026-06-02 04:52:25 -05:00
Chad Curtis 476a3856ec release: v2.8.7 2026-06-02 04:34:48 -05:00
Chad Curtis d69cfa0862 fix zapstore info v2 2026-06-02 04:32:59 -05:00
Chad Curtis a5cc9c5163 Break walletHeroNote onto two lines 2026-06-02 04:24:57 -05:00
Chad Curtis 42ac269a56 release: v2.8.6 2026-06-02 04:24:53 -05:00
Chad Curtis caa8e70703 fix zapstore info 2026-06-02 04:19:11 -05:00
Chad Curtis 8f53e3e53b Regenerate app icons as white double-bolt on orange circle
The Android launcher icons and adaptive foreground were the old icon
(orange-circle single bolt / stale Ditto vector), and generate-icons.sh
still sourced the purple Ditto branding. Rewrite the generator to use
the current logo.svg double-bolt glyph in white on the brand orange
(#e9673f) circle, and regenerate:
- Android legacy + adaptive launcher icons (all densities)
- adaptive icon background color -> #e9673f
- iOS AppIcon
- web/PWA/Zapstore icons (logo.png, icon-192, icon-512, apple-touch-icon)

Also remove the unused stale adaptive foreground vector so it can't
shadow the regenerated foreground PNG.
2026-06-02 04:17:23 -05:00
Chad Curtis 5d4d0825c6 Remove "no payout setup" from wallet hero note copy 2026-06-02 04:10:33 -05:00
Chad Curtis b9b7351361 release: v2.8.5 2026-06-02 03:40:31 -05:00
Chad Curtis 6dcae6385a ci: use uniform PKCS12 password for signing keystore
packageRelease failed with 'Given final block not properly padded'
because the migrated PKCS12 entry was protected with the store password,
not the key password Gradle read from key.properties. Write the PKCS12
with a single uniform password ($KEY_PASSWORD) for store and entry, and
point both storePassword and keyPassword at it.
2026-06-02 03:39:54 -05:00
Chad Curtis 13386bf0fd release: v2.8.4 2026-06-02 03:29:34 -05:00
Chad Curtis 2ae2a3da18 android: add R8 keep rules for barcode-scanner Gson references
R8 release minification failed on a missing com.google.gson.annotations
.SerializedName referenced by the OutSystems barcode plugin. Suppress the
Gson missing-class warning, keep annotations, and keep the plugin's model
classes so serialized fields survive shrinking.
2026-06-02 03:28:48 -05:00
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
98 changed files with 2047 additions and 988 deletions
+14 -4
View File
@@ -167,23 +167,33 @@ build-apk:
# Write local.properties for Gradle
- echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility.
# PKCS12 conceptually uses one password for the store and every entry; if the
# store and key passwords differ, keytool protects the migrated entry with the
# STORE password regardless of -destkeypass, so Gradle's later read with the
# key password fails ("Given final block not properly padded"). Unlock the
# source key with its own password ($KEY_PASSWORD), then write the PKCS12 with
# a single uniform password ($KEY_PASSWORD) for both store and entry so the
# key.properties below is internally consistent.
- echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/my-upload-key.jks
- keytool -importkeystore
-srckeystore android/app/my-upload-key.jks
-destkeystore android/app/my-upload-key.keystore
-deststoretype pkcs12
-srcstorepass "$KEYSTORE_PASSWORD"
-deststorepass "$KEYSTORE_PASSWORD"
-srcalias upload
-destalias upload
-srckeypass "$KEY_PASSWORD"
-deststorepass "$KEY_PASSWORD"
-destkeypass "$KEY_PASSWORD"
-noprompt
- rm android/app/my-upload-key.jks
# Write key.properties from CI/CD variables
# Write key.properties from CI/CD variables. The PKCS12 above uses
# $KEY_PASSWORD uniformly, so both storePassword and keyPassword point to it.
- |
cat > android/key.properties << EOF
storePassword=$KEYSTORE_PASSWORD
storePassword=$KEY_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=upload
storeFile=my-upload-key.keystore
+85
View File
@@ -1,5 +1,90 @@
# Changelog
## [2.8.9] - 2026-06-02
Adds an in-app prompt to grab the Android app from Zapstore, makes it easier to start or explore campaigns right from the home page, and irons out a batch of language and display fixes.
### Added
- A prompt to download the Android app from Zapstore, shown to mobile web visitors on the home page, in the account menu, and in the slide-out menu.
- A "Start a campaign" button alongside "Browse all" in the middle of the home page.
### Changed
- The "Explore campaigns" button now appears for everyone, not just logged-out visitors.
### Fixed
- Switching languages now takes effect immediately instead of showing stale text.
- The reply box and the replies heading on a post now show up in your chosen language.
- Account balances keep their Latin numerals regardless of display language.
- Filled in missing translations on the "Why Agora" screen.
## [2.8.8] - 2026-06-02
Fixes the app icon proportions and updates the loading splash to the Agora bolt.
### Fixed
- App icon no longer appears squashed.
- Loading splash now shows the Agora bolt instead of the old logo.
## [2.8.7] - 2026-06-02
Fixes the top navigation bar rendering behind the status bar on Android.
### Fixed
- Top navigation bar now clears the system status bar on Android.
## [2.8.6] - 2026-06-02
Refreshes the app icon to the orange Agora bolt mark across Android, iOS, and the web.
### Changed
- Update the app icon to the current Agora bolt on a brand-orange background.
## [2.8.5] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.4] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [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`. |
+2 -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.9"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -51,6 +51,7 @@ repositories {
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.core:core:$androidxCoreVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
+8
View File
@@ -19,6 +19,14 @@
-dontwarn okio.**
-keep class okhttp3.** { *; }
# Barcode scanner plugin (@capacitor/barcode-scanner -> OutSystems ionbarcode)
# references Gson's @SerializedName, but Gson isn't on the release classpath.
# Suppress the missing-class warning, keep the annotation attribute, and keep
# the plugin's model classes so R8 doesn't strip/rename serialized fields.
-dontwarn com.google.gson.**
-keepattributes *Annotation*
-keep class com.outsystems.plugins.barcode.** { *; }
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
@@ -8,6 +8,12 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.webkit.WebView;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.getcapacitor.BridgeActivity;
@@ -47,6 +53,35 @@ public class MainActivity extends BridgeActivity {
// Handle notification tap deep link
handleNotificationIntent(getIntent());
// The Android WebView reports env(safe-area-inset-*) as 0, so inject the
// real system-bar insets as CSS variables (--safe-area-inset-top/bottom)
// that the web layer consumes (see src/index.css). Without this, the top
// nav renders behind the status bar in the APK.
applySafeAreaInsets();
}
/**
* Read the status-bar (top) and navigation-bar (bottom) insets and write
* them into the WebView as CSS pixel variables. Re-applies on every inset
* change (rotation, status-bar show/hide, etc.).
*/
private void applySafeAreaInsets() {
final WebView webView = getBridge().getWebView();
if (webView == null) return;
ViewCompat.setOnApplyWindowInsetsListener(webView, (v, insets) -> {
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
float density = getResources().getDisplayMetrics().density;
int topPx = Math.round(bars.top / density);
int bottomPx = Math.round(bars.bottom / density);
String js =
"document.documentElement.style.setProperty('--safe-area-inset-top','" + topPx + "px');" +
"document.documentElement.style.setProperty('--safe-area-inset-bottom','" + bottomPx + "px');";
v.post(() -> webView.evaluateJavascript(js, null));
return insets;
});
ViewCompat.requestApplyInsets(webView);
}
@Override
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 115 KiB

@@ -1,50 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="100"
android:viewportHeight="100">
<!--
Ditto logo from public/logo.svg.
SVG viewBox is "-5 -10 100 100", so we shift all paths by (+5, +10)
to place the origin at (0,0) for the 100x100 viewport.
Then scale to 60% around the content center (50, 40) to fit within
Android's adaptive icon safe zone (66% of 108dp).
-->
<group
android:translateX="5"
android:translateY="10"
android:scaleX="0.7"
android:scaleY="0.7"
android:pivotX="50"
android:pivotY="40">
<!-- path1: bottom arc / bottom-right swash -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 71.719615,49.36907 -0.62891,0.37109 c -0.12891,0.07031 -0.26172,0.14844 -0.39062,0.21875 -3.9883,10.309 -14.008,17.617 -25.699,17.617 -4.1211,0 -8.0312,-0.89844 -11.539,-2.5391 -0.12891,0.03906 -0.26172,0.07031 -0.39063,0.10156 l -0.35156,0.08984 h -0.02734 l -0.25,0.05859 -0.07813,0.01953 -0.10938,0.03125 c -0.55859,0.12891 -1.1289,0.26172 -1.6992,0.39062 -0.10156,0.03125 -0.19922,0.05078 -0.30078,0.07031 l -0.30078,0.10156 -0.18359,0.0078 c -0.26953,0.05859 -1.3086,0.26953 -1.3086,0.26953 -0.28906,0.05859 -0.55859,0.10937 -0.82813,0.17187 4.9805,3.3086 10.961,5.2305 17.371,5.2305 15.059,0 27.699,-10.602 30.828,-24.738 -0.75,0.48828 -1.5195,0.96875 -2.2891,1.4414 -0.59375,0.36328 -1.2031,0.72656 -1.8242,1.0859 z" />
<!-- path2: small left accent dot/arc -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 30.926615,29.47807 c 0.36328,-0.48828 0.75,-0.95312 1.1523,-1.3828 0.75781,-0.80469 0.71484,-2.0703 -0.08984,-2.8281 -0.80469,-0.75781 -2.0703,-0.71484 -2.8281,0.08984 -0.50781,0.53906 -0.99219,1.125 -1.4492,1.7383 -0.65625,0.88672 -0.47266,2.1406 0.41406,2.7969 0.35938,0.26562 0.77344,0.39453 1.1875,0.39453 0.61719,0 1.2227,-0.27734 1.6133,-0.80859 z" />
<!-- path3: left vertical bar -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 26.742615,32.67807 c -1.0586,-0.3125 -2.1719,0.29687 -2.4805,1.3594 -0.55859,1.9062 -0.83984,3.9141 -0.83984,5.9609 0,2.3789 0.39062,4.7227 1.1602,6.9609 0.28516,0.82812 1.0625,1.3516 1.8906,1.3516 0.21484,0 0.43359,-0.03516 0.64844,-0.10938 1.043,-0.35938 1.6016,-1.4961 1.2422,-2.543 -0.625,-1.8203 -0.94141,-3.7227 -0.94141,-5.6602 0,-1.668 0.22656,-3.2969 0.67969,-4.8398 0.30859,-1.0586 -0.30078,-2.168 -1.3594,-2.4805 z" />
<!-- path4: main ring arc -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 14.691615,48.83807 c 0.10156,0.33984 0.19922,0.67969 0.32812,1.0195 0.42969,1.3516 0.94922,2.6484 1.5781,3.9102 0.10156,-0.01172 0.21094,-0.01172 0.32031,-0.01953 l 0.16016,-0.01172 0.80078,-0.07031 c 0.37109,-0.03906 0.67188,-0.07031 0.98047,-0.10156 0.51172,-0.05859 1.0195,-0.12109 1.5586,-0.19922 l 0.21875,-0.03125 c 0.07031,-0.01172 0.14062,-0.01953 0.21094,-0.03125 -1.2188,-2.2109 -2.1484,-4.6016 -2.7305,-7.1211 -0.16016,-0.71094 -0.30078,-1.4297 -0.39844,-2.1602 -0.19922,-1.3086 -0.30078,-2.6602 -0.30078,-4.0312 0,-0.89844 0.03906,-1.7812 0.12891,-2.6484 0.07031,-0.71094 0.16016,-1.4102 0.28906,-2.1016 2.25,-12.949 13.57,-22.828 27.16,-22.828 6.0508,0 11.648,1.9609 16.211,5.3008 0.57031,0.41016 1.1289,0.85938 1.6719,1.3203 1.6914,1.4219 3.2109,3.0703 4.5,4.8789 0.42969,0.60156 0.83984,1.2109 1.2188,1.8398 1.3203,2.1602 2.3398,4.5117 3.0195,7 0.23828,-0.17188 0.42969,-0.30859 0.62891,-0.46875 0.64844,-0.48047 1.2109,-0.92188 1.7383,-1.3398 0.28125,-0.23047 0.5,-0.41016 0.71094,-0.57812 0.10156,-0.07813 0.19141,-0.16016 0.28125,-0.23828 -0.42969,-1.3516 -0.96094,-2.6484 -1.5898,-3.8984 -0.14062,-0.32812 -0.30859,-0.64844 -0.48047,-0.96875 -0.32812,-0.64844 -0.69922,-1.2891 -1.0898,-1.9102 -1.6797,-2.7109 -3.7695,-5.1406 -6.1719,-7.2188 -0.57031,-0.48828 -1.1484,-0.96875 -1.7617,-1.4102 -0.55859,-0.42188 -1.1406,-0.82812 -1.7305,-1.2109 -4.9414,-3.2188 -10.852,-5.0898 -17.16,-5.0898 -14.961,0 -27.531,10.469 -30.75,24.469 -0.17188,0.67969 -0.30859,1.3711 -0.42188,2.0703 -0.12891,0.73828 -0.21875,1.5 -0.28906,2.2617 -0.07813,0.91016 -0.12109,1.8398 -0.12109,2.7812 0,2.3008 0.25,4.5508 0.71875,6.7109 0.17188,0.71484 0.35156,1.4258 0.5625,2.125 z" />
<!-- path5: top-right swash / outer arc with tail -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 90.441615,21.60007 c -2.1797,-5.3398 -9.4102,-7.3984 -21,-6.0391 1.8906,1.8906 3.5391,3.9688 4.9297,6.2109 0.28906,0.46094 0.55859,0.92187 0.80859,1.3789 5.5391,-0.12109 7.6094,1.0391 7.8398,1.4492 0.12891,0.46875 -0.55078,2.7305 -4.5898,6.4805 -0.01953,0.01953 -0.03125,0.03125 -0.03906,0.03906 -0.26172,0.23828 -0.51953,0.48047 -0.80078,0.71875 -0.19922,0.17969 -0.41016,0.35938 -0.62891,0.53906 -0.10938,0.10156 -0.21875,0.19141 -0.33984,0.28906 -0.23828,0.19922 -0.5,0.41016 -0.76172,0.62109 -0.12891,0.10156 -0.26172,0.21094 -0.39844,0.32031 -0.42969,0.33984 -0.89063,0.69141 -1.3711,1.0508 -0.26953,0.21094 -0.53906,0.41016 -0.82812,0.60938 -0.32031,0.23047 -0.64062,0.46875 -0.98047,0.69922 0,0.01172 -0.01172,0.01172 -0.01172,0.01172 -0.26953,0.19141 -0.55078,0.37891 -0.82812,0.57031 -0.28125,0.19141 -0.55859,0.37109 -0.85156,0.55859 -0.25,0.16016 -0.5,0.32812 -0.76172,0.48828 -6,3.8984 -13.48,7.7188 -21.379,10.922 -8.0117,3.2383 -15.871,5.6602 -22.93,7.0391 -0.30078,0.05859 -0.60156,0.12109 -0.89062,0.17188 -0.60938,0.12109 -1.2188,0.21875 -1.8203,0.32031 -0.07031,0.01172 -0.12891,0.01953 -0.19922,0.03125 h -0.01953 c -0.28906,0.05078 -0.57031,0.08984 -0.83984,0.12891 -0.30859,0.05078 -0.60938,0.08984 -0.91016,0.12891 -0.57031,0.07813 -1.1094,0.14844 -1.6406,0.21094 -0.35156,0.03906 -0.69141,0.07031 -1.0195,0.10156 -0.30078,0.03125 -0.58984,0.05078 -0.87891,0.07813 -0.48047,0.03125 -0.92969,0.05859 -1.3711,0.07813 -0.39844,0.01953 -0.78125,0.03125 -1.1484,0.03906 -5.5116996,0.10938 -7.5702996,-1.0391 -7.8007996,-1.4492 -0.12891,-0.48047 0.55078,-2.7383 4.6093996,-6.5 -0.12891,-0.48828 -0.26172,-1 -0.37891,-1.5391 -0.51953,-2.4219 -0.78906,-4.8906 -0.78906,-7.3594 0,-0.17969 0,-0.37109 0.01172,-0.55078 -9.2733996,7.082 -13.0229996,13.59 -10.8749996,18.949 1.7383,4.2695 6.7188,6.4492 14.5899996,6.4492 2.8594,0 6.1016,-0.28906 9.7109,-0.87109 0.17188,-0.03125 0.33984,-0.05859 0.51953,-0.08984 0.17188,-0.03125 0.35156,-0.05859 0.51953,-0.08984 l 1.2188,-0.21875 c 0.57031,-0.10156 1.1484,-0.21875 1.7305,-0.33984 0.53125,-0.10938 1.0508,-0.21094 1.5781,-0.32812 0.01172,0 0.03125,-0.01172 0.03906,-0.01172 0.05078,-0.01172 0.08984,-0.01953 0.14062,-0.03125 0.07031,-0.01172 0.12891,-0.03125 0.19922,-0.05078 0.57812,-0.12891 1.1602,-0.26172 1.7383,-0.39844 0.05078,-0.01172 0.10156,-0.03125 0.14844,-0.03906 0.21094,-0.05078 0.42188,-0.10156 0.64062,-0.14844 h 0.01172 c 6.0898,-1.5117 12.559,-3.6406 19.102,-6.2891 6.5508,-2.6602 12.68,-5.6289 18.109,-8.7812 0.21875,-0.12891 0.44141,-0.26172 0.66016,-0.39062 0.58984,-0.33984 1.1797,-0.69141 1.7617,-1.0508 1.5703,-0.96094 3.0586,-1.9297 4.4805,-2.9102 0.12891,-0.08984 0.26172,-0.17969 0.39062,-0.26953 11.242,-7.8477 15.941,-15.09 13.594,-20.938 z" />
</group>
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -5,43 +5,26 @@
android:viewportHeight="1200">
<!--
Android 12 splash screen masks the icon to a circle at 2/3 of canvas (160dp of 240dp).
The Ditto logo SVG has viewBox="-5 -10 100 100".
We scale the 100x100 logo to fit ~800x800 in the center of 1200x1200,
leaving ~200px padding on each side for the circular safe zone.
Scale factor: 800/100 = 8. Translate: (200 + 5*8, 200 + 10*8) = (240, 280) to shift origin.
Agora double-bolt logo from public/logo.svg (viewBox "0 0 720 880").
Android 12 splash masks the icon to a circle at 2/3 of the canvas
(~800dp of 1200dp). The logo is 720x880 (portrait); scale 880 -> 800
(factor 800/880 = 0.9091) giving a scaled width of ~654, then center:
translateX = (1200 - 720*0.9091) / 2 ≈ 273
translateY = (1200 - 880*0.9091) / 2 = 200
-->
<group
android:translateX="240"
android:translateY="280"
android:scaleX="8"
android:scaleY="8">
android:translateX="273"
android:translateY="200"
android:scaleX="0.9091"
android:scaleY="0.9091">
<!-- path1: bottom arc / bottom-right swash -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 71.719615,49.36907 -0.62891,0.37109 c -0.12891,0.07031 -0.26172,0.14844 -0.39062,0.21875 -3.9883,10.309 -14.008,17.617 -25.699,17.617 -4.1211,0 -8.0312,-0.89844 -11.539,-2.5391 -0.12891,0.03906 -0.26172,0.07031 -0.39063,0.10156 l -0.35156,0.08984 h -0.02734 l -0.25,0.05859 -0.07813,0.01953 -0.10938,0.03125 c -0.55859,0.12891 -1.1289,0.26172 -1.6992,0.39062 -0.10156,0.03125 -0.19922,0.05078 -0.30078,0.07031 l -0.30078,0.10156 -0.18359,0.0078 c -0.26953,0.05859 -1.3086,0.26953 -1.3086,0.26953 -0.28906,0.05859 -0.55859,0.10937 -0.82813,0.17187 4.9805,3.3086 10.961,5.2305 17.371,5.2305 15.059,0 27.699,-10.602 30.828,-24.738 -0.75,0.48828 -1.5195,0.96875 -2.2891,1.4414 -0.59375,0.36328 -1.2031,0.72656 -1.8242,1.0859 z" />
<!-- path2: small left accent dot/arc -->
android:pathData="M415.596 0L0 417.12L284.123 702.287L346.922 468.3H189.533L415.596 0Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="m 30.926615,29.47807 c 0.36328,-0.48828 0.75,-0.95312 1.1523,-1.3828 0.75781,-0.80469 0.71484,-2.0703 -0.08984,-2.8281 -0.80469,-0.75781 -2.0703,-0.71484 -2.8281,0.08984 -0.50781,0.53906 -0.99219,1.125 -1.4492,1.7383 -0.65625,0.88672 -0.47266,2.1406 0.41406,2.7969 0.35938,0.26562 0.77344,0.39453 1.1875,0.39453 0.61719,0 1.2227,-0.27734 1.6133,-0.80859 z" />
<!-- path3: left vertical bar -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 26.742615,32.67807 c -1.0586,-0.3125 -2.1719,0.29687 -2.4805,1.3594 -0.55859,1.9062 -0.83984,3.9141 -0.83984,5.9609 0,2.3789 0.39062,4.7227 1.1602,6.9609 0.28516,0.82812 1.0625,1.3516 1.8906,1.3516 0.21484,0 0.43359,-0.03516 0.64844,-0.10938 1.043,-0.35938 1.6016,-1.4961 1.2422,-2.543 -0.625,-1.8203 -0.94141,-3.7227 -0.94141,-5.6602 0,-1.668 0.22656,-3.2969 0.67969,-4.8398 0.30859,-1.0586 -0.30078,-2.168 -1.3594,-2.4805 z" />
<!-- path4: main ring arc -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 14.691615,48.83807 c 0.10156,0.33984 0.19922,0.67969 0.32812,1.0195 0.42969,1.3516 0.94922,2.6484 1.5781,3.9102 0.10156,-0.01172 0.21094,-0.01172 0.32031,-0.01953 l 0.16016,-0.01172 0.80078,-0.07031 c 0.37109,-0.03906 0.67188,-0.07031 0.98047,-0.10156 0.51172,-0.05859 1.0195,-0.12109 1.5586,-0.19922 l 0.21875,-0.03125 c 0.07031,-0.01172 0.14062,-0.01953 0.21094,-0.03125 -1.2188,-2.2109 -2.1484,-4.6016 -2.7305,-7.1211 -0.16016,-0.71094 -0.30078,-1.4297 -0.39844,-2.1602 -0.19922,-1.3086 -0.30078,-2.6602 -0.30078,-4.0312 0,-0.89844 0.03906,-1.7812 0.12891,-2.6484 0.07031,-0.71094 0.16016,-1.4102 0.28906,-2.1016 2.25,-12.949 13.57,-22.828 27.16,-22.828 6.0508,0 11.648,1.9609 16.211,5.3008 0.57031,0.41016 1.1289,0.85938 1.6719,1.3203 1.6914,1.4219 3.2109,3.0703 4.5,4.8789 0.42969,0.60156 0.83984,1.2109 1.2188,1.8398 1.3203,2.1602 2.3398,4.5117 3.0195,7 0.23828,-0.17188 0.42969,-0.30859 0.62891,-0.46875 0.64844,-0.48047 1.2109,-0.92188 1.7383,-1.3398 0.28125,-0.23047 0.5,-0.41016 0.71094,-0.57812 0.10156,-0.07813 0.19141,-0.16016 0.28125,-0.23828 -0.42969,-1.3516 -0.96094,-2.6484 -1.5898,-3.8984 -0.14062,-0.32812 -0.30859,-0.64844 -0.48047,-0.96875 -0.32812,-0.64844 -0.69922,-1.2891 -1.0898,-1.9102 -1.6797,-2.7109 -3.7695,-5.1406 -6.1719,-7.2188 -0.57031,-0.48828 -1.1484,-0.96875 -1.7617,-1.4102 -0.55859,-0.42188 -1.1406,-0.82812 -1.7305,-1.2109 -4.9414,-3.2188 -10.852,-5.0898 -17.16,-5.0898 -14.961,0 -27.531,10.469 -30.75,24.469 -0.17188,0.67969 -0.30859,1.3711 -0.42188,2.0703 -0.12891,0.73828 -0.21875,1.5 -0.28906,2.2617 -0.07813,0.91016 -0.12109,1.8398 -0.12109,2.7812 0,2.3008 0.25,4.5508 0.71875,6.7109 0.17188,0.71484 0.35156,1.4258 0.5625,2.125 z" />
<!-- path5: top-right swash / outer arc with tail -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 90.441615,21.60007 c -2.1797,-5.3398 -9.4102,-7.3984 -21,-6.0391 1.8906,1.8906 3.5391,3.9688 4.9297,6.2109 0.28906,0.46094 0.55859,0.92187 0.80859,1.3789 5.5391,-0.12109 7.6094,1.0391 7.8398,1.4492 0.12891,0.46875 -0.55078,2.7305 -4.5898,6.4805 -0.01953,0.01953 -0.03125,0.03125 -0.03906,0.03906 -0.26172,0.23828 -0.51953,0.48047 -0.80078,0.71875 -0.19922,0.17969 -0.41016,0.35938 -0.62891,0.53906 -0.10938,0.10156 -0.21875,0.19141 -0.33984,0.28906 -0.23828,0.19922 -0.5,0.41016 -0.76172,0.62109 -0.12891,0.10156 -0.26172,0.21094 -0.39844,0.32031 -0.42969,0.33984 -0.89063,0.69141 -1.3711,1.0508 -0.26953,0.21094 -0.53906,0.41016 -0.82812,0.60938 -0.32031,0.23047 -0.64062,0.46875 -0.98047,0.69922 0,0.01172 -0.01172,0.01172 -0.01172,0.01172 -0.26953,0.19141 -0.55078,0.37891 -0.82812,0.57031 -0.28125,0.19141 -0.55859,0.37109 -0.85156,0.55859 -0.25,0.16016 -0.5,0.32812 -0.76172,0.48828 -6,3.8984 -13.48,7.7188 -21.379,10.922 -8.0117,3.2383 -15.871,5.6602 -22.93,7.0391 -0.30078,0.05859 -0.60156,0.12109 -0.89062,0.17188 -0.60938,0.12109 -1.2188,0.21875 -1.8203,0.32031 -0.07031,0.01172 -0.12891,0.01953 -0.19922,0.03125 h -0.01953 c -0.28906,0.05078 -0.57031,0.08984 -0.83984,0.12891 -0.30859,0.05078 -0.60938,0.08984 -0.91016,0.12891 -0.57031,0.07813 -1.1094,0.14844 -1.6406,0.21094 -0.35156,0.03906 -0.69141,0.07031 -1.0195,0.10156 -0.30078,0.03125 -0.58984,0.05078 -0.87891,0.07813 -0.48047,0.03125 -0.92969,0.05859 -1.3711,0.07813 -0.39844,0.01953 -0.78125,0.03125 -1.1484,0.03906 -5.5116996,0.10938 -7.5702996,-1.0391 -7.8007996,-1.4492 -0.12891,-0.48047 0.55078,-2.7383 4.6093996,-6.5 -0.12891,-0.48828 -0.26172,-1 -0.37891,-1.5391 -0.51953,-2.4219 -0.78906,-4.8906 -0.78906,-7.3594 0,-0.17969 0,-0.37109 0.01172,-0.55078 -9.2733996,7.082 -13.0229996,13.59 -10.8749996,18.949 1.7383,4.2695 6.7188,6.4492 14.5899996,6.4492 2.8594,0 6.1016,-0.28906 9.7109,-0.87109 0.17188,-0.03125 0.33984,-0.05859 0.51953,-0.08984 0.17188,-0.03125 0.35156,-0.05859 0.51953,-0.08984 l 1.2188,-0.21875 c 0.57031,-0.10156 1.1484,-0.21875 1.7305,-0.33984 0.53125,-0.10938 1.0508,-0.21094 1.5781,-0.32812 0.01172,0 0.03125,-0.01172 0.03906,-0.01172 0.05078,-0.01172 0.08984,-0.01953 0.14062,-0.03125 0.07031,-0.01172 0.12891,-0.03125 0.19922,-0.05078 0.57812,-0.12891 1.1602,-0.26172 1.7383,-0.39844 0.05078,-0.01172 0.10156,-0.03125 0.14844,-0.03906 0.21094,-0.05078 0.42188,-0.10156 0.64062,-0.14844 h 0.01172 c 6.0898,-1.5117 12.559,-3.6406 19.102,-6.2891 6.5508,-2.6602 12.68,-5.6289 18.109,-8.7812 0.21875,-0.12891 0.44141,-0.26172 0.66016,-0.39062 0.58984,-0.33984 1.1797,-0.69141 1.7617,-1.0508 1.5703,-0.96094 3.0586,-1.9297 4.4805,-2.9102 0.12891,-0.08984 0.26172,-0.17969 0.39062,-0.26953 11.242,-7.8477 15.941,-15.09 13.594,-20.938 z" />
android:pathData="M431.879 127.936L363.762 381.328H527.876L258.808 880L720 417.114L431.879 127.936Z" />
</group>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#ff6600</color>
<color name="ic_launcher_background">#e9673f</color>
</resources>
+1 -1
View File
@@ -1,5 +1,5 @@
ext {
minSdkVersion = 24
minSdkVersion = 26
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
-7
View File
@@ -18,13 +18,6 @@ const config: CapacitorConfig = {
contentInset: 'never',
scheme: 'Agora'
},
plugins: {
SystemBars: {
// Inject --safe-area-inset-* CSS variables on Android to work around
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
insetsHandling: 'css',
},
},
};
export default config;
+2 -2
View File
@@ -323,7 +323,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.14.4;
MARKETING_VERSION = 2.8.9;
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.9;
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 75 KiB

+26 -26
View File
@@ -1,12 +1,12 @@
{
"name": "agora",
"version": "2.8.0",
"version": "2.8.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agora",
"version": "2.8.0",
"version": "2.8.9",
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.10.0",
"@capacitor/app": "^8.0.0",
@@ -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.9",
"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",
+85
View File
@@ -1,5 +1,90 @@
# Changelog
## [2.8.9] - 2026-06-02
Adds an in-app prompt to grab the Android app from Zapstore, makes it easier to start or explore campaigns right from the home page, and irons out a batch of language and display fixes.
### Added
- A prompt to download the Android app from Zapstore, shown to mobile web visitors on the home page, in the account menu, and in the slide-out menu.
- A "Start a campaign" button alongside "Browse all" in the middle of the home page.
### Changed
- The "Explore campaigns" button now appears for everyone, not just logged-out visitors.
### Fixed
- Switching languages now takes effect immediately instead of showing stale text.
- The reply box and the replies heading on a post now show up in your chosen language.
- Account balances keep their Latin numerals regardless of display language.
- Filled in missing translations on the "Why Agora" screen.
## [2.8.8] - 2026-06-02
Fixes the app icon proportions and updates the loading splash to the Agora bolt.
### Fixed
- App icon no longer appears squashed.
- Loading splash now shows the Agora bolt instead of the old logo.
## [2.8.7] - 2026-06-02
Fixes the top navigation bar rendering behind the status bar on Android.
### Fixed
- Top navigation bar now clears the system status bar on Android.
## [2.8.6] - 2026-06-02
Refreshes the app icon to the orange Agora bolt mark across Android, iOS, and the web.
### Changed
- Update the app icon to the current Agora bolt on a brand-orange background.
## [2.8.5] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.4] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 36 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+26 -15
View File
@@ -42,21 +42,28 @@ if [ ! -f "$SOURCE_SVG" ]; then
fi
# Brand colors
BG_COLOR="#7c52e0" # Ditto purple
BG_COLOR="#e9673f" # Agora orange (hsl(14 79% 58%))
TMPDIR=$(mktemp -d)
LOGO_WHITE_SVG="$TMPDIR/logo_white.svg"
LOGO_WHITE="$TMPDIR/logo_white.png"
# Recolor the SVG fill to white before rasterizing.
sed 's/#7c52e0/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
# Recolor the SVG fill to white before rasterizing. logo.svg declares the
# glyph with fill="black", so recolor both the attribute form and any hex.
sed -e 's/fill="black"/fill="#ffffff"/g' \
-e 's/#000000/#ffffff/g' \
-e 's/#7c52e0/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
echo "Rendering white SVG at 512x512..."
echo "Rendering white SVG (preserving aspect ratio)..."
# Render at 1024px tall and let the renderer derive the width from the SVG
# viewBox, so the non-square logo (720x880) is NOT stretched into a square.
# The composite steps below use -resize WxH which fits-inside (aspect-
# preserving), keeping the glyph's true proportions.
if [ "$SVG_RENDERER" = "inkscape" ]; then
inkscape --export-type=png --export-filename="$LOGO_WHITE" -w 512 -h 512 "$LOGO_WHITE_SVG" 2>/dev/null
inkscape --export-type=png --export-filename="$LOGO_WHITE" -h 1024 "$LOGO_WHITE_SVG" 2>/dev/null
else
rsvg-convert -w 512 -h 512 "$LOGO_WHITE_SVG" -o "$LOGO_WHITE"
rsvg-convert -h 1024 "$LOGO_WHITE_SVG" -o "$LOGO_WHITE"
fi
# ── Adaptive icon foreground PNGs (transparent bg, white logo, safe-zone padding) ──
@@ -82,23 +89,27 @@ make_foreground 192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foregrou
# ── Legacy launcher icons (ic_launcher.png and ic_launcher_round.png) ──
# These are used on pre-API-26 devices and as fallback on some launchers.
# They must have the logo composited onto the purple background — NOT just
# a solid color fill.
# Both are the white logo composited onto an orange circle (brand mark).
echo "Generating legacy launcher icons (ic_launcher.png and ic_launcher_round.png)..."
# make_legacy_square: logo on flat purple square background
# make_legacy_square: white logo on an orange circle (transparent corners)
make_legacy_square() {
local size=$1
local content_size=$(echo "$size * 60 / 100" | bc)
local dest=$2
local mask="$TMPDIR/circle_mask_sq_${size}.png"
$MAGICK -size "${size}x${size}" "xc:none" \
-fill white -draw "circle $((size/2)),$((size/2)) $((size/2)),0" \
"$mask"
$MAGICK -size "${size}x${size}" "xc:${BG_COLOR}" \
"$mask" -compose dst-in -composite \
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
-gravity center -compose over -composite \
"$dest"
}
# make_legacy_round: logo on circular purple background (alpha-masked circle)
# make_legacy_round: white logo on circular orange background (alpha-masked circle)
make_legacy_round() {
local size=$1
local content_size=$(echo "$size * 60 / 100" | bc)
@@ -108,7 +119,7 @@ make_legacy_round() {
$MAGICK -size "${size}x${size}" "xc:none" \
-fill white -draw "circle $((size/2)),$((size/2)) $((size/2)),0" \
"$mask"
# Fill purple, apply circle mask, composite logo
# Fill orange, apply circle mask, composite logo
$MAGICK -size "${size}x${size}" "xc:${BG_COLOR}" \
"$mask" -compose dst-in -composite \
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
@@ -134,11 +145,11 @@ mkdir -p android/app/src/main/res/values
cat > "$BACKGROUND_COLOR_FILE" << 'EOF'
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#7c52e0</color>
<color name="ic_launcher_background">#e9673f</color>
</resources>
EOF
# ── iOS App Icon (1024x1024, white logo on purple background) ──
# ── iOS App Icon (1024x1024, white logo on orange background) ──
echo "Generating iOS app icon..."
@@ -146,7 +157,7 @@ IOS_ICON_DIR="ios/App/App/Assets.xcassets/AppIcon.appiconset"
if [ -d "$IOS_ICON_DIR" ]; then
IOS_ICON="$IOS_ICON_DIR/AppIcon-512@2x.png"
# Logo at ~60% of canvas, centered on purple background (matches legacy Android style)
# Logo at ~60% of canvas, centered on orange background (matches Android style)
$MAGICK -size "1024x1024" "xc:${BG_COLOR}" \
\( "$LOGO_WHITE" -resize "614x614" \) \
-gravity center -compose over -composite \
@@ -160,7 +171,7 @@ fi
rm -rf "$TMPDIR"
echo -e "\n${GREEN}App icons generated successfully!${NC}"
echo -e "Icon: white Ditto logo on ${GREEN}${BG_COLOR}${NC} (Ditto purple)"
echo -e "Icon: white Agora logo on ${GREEN}${BG_COLOR}${NC} (Agora orange)"
echo -e "Generated:"
echo -e " Android:"
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
+1 -1
View File
@@ -43,7 +43,7 @@ const hardcodedConfig: AppConfig = {
appId: "agora",
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
homePage: "campaigns",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkzem0wfssdl264k",
theme: "system",
useAppRelays: true,
useUserRelays: false,
+1 -1
View File
@@ -84,7 +84,7 @@ function PageSkeleton() {
function SiteFooter() {
const { t } = useTranslation();
return (
<footer className="bg-background mt-auto pt-12">
<footer className="bg-background mt-auto pt-6 sm:pt-12">
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
<button
type="button"
-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>
)}
+50
View File
@@ -0,0 +1,50 @@
import { Capacitor } from '@capacitor/core';
import { useTranslation } from 'react-i18next';
import { ArrowRight } from 'lucide-react';
import { useAppContext } from '@/hooks/useAppContext';
import { ZAPSTORE_URL } from '@/lib/zapstore';
import { cn } from '@/lib/utils';
/**
* Zapstore download nudge — prompts mobile-web visitors to install the native
* Android app. Hidden inside the native app (you're already in it) and on
* desktop (`sm:hidden`), where downloading works differently.
*/
export function AppDownloadNudge({ className }: { className?: string }) {
const { t } = useTranslation();
const { config } = useAppContext();
if (Capacitor.isNativePlatform()) return null;
return (
<div className={cn('sm:hidden px-4 pt-8 pb-4', className)}>
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
{t('feed.getApp.eyebrow')}
</p>
<div className="flex items-center gap-3">
<img
src="/logo.png"
alt={config.appName}
className="h-10 w-10 shrink-0 rounded-xl"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground">
{t('feed.getApp.title', { appName: config.appName })}
</p>
<p className="text-xs text-muted-foreground">
{t('feed.getApp.subtitle', { appName: config.appName })}
</p>
</div>
<a
href={ZAPSTORE_URL}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary/10 hover:bg-primary/20 text-primary text-xs font-medium transition-colors"
>
{t('feed.getApp.download')}
<ArrowRight className="h-3 w-3" />
</a>
</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" />}
+19 -4
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import {
Activity,
Bell,
Download,
HandHeart,
Info,
LayoutDashboard,
@@ -15,6 +16,7 @@ import {
X,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Capacitor } from '@capacitor/core';
import { LoginArea } from '@/components/auth/LoginArea';
import { LogoIcon } from '@/components/icons/LogoIcon';
@@ -25,6 +27,7 @@ import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useHdBtcPrice } from '@/hooks/useHdBtcPrice';
import { useHdWallet } from '@/hooks/useHdWallet';
import { satsToUSD } from '@/lib/bitcoin';
import { ZAPSTORE_URL } from '@/lib/zapstore';
import { cn } from '@/lib/utils';
interface NavItem {
@@ -67,7 +70,7 @@ export function TopNav() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<header className="sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
<header className="safe-area-top sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
<div className="mx-auto flex h-16 max-w-7xl items-center gap-1 md:gap-4 px-4 sm:px-6">
{/* Mobile menu trigger */}
<button
@@ -95,7 +98,7 @@ export function TopNav() {
>
<LogoIcon className="size-9" />
<span
className="font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
className="latin-display font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
style={{
WebkitTextStroke: '0.022em currentColor',
transform: 'skewX(-6deg) scaleX(1.1)',
@@ -148,7 +151,7 @@ export function TopNav() {
>
<LogoIcon className="size-9" />
<span
className="font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
className="latin-display font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
style={{
WebkitTextStroke: '0.022em currentColor',
transform: 'skewX(-6deg) scaleX(1.1)',
@@ -175,6 +178,18 @@ export function TopNav() {
})}
onClose={() => setMobileOpen(false)}
/>
{!Capacitor.isNativePlatform() && (
<a
href={ZAPSTORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors"
>
<Download className="size-4 shrink-0" />
{t('nav.getApp')}
</a>
)}
</nav>
<div className="border-t border-border p-4 space-y-3">
<MobileFooterLinks onClose={() => setMobileOpen(false)} />
@@ -218,7 +233,7 @@ function WalletBalancePill() {
title={t('nav.wallet')}
>
<span
className="font-display font-normal tracking-wide leading-none uppercase text-xl inline-block tabular-nums"
className="latin-display font-display font-normal tracking-wide leading-none uppercase text-xl inline-block tabular-nums"
style={{
WebkitTextStroke: '0.022em currentColor',
transform: 'skewX(-6deg) scaleX(1.1)',
+11 -1
View File
@@ -7,8 +7,10 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Activity, Bell, ChevronDown, CircleHelp, LayoutDashboard, LogOut, Search, Settings, User, UserIcon, UserPlus, Wallet } from 'lucide-react';
import { Capacitor } from '@capacitor/core';
import { Activity, Bell, ChevronDown, CircleHelp, Download, LayoutDashboard, LogOut, Search, Settings, User, UserIcon, UserPlus, Wallet } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { ZAPSTORE_URL } from '@/lib/zapstore';
import {
DropdownMenu,
DropdownMenuContent,
@@ -144,6 +146,14 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
<span>{t('nav.about')}</span>
</Link>
</DropdownMenuItem>
{!Capacitor.isNativePlatform() && (
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
<a href={ZAPSTORE_URL} target="_blank" rel="noopener noreferrer">
<Download className='w-4 h-4' />
<span>{t('nav.getApp')}</span>
</a>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onAddAccountClick}
@@ -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;
+19
View File
@@ -95,6 +95,7 @@ function resolveLocaleFile(lng: string): string | undefined {
* already-loaded locales).
*/
const loadedLocales = new Set<string>(['en']);
let languageChangeRequest = 0;
async function loadLocale(lng: string): Promise<void> {
const file = resolveLocaleFile(lng);
@@ -134,6 +135,21 @@ async function loadLocale(lng: string): Promise<void> {
loadedLocales.add(file);
}
/**
* Switch languages only after the target locale has been registered.
*
* Calling i18next.changeLanguage() first can render React components against a
* missing lazy-loaded bundle, leaving them stuck on fallback English until an
* unrelated render happens. The request counter keeps rapid clicks ordered so a
* slower earlier download cannot overwrite the latest selection.
*/
export async function changeAppLanguage(lng: string): Promise<void> {
const request = ++languageChangeRequest;
await loadLocale(lng);
if (request !== languageChangeRequest) return;
await i18n.changeLanguage(lng);
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
@@ -161,6 +177,9 @@ i18n
caches: ['localStorage'],
lookupLocalStorage: 'i18nextLng',
},
react: {
bindI18nStore: 'added',
},
});
// Load the locale the detector picked on startup. If it isn't English the
+11 -3
View File
@@ -115,11 +115,19 @@
-webkit-text-stroke: 0;
}
:root[lang|="zh"] .font-display.latin-display {
font-family: 'Bebas Neue', sans-serif;
font-weight: 400;
letter-spacing: 0.025em;
text-transform: uppercase;
-webkit-text-stroke: 0.022em currentColor;
}
@layer utilities {
/* ── Safe-area inset utilities ────────────────────────────────────────────
Use var(--safe-area-inset-*, …) as the outer wrapper so that
Capacitor's SystemBars plugin (which injects --safe-area-inset-* CSS
variables on Android) takes precedence when available. The inner
Use var(--safe-area-inset-*, …) as the outer wrapper. On the Android APK
the WebView reports env(safe-area-inset-*) as 0, so MainActivity injects
the real system-bar insets into --safe-area-inset-top/bottom. The inner
env(safe-area-inset-*, 0px) is the standard fallback for iOS / web. */
.safe-area-top {
+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);
}
}
+5
View File
@@ -0,0 +1,5 @@
/** Zapstore app id for the Android build. */
export const ZAPSTORE_APP_ID = 'spot.agora.app';
/** Public Zapstore page for the Android app. */
export const ZAPSTORE_URL = `https://zapstore.dev/apps/${encodeURIComponent(ZAPSTORE_APP_ID)}`;
+36 -21
View File
@@ -60,7 +60,8 @@
"privacy": "الخصوصية",
"safety": "السلامة",
"changelog": "سجل التغييرات",
"sourceCode": "الكود المصدري"
"sourceCode": "الكود المصدري",
"getApp": "احصل على التطبيق"
},
"auth": {
"join": "انضمام",
@@ -128,6 +129,12 @@
},
"feed": {
"indexTagline": "محتواك. أسلوبك. قواعدك.",
"getApp": {
"eyebrow": "احصل على التطبيق",
"title": "{{appName}} لنظام أندرويد",
"subtitle": "تجربة {{appName}} الكاملة",
"download": "تنزيل"
},
"compose": {
"placeholder": "ماذا يحدث؟"
},
@@ -496,20 +503,23 @@
"wallet": "محفظة بيتكوين",
"myWalletLabel": "محفظة {{name}}",
"myWalletDefault": "محفظتي",
"walletHeroNote": "تتدفّق التبرّعات مباشرةً إلى محفظة Agora الخاصة بك.\nلا وسيط.",
"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 +554,6 @@
"goal": "الهدف",
"goalPlaceholder": "25,000",
"goalNote": "دولارات أمريكية صحيحة. المتبرعون يدفعون بالبيتكوين؛ يقدّر العملاء المعادل بالدولار وقت العرض.",
"deadline": "الموعد النهائي",
"submitCreate": "إطلاق الحملة",
"submitEdit": "تحديث الحملة",
"publishing": "جارٍ النشر…",
@@ -569,8 +578,6 @@
"errorSpInvalid": "رمز الدفع الصامت ليس رمز BIP-352 معروفًا (sp1…).",
"errorWalletRequired": "أدخل نقطة محفظة واحدة على الأقل — عنوان بيتكوين شبكة رئيسية (bc1q… / bc1p…) أو رمز دفع صامت (sp1…).",
"errorGoalInvalid": "يجب أن يكون الهدف مبلغًا موجبًا بالدولار الصحيح.",
"errorDeadlinePast": "لا يمكن أن يكون الموعد النهائي في الماضي.",
"errorDeadlineInvalid": "الموعد النهائي ليس تاريخًا صالحًا.",
"errorEditLatestMissing": "تعذّر العثور على أحدث نسخة لهذه الحملة لتحديثها.",
"errorSlugCollision": "لديك بالفعل حملة بالمعرّف «{{slug}}». اختر معرّفًا آخر.",
"errorBannerInvalid": "يجب أن يكون البانر رابط https:// صالحًا.",
@@ -587,6 +594,8 @@
"bannerStepSubtitle": "صورة واحدة لافتة تُمثّل الحملة في كل بطاقة.",
"storyStepTitle": "احكِ قصتك",
"storyStepSubtitle": "من المستفيد وكيف ستُستخدم الأموال.",
"goalStepTitle": "الهدف",
"goalStepSubtitle": "اختياري — اتركه فارغًا لحملة مفتوحة دون موعد نهائي.",
"next": "التالي",
"back": "رجوع",
"skip": "تخطٍّ",
@@ -596,11 +605,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | حملات {{appName}}",
"seoDescriptionFallback": "ادعم {{title}} على {{appName}}.",
"deadlineEndedOn": "انتهى في {{date}}",
"deadlineEndsToday": "ينتهي اليوم",
"deadlineDaysLeft_one": "بقي {{count}} يوم",
"deadlineDaysLeft_other": "بقي {{count}} يومًا",
"deadlineEndsOn": "ينتهي في {{date}}",
"back": "رجوع",
"edit": "تعديل",
"delete": "حذف",
@@ -643,7 +647,6 @@
"deleteDialogTitle": "حذف هذه الحملة؟",
"deleteDialogBody": "هذا ينشر طلب حذف NIP-09. ستزيل المرحّلات المتعاونة الحملة من الخلاصات والروابط المباشرة. تبقى إيصالات التبرعات السابقة على السلسلة بغض النظر. لا يمكن التراجع عن هذا الإجراء — لمواصلة قبول التبرعات، عدّل الحملة بدلًا من ذلك.",
"storyHeading": "القصة",
"campaignEnded": "انتهت الحملة",
"donate": "تبرّع",
"share": "مشاركة",
"target": "الهدف: {{amount}}",
@@ -744,7 +747,7 @@
"wlcDesc": "حملات منتقاة من World Liberty Congress.",
"allCampaigns": "كل الحملات",
"allCampaignsDesc": "كل الحملات على الشبكة، بالترتيب الزمني.",
"browseAll": "تصفّح كل الحملات",
"browseAll": "تصفّح كل الحملات",
"hidden": "مخفية",
"hiddenDesc": "حملات أُزيلت من الصفحة الرئيسية العامة. استخدم قائمة البطاقة لإلغاء الإخفاء.",
"hiddenEmpty": "لا توجد حملات مخفية حالياً.",
@@ -810,6 +813,8 @@
"lists": {
"stripAria": "قوائم مواضيع منتقاة للحملات",
"create": "قائمة جديدة",
"showMore": "إظهار {{count}} أخرى",
"showLess": "إظهار أقل",
"createDesc": "أنشئ قائمة مواضيع جديدة. ثم انتقِ إليها حملات من أي صفحة حملة.",
"createSubmit": "إنشاء القائمة",
"createFailed": "فشل إنشاء القائمة",
@@ -1228,6 +1233,7 @@
"walletSend": {
"title": "إرسال البيتكوين",
"send": "إرسال البيتكوين",
"max": "MAX",
"tapAgainToConfirm": "انقر مرة أخرى للتأكيد",
"satPerVB": "{{rate}} ساتوشي/vB",
"notEnoughBitcoin": "لا يوجد بيتكوين كافٍ",
@@ -1951,11 +1957,20 @@
},
"placeholder": {
"writeComment": "اكتب تعليقًا...",
"writeReply": "اكتب ردًا...",
"addComment": "أضف تعليقًا...",
"whatsHappening": "ماذا يحدث؟"
},
"blueskyDisclaimer": "الأشخاص على Bluesky لا يمكنهم رؤيتك لأنهم في الواقع غير لامركزيين."
},
"postDetail": {
"repliesHeading": "الردود",
"commentsHeading": "التعليقات",
"replyCount_one": "رد",
"replyCount_other": "ردود",
"commentCount_one": "تعليق",
"commentCount_other": "تعليقات"
},
"noteCard": {
"botAccount": "حساب بوت",
"showLess": "عرض أقل",
+36 -23
View File
@@ -64,7 +64,8 @@
"privacy": "Privacy",
"safety": "Safety",
"changelog": "Changelog",
"sourceCode": "Source code"
"sourceCode": "Source code",
"getApp": "Get the app"
},
"auth": {
"join": "Join",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "Your content. Your vibe. Your rules.",
"getApp": {
"eyebrow": "Get the app",
"title": "{{appName}} for Android",
"subtitle": "The full {{appName}} experience",
"download": "Download"
},
"compose": {
"placeholder": "What's happening?"
},
@@ -258,11 +265,20 @@
},
"placeholder": {
"writeComment": "Write a comment...",
"writeReply": "Write a reply...",
"addComment": "Add a comment...",
"whatsHappening": "What's happening?"
},
"blueskyDisclaimer": "People on Bluesky can't see you because they're not actually decentralized."
},
"postDetail": {
"repliesHeading": "Replies",
"commentsHeading": "Comments",
"replyCount_one": "reply",
"replyCount_other": "replies",
"commentCount_one": "comment",
"commentCount_other": "comments"
},
"noteCard": {
"botAccount": "Bot account",
"showLess": "Show less",
@@ -934,20 +950,23 @@
"wallet": "Bitcoin wallet",
"myWalletLabel": "{{name}}'s wallet",
"myWalletDefault": "My wallet",
"walletHeroNote": "Donations flow straight into your own Agora wallet.\nNo middleman.",
"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 +1001,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 +1025,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 +1041,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 +1054,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 +1096,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 +1196,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 +1266,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 +1754,7 @@
"walletSend": {
"title": "Send Bitcoin",
"send": "Send Bitcoin",
"max": "MAX",
"notEnoughBitcoin": "Not enough Bitcoin",
"tapAgainToConfirm": "Tap again to confirm",
"satPerVB": "{{rate}} sat/vB",
+34 -21
View File
@@ -64,7 +64,8 @@
"privacy": "Privacidad",
"safety": "Seguridad",
"changelog": "Novedades",
"sourceCode": "Código fuente"
"sourceCode": "Código fuente",
"getApp": "Descargar la app"
},
"auth": {
"join": "Unirse",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "Tu contenido. Tu estilo. Tus reglas.",
"getApp": {
"eyebrow": "Descargar la app",
"title": "{{appName}} para Android",
"subtitle": "La experiencia completa de {{appName}}",
"download": "Descargar"
},
"compose": {
"placeholder": "¿Qué está pasando?"
},
@@ -508,20 +515,23 @@
"wallet": "Cartera Bitcoin",
"myWalletLabel": "Cartera de {{name}}",
"myWalletDefault": "Mi cartera",
"walletHeroNote": "Las donaciones llegan directamente a tu propia cartera de Agora.\nSin intermediarios.",
"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 +566,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 +590,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 +615,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 +657,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 +757,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 +827,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 +1247,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",
@@ -1967,11 +1971,20 @@
},
"placeholder": {
"writeComment": "Escribe un comentario...",
"writeReply": "Escribe una respuesta...",
"addComment": "Agregar un comentario...",
"whatsHappening": "¿Qué está pasando?"
},
"blueskyDisclaimer": "La gente en Bluesky no puede verte porque en realidad no están descentralizados."
},
"postDetail": {
"repliesHeading": "Respuestas",
"commentsHeading": "Comentarios",
"replyCount_one": "respuesta",
"replyCount_other": "respuestas",
"commentCount_one": "comentario",
"commentCount_other": "comentarios"
},
"noteCard": {
"botAccount": "Cuenta de bot",
"showLess": "Mostrar menos",
+34 -21
View File
@@ -64,7 +64,8 @@
"privacy": "حریم خصوصی",
"safety": "ایمنی",
"changelog": "تغییرات",
"sourceCode": "کد منبع"
"sourceCode": "کد منبع",
"getApp": "دریافت برنامه"
},
"auth": {
"join": "پیوستن",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "محتوای شما. سبک شما. قوانین شما.",
"getApp": {
"eyebrow": "دریافت برنامه",
"title": "{{appName}} برای اندروید",
"subtitle": "تجربه کامل {{appName}}",
"download": "دانلود"
},
"compose": {
"placeholder": "چه خبر؟"
},
@@ -508,20 +515,23 @@
"wallet": "کیف پول بیت‌کوین",
"myWalletLabel": "کیف پول {{name}}",
"myWalletDefault": "کیف پول من",
"walletHeroNote": "کمک‌های مالی مستقیماً به کیف پول Agora خودت سرازیر می‌شوند.\nبدون واسطه.",
"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 +566,6 @@
"goal": "هدف",
"goalPlaceholder": "۲۵٬۰۰۰",
"goalNote": "دلار آمریکای صحیح. اهداکنندگان با بیت‌کوین می‌پردازند؛ کلاینت‌ها معادل دلاری را در زمان نمایش تخمین می‌زنند.",
"deadline": "مهلت",
"submitCreate": "راه‌اندازی کمپین",
"submitEdit": "به‌روزرسانی کمپین",
"publishing": "در حال انتشار…",
@@ -581,8 +590,6 @@
"errorSpInvalid": "کد پرداخت بی‌صدا یک کد BIP-352 شناخته‌شده نیست (sp1…).",
"errorWalletRequired": "حداقل یک نقطهٔ کیف پول وارد کن — یک نشانی بیت‌کوین شبکهٔ اصلی (bc1q… / bc1p…) یا یک کد پرداخت بی‌صدا (sp1…).",
"errorGoalInvalid": "هدف باید یک مقدار مثبت به دلار صحیح باشد.",
"errorDeadlinePast": "مهلت نمی‌تواند در گذشته باشد.",
"errorDeadlineInvalid": "مهلت یک تاریخ معتبر نیست.",
"errorEditLatestMissing": "آخرین نسخهٔ این کمپین برای به‌روزرسانی یافت نشد.",
"errorSlugCollision": "از قبل کمپینی با شناسهٔ «{{slug}}» داری. شناسهٔ دیگری انتخاب کن.",
"errorBannerInvalid": "بنر باید یک نشانی https:// معتبر باشد.",
@@ -608,11 +615,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | کمپین‌های {{appName}}",
"seoDescriptionFallback": "از {{title}} در {{appName}} حمایت کن.",
"deadlineEndedOn": "در {{date}} پایان یافت",
"deadlineEndsToday": "امروز پایان می‌یابد",
"deadlineDaysLeft_one": "{{count}} روز باقی",
"deadlineDaysLeft_other": "{{count}} روز باقی",
"deadlineEndsOn": "در {{date}} پایان می‌یابد",
"back": "بازگشت",
"edit": "ویرایش",
"delete": "حذف",
@@ -655,7 +657,6 @@
"deleteDialogTitle": "این کمپین حذف شود؟",
"deleteDialogBody": "این یک درخواست حذف NIP-09 منتشر می‌کند. رله‌های همکار کمپین را از فیدها و پیوندهای مستقیم حذف خواهند کرد. رسیدهای کمک‌های گذشته در زنجیره باقی می‌مانند. این کار قابل بازگشت نیست — برای ادامهٔ دریافت کمک، به‌جای حذف کمپین را ویرایش کن.",
"storyHeading": "داستان",
"campaignEnded": "کمپین پایان یافت",
"donate": "کمک کنید",
"share": "هم‌رسانی",
"target": "هدف: {{amount}}",
@@ -756,7 +757,7 @@
"wlcDesc": "کمپین‌های گزینش‌شده توسط کنگرهٔ آزادی جهانی (World Liberty Congress).",
"allCampaigns": "همه کمپین‌ها",
"allCampaignsDesc": "همه کمپین‌های شبکه، به ترتیب زمانی.",
"browseAll": "مرور همه کمپین‌ها",
"browseAll": "مرور همه کمپین‌ها",
"hidden": "پنهان‌شده",
"hiddenDesc": "کمپین‌هایی که از صفحه اصلی عمومی حذف شده‌اند. برای آشکار کردن دوباره از منوی کارت استفاده کنید.",
"hiddenEmpty": "در حال حاضر هیچ کمپینی پنهان نشده است.",
@@ -826,6 +827,8 @@
"lists": {
"stripAria": "فهرست‌های منتخب موضوعی کمپین‌ها",
"create": "فهرست جدید",
"showMore": "نمایش {{count}} مورد بیشتر",
"showLess": "نمایش کمتر",
"createDesc": "یک فهرست موضوعی جدید بسازید. از هر صفحهٔ کمپین، کمپین‌ها را به آن اضافه کنید.",
"createSubmit": "ساخت فهرست",
"createFailed": "ساخت فهرست ناموفق بود",
@@ -1244,6 +1247,7 @@
"walletSend": {
"title": "ارسال بیت‌کوین",
"send": "ارسال بیت‌کوین",
"max": "MAX",
"tapAgainToConfirm": "برای تأیید دوباره ضربه بزنید",
"satPerVB": "{{rate}} ساتوشی/vB",
"notEnoughBitcoin": "بیت‌کوین کافی نیست",
@@ -1967,11 +1971,20 @@
},
"placeholder": {
"writeComment": "یک نظر بنویس...",
"writeReply": "یک پاسخ بنویس...",
"addComment": "افزودن نظر...",
"whatsHappening": "چه خبر؟"
},
"blueskyDisclaimer": "افراد در Bluesky نمی‌توانند شما را ببینند چون آن‌ها در واقع غیرمتمرکز نیستند."
},
"postDetail": {
"repliesHeading": "پاسخ‌ها",
"commentsHeading": "نظرات",
"replyCount_one": "پاسخ",
"replyCount_other": "پاسخ",
"commentCount_one": "نظر",
"commentCount_other": "نظر"
},
"noteCard": {
"botAccount": "حساب ربات",
"showLess": "نمایش کمتر",
+31 -20
View File
@@ -63,7 +63,8 @@
"privacy": "Confidentialité",
"safety": "Sécurité",
"changelog": "Journal des modifications",
"sourceCode": "Code source"
"sourceCode": "Code source",
"getApp": "Télécharger l'application"
},
"auth": {
"join": "Rejoindre",
@@ -131,6 +132,12 @@
},
"feed": {
"indexTagline": "Votre contenu. Votre ambiance. Vos règles.",
"getApp": {
"eyebrow": "Télécharger l'application",
"title": "{{appName}} pour Android",
"subtitle": "L'expérience {{appName}} complète",
"download": "Télécharger"
},
"compose": {
"placeholder": "Que se passe-t-il ?"
},
@@ -256,11 +263,20 @@
},
"placeholder": {
"writeComment": "Écrire un commentaire...",
"writeReply": "Écrire une réponse...",
"addComment": "Ajouter un commentaire...",
"whatsHappening": "Que se passe-t-il ?"
},
"blueskyDisclaimer": "Les utilisateurs de Bluesky ne peuvent pas vous voir car ils ne sont pas vraiment décentralisés."
},
"postDetail": {
"repliesHeading": "Réponses",
"commentsHeading": "Commentaires",
"replyCount_one": "réponse",
"replyCount_other": "réponses",
"commentCount_one": "commentaire",
"commentCount_other": "commentaires"
},
"noteCard": {
"botAccount": "Compte bot",
"showLess": "Réduire",
@@ -939,19 +955,20 @@
"wallet": "Portefeuille Bitcoin",
"myWalletLabel": "Portefeuille de {{name}}",
"myWalletDefault": "Mon portefeuille",
"walletHeroNote": "Les dons arrivent directement dans votre propre portefeuille Agora.\nPas d'intermédiaire.",
"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 +1004,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 +1028,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 +1053,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 +1095,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 +1195,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 +1265,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 +1686,7 @@
"walletSend": {
"title": "Envoyer du Bitcoin",
"send": "Envoyer du Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Appuyez à nouveau pour confirmer",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "Bitcoin insuffisant",
+63 -22
View File
@@ -64,7 +64,8 @@
"privacy": "प्राइवेसी",
"safety": "सुरक्षा",
"changelog": "बदलाव",
"sourceCode": "सोर्स कोड"
"sourceCode": "सोर्स कोड",
"getApp": "ऐप पाएं"
},
"auth": {
"join": "जुड़ें",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "आपका कंटेंट। आपका अंदाज़। आपके नियम।",
"getApp": {
"eyebrow": "ऐप पाएं",
"title": "Android के लिए {{appName}}",
"subtitle": "संपूर्ण {{appName}} अनुभव",
"download": "डाउनलोड करें"
},
"compose": {
"placeholder": "क्या चल रहा है?"
},
@@ -257,11 +264,20 @@
},
"placeholder": {
"writeComment": "कमेंट लिखें...",
"writeReply": "उत्तर लिखें...",
"addComment": "कमेंट जोड़ें...",
"whatsHappening": "क्या चल रहा है?"
},
"blueskyDisclaimer": "Bluesky पर लोग आपको नहीं देख सकते क्योंकि वे असल में डिसेंट्रलाइज़्ड नहीं हैं।"
},
"postDetail": {
"repliesHeading": "उत्तर",
"commentsHeading": "कमेंट",
"replyCount_one": "उत्तर",
"replyCount_other": "उत्तर",
"commentCount_one": "कमेंट",
"commentCount_other": "कमेंट"
},
"noteCard": {
"botAccount": "बॉट अकाउंट",
"showLess": "कम दिखाएँ",
@@ -940,20 +956,23 @@
"wallet": "Bitcoin वॉलेट",
"myWalletLabel": "{{name}} का वॉलेट",
"myWalletDefault": "मेरा वॉलेट",
"walletHeroNote": "दान सीधे आपके अपने Agora वॉलेट में आता है।\nकोई बिचौलिया नहीं।",
"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 +1007,6 @@
"goal": "लक्ष्य",
"goalPlaceholder": "25,000",
"goalNote": "पूरे US डॉलर। डोनर Bitcoin में भुगतान करते हैं; क्लाइंट देखने के समय USD-समकक्ष का अनुमान लगाते हैं।",
"deadline": "अंतिम तारीख़",
"submitCreate": "कैंपेन लॉन्च करें",
"submitEdit": "कैंपेन अपडेट करें",
"publishing": "पब्लिश हो रहा है…",
@@ -1013,8 +1031,6 @@
"errorSpInvalid": "साइलेंट-पेमेंट कोड कोई पहचाना BIP-352 कोड नहीं है (sp1…)।",
"errorWalletRequired": "कम से कम एक वॉलेट endpoint दें — एक Bitcoin mainnet एड्रेस (bc1q… / bc1p…) या एक साइलेंट-पेमेंट कोड (sp1…)।",
"errorGoalInvalid": "लक्ष्य एक धनात्मक पूर्ण-डॉलर राशि होनी चाहिए।",
"errorDeadlinePast": "अंतिम तारीख़ बीते समय की नहीं हो सकती।",
"errorDeadlineInvalid": "अंतिम तारीख़ मान्य तारीख़ नहीं है।",
"errorEditLatestMissing": "इस कैंपेन का सबसे नया संस्करण अपडेट करने के लिए नहीं मिला।",
"errorSlugCollision": "आपके पास पहले से \"{{slug}}\" पहचानकर्ता वाला कैंपेन है। दूसरा चुनें।",
"errorBannerInvalid": "बैनर एक मान्य https:// URL होना चाहिए।",
@@ -1040,11 +1056,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} फंडरेज़र",
"seoDescriptionFallback": "{{appName}} पर {{title}} का समर्थन करें।",
"deadlineEndedOn": "{{date}} को समाप्त",
"deadlineEndsToday": "आज समाप्त",
"deadlineDaysLeft_one": "{{count}} दिन बाक़ी",
"deadlineDaysLeft_other": "{{count}} दिन बाक़ी",
"deadlineEndsOn": "{{date}} को समाप्त",
"back": "वापस",
"edit": "एडिट करें",
"delete": "डिलीट करें",
@@ -1087,7 +1098,6 @@
"deleteDialogTitle": "इस कैंपेन को डिलीट करें?",
"deleteDialogBody": "इससे एक NIP-09 डिलीशन रिक्वेस्ट पब्लिश होती है। सही तरीक़े से चलने वाले रिले कैंपेन को फ़ीड और सीधे लिंक से हटा देंगे। पिछली डोनेशन रसीदें ऑन-चेन वैसे भी रहेंगी। यह क्रिया वापस नहीं ली जा सकती — डोनेशन लेना जारी रखने के लिए इसके बजाय कैंपेन एडिट करें।",
"storyHeading": "कहानी",
"campaignEnded": "कैंपेन समाप्त",
"donate": "डोनेट करें",
"share": "शेयर करें",
"target": "लक्ष्य: {{amount}}",
@@ -1188,7 +1198,7 @@
"wlcDesc": "World Liberty Congress द्वारा चुने गए कैंपेन।",
"allCampaigns": "सभी कैंपेन",
"allCampaignsDesc": "नेटवर्क के सभी कैंपेन, कालक्रम के अनुसार।",
"browseAll": "सभी कैंपेन देखें",
"browseAll": "सभी कैंपेन देखें",
"hidden": "छुपा हुआ",
"hiddenDesc": "सार्वजनिक होमपेज से दबाए गए कैंपेन। कार्ड के kebab मेन्यू से अनहाइड करें।",
"hiddenEmpty": "अभी कोई कैंपेन छुपा हुआ नहीं है।",
@@ -1199,7 +1209,35 @@
"searchPlaceholder": "कैंपेन खोजें…",
"searchAriaLabel": "कैंपेन खोजें",
"noMatch": "“{{query}}” से मेल खाने वाला कोई कैंपेन नहीं",
"noMatchHint": "अलग खोज शब्द आज़माएँ, या खोज साफ़ करें।"
"noMatchHint": "अलग खोज शब्द आज़माएँ, या खोज साफ़ करें।",
"whyDifferent": {
"eyebrow": "क्यों {{appName}}",
"title": "अलग ढंग से बना।",
"lede": "डोनर से सक्रियकर्ता तक सीधे Bitcoin। बीच में कोई प्लेटफ़ॉर्म नहीं, कोई कस्टोडियन नहीं, अनुमति की ज़रूरत नहीं।",
"block1": {
"heading": "GoFundMe से अलग",
"body": "कोई प्लेटफ़ॉर्म आपके डोनेशन फ्रीज़ नहीं कर सकता, रिफंड नहीं माँग सकता, या नीति असहमति पर आपका कैंपेन बंद नहीं कर सकता। कोई Stripe, Visa या बैंक बीच में बैठकर कैंपेन के दौरान आपको काट नहीं सकता।",
"bullet1": "फ्रीज़-प्रूफ़ — कोई प्लेटफ़ॉर्म वीटो नहीं",
"bullet2": "कोई भुगतान प्रोसेसर प्लग नहीं खींच सकता",
"bullet3": "शून्य प्लेटफ़ॉर्म फ़ीस"
},
"block2": {
"heading": "दूसरे ‘Bitcoin’ प्लेटफ़ॉर्मों से अलग",
"body": "फेल होने या ऑफ़लाइन जाने वाला कोई केंद्रीय Lightning node, कस्टोडियन या LSP नहीं। फंड सीधे Bitcoin पर आपके नियंत्रण वाले वॉलेट में सेटल होते हैं। अगर {{appName}} कल गायब हो जाए, तब भी हर कैंपेन चलता रहेगा।",
"bullet1": "खाली या फ्रीज़ होने वाला कोई custodial wallet नहीं",
"bullet2": "आपके अपने वॉलेट में on-chain सेटलमेंट",
"bullet3": "{{appName}} गायब होने पर भी काम करता है"
},
"block3": {
"heading": "सार्वजनिक या निजी। चुनाव आपका।",
"body": "सक्रियकर्ता अपने threat model के हिसाब से receiving option चुनते हैं। डोनर को एक QR दिखता है; वॉलेट सही protocol चुनता है।",
"publicLabel": "सार्वजनिक",
"publicSummary": "हर Bitcoin वॉलेट में काम करता है। तेज़ और on-chain सत्यापन योग्य।",
"privateLabel": "निजी",
"privateSummary": "BIP-352 silent payments। डोनेशन unlinkable outputs पर पहुँचते हैं।"
},
"readMore": "पूरा विवरण पढ़ें"
}
},
"all": {
"title": "कैंपेन",
@@ -1230,6 +1268,8 @@
"lists": {
"stripAria": "क्यूरेटेड कैंपेन टॉपिक सूचियाँ",
"create": "नई सूची",
"showMore": "{{count}} और दिखाएँ",
"showLess": "कम दिखाएँ",
"createDesc": "एक नई टॉपिक सूची बनाएँ। किसी भी कैंपेन पेज से कैंपेन उसमें जोड़कर क्यूरेट करें।",
"createSubmit": "सूची बनाएँ",
"createFailed": "सूची नहीं बनाई जा सकी",
@@ -1585,6 +1625,7 @@
"walletSend": {
"title": "Bitcoin भेजें",
"send": "Bitcoin भेजें",
"max": "MAX",
"tapAgainToConfirm": "पुष्टि के लिए फिर टैप करें",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "पर्याप्त बिटकॉइन नहीं",
+63 -22
View File
@@ -64,7 +64,8 @@
"privacy": "Privasi",
"safety": "Keamanan",
"changelog": "Catatan Versi",
"sourceCode": "Kode sumber"
"sourceCode": "Kode sumber",
"getApp": "Dapatkan aplikasi"
},
"auth": {
"join": "Gabung",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "Konten Anda. Gaya Anda. Aturan Anda.",
"getApp": {
"eyebrow": "Dapatkan aplikasi",
"title": "{{appName}} untuk Android",
"subtitle": "Pengalaman {{appName}} sepenuhnya",
"download": "Unduh"
},
"compose": {
"placeholder": "Apa yang sedang terjadi?"
},
@@ -257,11 +264,20 @@
},
"placeholder": {
"writeComment": "Tulis komentar...",
"writeReply": "Tulis balasan...",
"addComment": "Tambah komentar...",
"whatsHappening": "Apa yang sedang terjadi?"
},
"blueskyDisclaimer": "Orang-orang di Bluesky tidak bisa melihat Anda karena mereka sebenarnya tidak terdesentralisasi."
},
"postDetail": {
"repliesHeading": "Balasan",
"commentsHeading": "Komentar",
"replyCount_one": "balasan",
"replyCount_other": "balasan",
"commentCount_one": "komentar",
"commentCount_other": "komentar"
},
"noteCard": {
"botAccount": "Akun bot",
"showLess": "Tampilkan lebih sedikit",
@@ -940,20 +956,23 @@
"wallet": "Dompet Bitcoin",
"myWalletLabel": "Dompet {{name}}",
"myWalletDefault": "Dompet saya",
"walletHeroNote": "Donasi langsung masuk ke dompet Agora Anda sendiri.\nTanpa perantara.",
"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 +1007,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 +1031,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 +1056,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 +1098,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 +1198,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.",
@@ -1199,7 +1209,35 @@
"searchPlaceholder": "Cari kampanye…",
"searchAriaLabel": "Cari kampanye",
"noMatch": "Tidak ada kampanye yang cocok dengan “{{query}}”",
"noMatchHint": "Coba kata pencarian lain, atau bersihkan pencarian."
"noMatchHint": "Coba kata pencarian lain, atau bersihkan pencarian.",
"whyDifferent": {
"eyebrow": "Mengapa {{appName}}",
"title": "Dibangun berbeda.",
"lede": "Bitcoin langsung dari donor ke aktivis. Tanpa platform di tengah, tanpa kustodian yang menahan dana, tanpa perlu izin.",
"block1": {
"heading": "Berbeda dari GoFundMe",
"body": "Tidak ada platform yang bisa membekukan donasi Anda, menuntut pengembalian dana, atau menghentikan kampanye karena perbedaan kebijakan. Tidak ada Stripe, Visa, atau bank di tengah yang bisa memutus akses Anda saat kampanye berjalan.",
"bullet1": "Tahan pembekuan — tanpa veto platform",
"bullet2": "Tidak ada pemroses pembayaran yang bisa mencabut akses",
"bullet3": "Nol biaya platform"
},
"block2": {
"heading": "Berbeda dari platform Bitcoin lain",
"body": "Tidak ada node Lightning terpusat, kustodian, atau LSP yang bisa gagal atau offline. Dana diselesaikan langsung di Bitcoin ke dompet yang Anda kendalikan. Jika {{appName}} hilang besok, setiap kampanye tetap berjalan.",
"bullet1": "Tidak ada dompet kustodial yang bisa dikuras atau dibekukan",
"bullet2": "Diselesaikan on-chain ke dompet milik Anda",
"bullet3": "Tetap berfungsi meski {{appName}} menghilang"
},
"block3": {
"heading": "Publik atau privat. Pilihan Anda.",
"body": "Aktivis memilih opsi penerimaan yang sesuai dengan model ancaman mereka. Donor melihat satu QR; dompet memilih protokol yang tepat.",
"publicLabel": "Publik",
"publicSummary": "Berfungsi di setiap dompet Bitcoin. Cepat dan dapat diverifikasi on-chain.",
"privateLabel": "Privat",
"privateSummary": "Silent payments BIP-352. Donasi masuk ke output yang tidak dapat ditautkan."
},
"readMore": "Baca uraian lengkap"
}
},
"all": {
"title": "Kampanye",
@@ -1230,6 +1268,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 +1625,7 @@
"walletSend": {
"title": "Kirim Bitcoin",
"send": "Kirim Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Ketuk lagi untuk konfirmasi",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "Bitcoin tidak mencukupi",
+34 -21
View File
@@ -64,7 +64,8 @@
"privacy": "ភាពឯកជន",
"safety": "សុវត្ថិភាព",
"changelog": "កំណត់ហេតុការផ្លាស់ប្តូរ",
"sourceCode": "កូដប្រភព"
"sourceCode": "កូដប្រភព",
"getApp": "ទាញយកកម្មវិធី"
},
"auth": {
"join": "ចូលរួម",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "ខ្លឹមសាររបស់អ្នក។ ស្ទីលរបស់អ្នក។ ច្បាប់របស់អ្នក។",
"getApp": {
"eyebrow": "ទាញយកកម្មវិធី",
"title": "{{appName}} សម្រាប់ Android",
"subtitle": "បទពិសោធន៍ {{appName}} ពេញលេញ",
"download": "ទាញយក"
},
"compose": {
"placeholder": "មានរឿងអ្វី?"
},
@@ -508,20 +515,23 @@
"wallet": "កាបូបប៊ីតខញ",
"myWalletLabel": "កាបូបរបស់ {{name}}",
"myWalletDefault": "កាបូបរបស់ខ្ញុំ",
"walletHeroNote": "ការបរិច្ចាគហូរចូលដោយផ្ទាល់ទៅក្នុងកាបូប Agora ផ្ទាល់ខ្លួនរបស់អ្នក។\nគ្មានអ្នកកណ្ដាល។",
"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 +566,6 @@
"goal": "គោលដៅ",
"goalPlaceholder": "25,000",
"goalNote": "ដុល្លារអាមេរិកពេញ។ អ្នកបរិច្ចាគបង់ប្រាក់ជាប៊ីតខញ; អតិថិជនប៉ាន់ប្រមាណសមមូល USD នៅពេលមើល។",
"deadline": "ពេលកំណត់",
"submitCreate": "បើកដំណើរយុទ្ធនាការ",
"submitEdit": "ធ្វើបច្ចុប្បន្នភាពយុទ្ធនាការ",
"publishing": "កំពុងផ្សព្វផ្សាយ…",
@@ -581,8 +590,6 @@
"errorSpInvalid": "លេខកូដបង់ប្រាក់ស្ងាត់មិនមែនជាលេខកូដ BIP-352 ដែលត្រូវបានទទួលស្គាល់ទេ (sp1…)។",
"errorWalletRequired": "ផ្តល់យ៉ាងហោចណាស់ចំណុចកាបូបមួយ — អាសយដ្ឋានប៊ីតខញ mainnet (bc1q… / bc1p…) ឬលេខកូដបង់ប្រាក់ស្ងាត់ (sp1…)។",
"errorGoalInvalid": "គោលដៅត្រូវតែជាចំនួនវិជ្ជមានជា USD ពេញ។",
"errorDeadlinePast": "ពេលកំណត់មិនអាចស្ថិតក្នុងអតីតកាល។",
"errorDeadlineInvalid": "ពេលកំណត់មិនមែនជាកាលបរិច្ឆេទត្រឹមត្រូវ។",
"errorEditLatestMissing": "មិនអាចស្វែងរកកំណែចុងក្រោយរបស់យុទ្ធនាការនេះដើម្បីធ្វើបច្ចុប្បន្នភាព។",
"errorSlugCollision": "អ្នកមានយុទ្ធនាការដែលមានកំណត់អត្តសញ្ញាណ «{{slug}}» រួចហើយ។ ជ្រើសរើសផ្សេង។",
"errorBannerInvalid": "បដាត្រូវតែជា URL https:// ត្រឹមត្រូវ។",
@@ -608,11 +615,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | យុទ្ធនាការរបស់ {{appName}}",
"seoDescriptionFallback": "គាំទ្រ {{title}} នៅលើ {{appName}}។",
"deadlineEndedOn": "បានបញ្ចប់នៅ {{date}}",
"deadlineEndsToday": "បញ្ចប់នៅថ្ងៃនេះ",
"deadlineDaysLeft_one": "នៅសល់ {{count}} ថ្ងៃ",
"deadlineDaysLeft_other": "នៅសល់ {{count}} ថ្ងៃ",
"deadlineEndsOn": "បញ្ចប់នៅ {{date}}",
"back": "ត្រឡប់",
"edit": "កែសម្រួល",
"delete": "លុប",
@@ -655,7 +657,6 @@
"deleteDialogTitle": "លុបយុទ្ធនាការនេះមែនទេ?",
"deleteDialogBody": "នេះផ្សព្វផ្សាយសំណើលុប NIP-09។ Relay ដែលអនុលោមនឹងលុបយុទ្ធនាការចេញពី feed និងតំណផ្ទាល់។ បង្កាន់ដៃនៃការបរិច្ចាគកន្លងមកនៅតែស្ថិតលើខ្សែសង្វាក់។ សកម្មភាពនេះមិនអាចត្រឡប់វិញបានទេ — ដើម្បីបន្តទទួលការបរិច្ចាគ កែសម្រួលយុទ្ធនាការជំនួស។",
"storyHeading": "រឿង",
"campaignEnded": "យុទ្ធនាការបានបញ្ចប់",
"donate": "បរិច្ចាគ",
"share": "ចែករំលែក",
"target": "គោលដៅ៖ {{amount}}",
@@ -756,7 +757,7 @@
"wlcDesc": "យុទ្ធនាការដែលបានសម្រិតសម្រាំងដោយសភាសេរីភាពពិភពលោក (World Liberty Congress)។",
"allCampaigns": "យុទ្ធនាការទាំងអស់",
"allCampaignsDesc": "យុទ្ធនាការទាំងអស់នៅលើបណ្តាញ តាមលំដាប់កាលប្បវត្តិ។",
"browseAll": "មើលយុទ្ធនាការទាំងអស់",
"browseAll": "មើលយុទ្ធនាការទាំងអស់",
"hidden": "បានលាក់",
"hiddenDesc": "យុទ្ធនាការដែលត្រូវបានដកចេញពីទំព័រដើមសាធារណៈ។ ប្រើម៉ឺនុយលើកាតដើម្បីឈប់លាក់។",
"hiddenEmpty": "បច្ចុប្បន្នគ្មានយុទ្ធនាការត្រូវបានលាក់។",
@@ -826,6 +827,8 @@
"lists": {
"stripAria": "បញ្ជីប្រធានបទយុទ្ធនាការដែលបានសម្រិតសម្រាំង",
"create": "បញ្ជីថ្មី",
"showMore": "បង្ហាញ {{count}} ទៀត",
"showLess": "បង្ហាញតិច",
"createDesc": "បង្កើតបញ្ជីប្រធានបទថ្មី។ សម្រិតសម្រាំងយុទ្ធនាការទៅក្នុងវាពីទំព័រយុទ្ធនាការណាមួយ។",
"createSubmit": "បង្កើតបញ្ជី",
"createFailed": "បរាជ័យក្នុងការបង្កើតបញ្ជី",
@@ -1244,6 +1247,7 @@
"walletSend": {
"title": "ផ្ញើ Bitcoin",
"send": "ផ្ញើ Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "ប៉ះម្ដងទៀតដើម្បីបញ្ជាក់",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "មិនមាន Bitcoin គ្រប់គ្រាន់",
@@ -1967,11 +1971,20 @@
},
"placeholder": {
"writeComment": "សរសេរមតិយោបល់...",
"writeReply": "សរសេរការឆ្លើយតប...",
"addComment": "បន្ថែមមតិយោបល់...",
"whatsHappening": "មានរឿងអ្វី?"
},
"blueskyDisclaimer": "មនុស្សនៅលើ Bluesky មិនអាចមើលឃើញអ្នកទេ ព្រោះតាមពិតពួកគេមិនមែនជាការវិមជ្ឈការទេ។"
},
"postDetail": {
"repliesHeading": "ការឆ្លើយតប",
"commentsHeading": "មតិយោបល់",
"replyCount_one": "ការឆ្លើយតប",
"replyCount_other": "ការឆ្លើយតប",
"commentCount_one": "មតិយោបល់",
"commentCount_other": "មតិយោបល់"
},
"noteCard": {
"botAccount": "គណនី Bot",
"showLess": "បង្ហាញតិច",
+36 -21
View File
@@ -64,7 +64,8 @@
"privacy": "محرمیت",
"safety": "خوندیتوب",
"changelog": "د بدلونونو لاګ",
"sourceCode": "د سرچینې کوډ"
"sourceCode": "د سرچینې کوډ",
"getApp": "اپ ترلاسه کړئ"
},
"auth": {
"join": "ګډون",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "ستاسو مینځپانګه. ستاسو سټایل. ستاسو قواعد.",
"getApp": {
"eyebrow": "اپ ترلاسه کړئ",
"title": "{{appName}} د اندروید لپاره",
"subtitle": "بشپړ {{appName}} تجربه",
"download": "ډاونلوډ"
},
"compose": {
"placeholder": "څه روان دي؟"
},
@@ -508,20 +515,23 @@
"wallet": "د بټ‌کوین پاکټ",
"myWalletLabel": "د {{name}} پاکټ",
"myWalletDefault": "زما پاکټ",
"walletHeroNote": "بسپنې مستقیماً ستاسو په خپل اګورا (Agora) پاکټ کې راځي.\nنه منځګړی.",
"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 +566,6 @@
"goal": "هدف",
"goalPlaceholder": "25,000",
"goalNote": "ټول امریکايي ډالر. ډونرز په بټ‌کوین پیسې ورکوي؛ کلائنټونه د لیدلو په وخت کې د ډالر معادل اټکل کوي.",
"deadline": "وروستۍ نېټه",
"submitCreate": "کمپاین پیلول",
"submitEdit": "د کمپاین تازه کول",
"publishing": "خپرول…",
@@ -581,8 +590,6 @@
"errorSpInvalid": "د چپ پیسو کوډ د پېژندل شوي BIP-352 کوډ نه دی (sp1…).",
"errorWalletRequired": "لږ تر لږه یوه د پاکټ نقطه ورکړئ — د بټ‌کوین mainnet پته (bc1q… / bc1p…) یا د چپ پیسو کوډ (sp1…).",
"errorGoalInvalid": "هدف باید د ډالر مثبته بشپړه اندازه وي.",
"errorDeadlinePast": "وروستۍ نېټه نشي کولی په تېر وخت کې وي.",
"errorDeadlineInvalid": "وروستۍ نېټه سمه نېټه نه ده.",
"errorEditLatestMissing": "د دې کمپاین وروستۍ نسخه د تازه کولو لپاره ونه موندل شوه.",
"errorSlugCollision": "تاسو لا دمخه «{{slug}}» پېژندونکی کمپاین لرئ. بل وټاکئ.",
"errorBannerInvalid": "بنر باید د https:// سمه نښه وي.",
@@ -599,6 +606,8 @@
"bannerStepSubtitle": "یو زړه‌راښکونکی انځور په هر کارت کې کمپاین ښیي.",
"storyStepTitle": "خپله کیسه ووایاست",
"storyStepSubtitle": "څوک ګټه اخلي او بسپنې به څنګه ولګول شي.",
"goalStepTitle": "هدف",
"goalStepSubtitle": "اختیاري — د بې‌مهاله کمپاین لپاره یې تش پرېږدئ.",
"next": "بل",
"back": "شاته",
"skip": "تېرول",
@@ -608,11 +617,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | د {{appName}} کمپاینونه",
"seoDescriptionFallback": "په {{appName}} کې د {{title}} ملاتړ وکړئ.",
"deadlineEndedOn": "په {{date}} پای ته ورسیده",
"deadlineEndsToday": "نن پای ته رسي",
"deadlineDaysLeft_one": "{{count}} ورځ پاتې",
"deadlineDaysLeft_other": "{{count}} ورځې پاتې",
"deadlineEndsOn": "په {{date}} پای ته رسي",
"back": "شاته",
"edit": "سمول",
"delete": "ړنګول",
@@ -655,7 +659,6 @@
"deleteDialogTitle": "دا کمپاین ړنګ کړئ؟",
"deleteDialogBody": "دا د NIP-09 د ړنګولو غوښتنه خپروي. ښه چلند کوونکي ریلې به کمپاین له فیدونو او مستقیمو لینکونو څخه لرې کړي. د تېرو مرستو رسیدونه پر چین پاتې کیږي. دا کار بیرته نه راګرځول کیږي — د مرستو د منلو لپاره، کمپاین سم کړئ.",
"storyHeading": "کیسه",
"campaignEnded": "کمپاین پای ته ورسید",
"donate": "مرسته",
"share": "شریکول",
"target": "هدف: {{amount}}",
@@ -756,7 +759,7 @@
"wlcDesc": "د World Liberty Congress لخوا غوره شوي کمپاینونه.",
"allCampaigns": "ټول کمپاینونه",
"allCampaignsDesc": "د شبکې ټول کمپاینونه، د وخت په ترتیب.",
"browseAll": "ټول کمپاینونه وګورئ",
"browseAll": "ټول کمپاینونه وګورئ",
"hidden": "پټ شوي",
"hiddenDesc": "هغه کمپاینونه چې د عمومي کور پاڼې څخه پټ شوي. د بېرته ښودلو لپاره د کارت مینو وکاروئ.",
"hiddenEmpty": "اوس مهال هیڅ کمپاین پټ نه دی.",
@@ -826,6 +829,8 @@
"lists": {
"stripAria": "د کمپاین موضوعاتو ترتیب شوي لیستونه",
"create": "نوی لیست",
"showMore": "{{count}} نور وښایه",
"showLess": "لږ وښایه",
"createDesc": "د موضوع یو نوی لیست جوړ کړئ. د هر کمپاین له پاڼې څخه کمپاینونه پکې ترتیب کړئ.",
"createSubmit": "لیست جوړ کړئ",
"createFailed": "د لیست جوړول ناکام شول",
@@ -1244,6 +1249,7 @@
"walletSend": {
"title": "بټکوین لیږل",
"send": "بټکوین لیږل",
"max": "MAX",
"tapAgainToConfirm": "د تایید لپاره بیا ټک ووهئ",
"satPerVB": "{{rate}} ساتوشي/vB",
"notEnoughBitcoin": "کافی بټکوین نشته",
@@ -1967,11 +1973,20 @@
},
"placeholder": {
"writeComment": "تبصره ولیکئ...",
"writeReply": "ځواب ولیکئ...",
"addComment": "تبصره اضافه کړئ...",
"whatsHappening": "څه روان دي؟"
},
"blueskyDisclaimer": "په Bluesky کې خلک تاسو نه شي لیدلی ځکه چې هغوی واقعیا غیرمرکزي نه دي."
},
"postDetail": {
"repliesHeading": "ځوابونه",
"commentsHeading": "تبصرې",
"replyCount_one": "ځواب",
"replyCount_other": "ځوابونه",
"commentCount_one": "تبصره",
"commentCount_other": "تبصرې"
},
"noteCard": {
"botAccount": "د بوټ ګڼون",
"showLess": "لږ ښکاره کړئ",
+36 -21
View File
@@ -64,7 +64,8 @@
"privacy": "Privacidade",
"safety": "Segurança",
"changelog": "Notas de versão",
"sourceCode": "Código-fonte"
"sourceCode": "Código-fonte",
"getApp": "Baixar o app"
},
"auth": {
"join": "Entrar",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "Seu conteúdo. Seu estilo. Suas regras.",
"getApp": {
"eyebrow": "Baixar o app",
"title": "{{appName}} para Android",
"subtitle": "A experiência completa do {{appName}}",
"download": "Baixar"
},
"compose": {
"placeholder": "O que está acontecendo?"
},
@@ -257,11 +264,20 @@
},
"placeholder": {
"writeComment": "Escrever um comentário...",
"writeReply": "Escrever uma resposta...",
"addComment": "Adicionar um comentário...",
"whatsHappening": "O que está acontecendo?"
},
"blueskyDisclaimer": "As pessoas no Bluesky não podem te ver porque eles não são realmente descentralizados."
},
"postDetail": {
"repliesHeading": "Respostas",
"commentsHeading": "Comentários",
"replyCount_one": "resposta",
"replyCount_other": "respostas",
"commentCount_one": "comentário",
"commentCount_other": "comentários"
},
"noteCard": {
"botAccount": "Conta bot",
"showLess": "Mostrar menos",
@@ -940,20 +956,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.\nSem intermediários.",
"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 +1007,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 +1031,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 +1047,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 +1058,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 +1100,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 +1200,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 +1270,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 +1691,7 @@
"walletSend": {
"title": "Enviar Bitcoin",
"send": "Enviar Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Toque novamente para confirmar",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "Bitcoin insuficiente",
+33 -20
View File
@@ -64,7 +64,8 @@
"privacy": "Конфиденциальность",
"safety": "Безопасность",
"changelog": "История изменений",
"sourceCode": "Исходный код"
"sourceCode": "Исходный код",
"getApp": "Скачать приложение"
},
"auth": {
"join": "Присоединиться",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "Ваш контент. Ваша атмосфера. Ваши правила.",
"getApp": {
"eyebrow": "Скачать приложение",
"title": "{{appName}} для Android",
"subtitle": "Полный опыт {{appName}}",
"download": "Скачать"
},
"compose": {
"placeholder": "Что происходит?"
},
@@ -257,11 +264,20 @@
},
"placeholder": {
"writeComment": "Написать комментарий...",
"writeReply": "Написать ответ...",
"addComment": "Добавить комментарий...",
"whatsHappening": "Что происходит?"
},
"blueskyDisclaimer": "Люди в Bluesky не могут вас видеть, потому что они на самом деле не децентрализованы."
},
"postDetail": {
"repliesHeading": "Ответы",
"commentsHeading": "Комментарии",
"replyCount_one": "ответ",
"replyCount_other": "ответов",
"commentCount_one": "комментарий",
"commentCount_other": "комментариев"
},
"noteCard": {
"botAccount": "Бот-аккаунт",
"showLess": "Свернуть",
@@ -940,19 +956,20 @@
"wallet": "Bitcoin-кошелёк",
"myWalletLabel": "Кошелёк {{name}}",
"myWalletDefault": "Мой кошелёк",
"walletHeroNote": "Пожертвования поступают напрямую в ваш собственный кошелёк Agora.\nБез посредников.",
"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 +1005,6 @@
"goal": "Цель",
"goalPlaceholder": "25 000",
"goalNote": "Целые доллары США. Жертвователи платят в Bitcoin; клиенты оценивают эквивалент в USD во время просмотра.",
"deadline": "Срок",
"submitCreate": "Запустить кампанию",
"submitEdit": "Обновить кампанию",
"publishing": "Публикация…",
@@ -1013,8 +1029,6 @@
"errorSpInvalid": "Код тихого платежа не является распознанным кодом BIP-352 (sp1…).",
"errorWalletRequired": "Укажите хотя бы один эндпойнт кошелька — Bitcoin-адрес mainnet (bc1q… / bc1p…) или код тихого платежа (sp1…).",
"errorGoalInvalid": "Цель должна быть положительной целой суммой в долларах.",
"errorDeadlinePast": "Срок не может быть в прошлом.",
"errorDeadlineInvalid": "Срок не является действительной датой.",
"errorEditLatestMissing": "Не удалось найти последнюю версию этой кампании для обновления.",
"errorSlugCollision": "У вас уже есть кампания с идентификатором «{{slug}}». Выберите другой.",
"errorBannerInvalid": "Баннер должен быть валидной URL https://.",
@@ -1031,6 +1045,8 @@
"bannerStepSubtitle": "Одно яркое изображение представит кампанию на каждой карточке.",
"storyStepTitle": "Расскажите свою историю",
"storyStepSubtitle": "Кому это поможет и как будут использованы средства.",
"goalStepTitle": "Цель",
"goalStepSubtitle": "Необязательно — оставьте пустым для бессрочной кампании.",
"next": "Далее",
"back": "Назад",
"skip": "Пропустить",
@@ -1040,11 +1056,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | Сборы средств {{appName}}",
"seoDescriptionFallback": "Поддержите {{title}} на {{appName}}.",
"deadlineEndedOn": "Завершено {{date}}",
"deadlineEndsToday": "Завершается сегодня",
"deadlineDaysLeft_one": "Остался {{count}} день",
"deadlineDaysLeft_other": "Осталось {{count}} дней",
"deadlineEndsOn": "Завершается {{date}}",
"back": "Назад",
"edit": "Изменить",
"delete": "Удалить",
@@ -1087,7 +1098,6 @@
"deleteDialogTitle": "Удалить эту кампанию?",
"deleteDialogBody": "Это публикует запрос на удаление NIP-09. Корректно работающие реле уберут кампанию из лент и прямых ссылок. Прошлые квитанции о пожертвованиях остаются в блокчейне независимо. Это действие нельзя отменить — чтобы продолжать принимать пожертвования, отредактируйте кампанию.",
"storyHeading": "История",
"campaignEnded": "Кампания завершена",
"donate": "Пожертвовать",
"share": "Поделиться",
"target": "Цель: {{amount}}",
@@ -1188,7 +1198,7 @@
"wlcDesc": "Кампании, отобранные World Liberty Congress.",
"allCampaigns": "Все кампании",
"allCampaignsDesc": "Все кампании в сети, в хронологическом порядке.",
"browseAll": "Просмотреть все кампании",
"browseAll": "Просмотреть все кампании",
"hidden": "Скрытые",
"hiddenDesc": "Кампании, скрытые с публичной главной страницы. Используйте меню с тремя точками на карточке, чтобы вернуть.",
"hiddenEmpty": "В настоящее время нет скрытых кампаний.",
@@ -1258,6 +1268,8 @@
"lists": {
"stripAria": "Кураторские тематические списки кампаний",
"create": "Новый список",
"showMore": "Показать ещё {{count}}",
"showLess": "Скрыть",
"createDesc": "Создайте новый тематический список. Добавляйте в него кампании с любой страницы кампании.",
"createSubmit": "Создать список",
"createFailed": "Не удалось создать список",
@@ -1677,6 +1689,7 @@
"walletSend": {
"title": "Отправить Bitcoin",
"send": "Отправить Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Нажмите ещё раз для подтверждения",
"satPerVB": "{{rate}} сат/vB",
"notEnoughBitcoin": "Недостаточно биткоинов",
+36 -21
View File
@@ -64,7 +64,8 @@
"privacy": "Akavanzika",
"safety": "Kuchengetedzeka",
"changelog": "Rondedzero yeshanduko",
"sourceCode": "Kodhi yetsime"
"sourceCode": "Kodhi yetsime",
"getApp": "Tora app"
},
"auth": {
"join": "Joinha",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "Zviri zvako. Mafambisirwo ako. Mitemo yako.",
"getApp": {
"eyebrow": "Tora app",
"title": "{{appName}} yeAndroid",
"subtitle": "Ruzivo rwakazara rwe{{appName}}",
"download": "Dhaunirodha"
},
"compose": {
"placeholder": "Chii chiri kuitika?"
},
@@ -508,20 +515,23 @@
"wallet": "Chikwama cheBitcoin",
"myWalletLabel": "Chikwama cha{{name}}",
"myWalletDefault": "Chikwama changu",
"walletHeroNote": "Zvipo zvinopinda zvakananga muchikwama chako cheAgora.\nHapana munhu ari pakati.",
"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 +566,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 +590,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 +606,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 +617,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 +659,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 +759,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 +829,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 +1249,7 @@
"walletSend": {
"title": "Tumira Bitcoin",
"send": "Tumira Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Dzvanya zvakare kuti usimbise",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "Bitcoin haina kukwana",
@@ -1967,11 +1973,20 @@
},
"placeholder": {
"writeComment": "Nyora tsinhiro...",
"writeReply": "Nyora mhinduro...",
"addComment": "Wedzera tsinhiro...",
"whatsHappening": "Chii chiri kuitika?"
},
"blueskyDisclaimer": "Vanhu paBluesky havakukuoni nokuti chaizvoizvo havasi vakapararira."
},
"postDetail": {
"repliesHeading": "Mhinduro",
"commentsHeading": "Tsinhiro",
"replyCount_one": "mhinduro",
"replyCount_other": "mhinduro",
"commentCount_one": "tsinhiro",
"commentCount_other": "tsinhiro"
},
"noteCard": {
"botAccount": "Akaundi yebhoti",
"showLess": "Ratidza zvishoma",
+63 -22
View File
@@ -63,7 +63,8 @@
"privacy": "Faragha",
"safety": "Usalama",
"changelog": "Kumbukumbu ya mabadiliko",
"sourceCode": "Msimbo wa chanzo"
"sourceCode": "Msimbo wa chanzo",
"getApp": "Pata programu"
},
"auth": {
"join": "Jiunge",
@@ -131,6 +132,12 @@
},
"feed": {
"indexTagline": "Maudhui yako. Mtindo wako. Sheria zako.",
"getApp": {
"eyebrow": "Pata programu",
"title": "{{appName}} kwa Android",
"subtitle": "Uzoefu kamili wa {{appName}}",
"download": "Pakua"
},
"compose": {
"placeholder": "Kuna nini?"
},
@@ -256,11 +263,20 @@
},
"placeholder": {
"writeComment": "Andika maoni...",
"writeReply": "Andika jibu...",
"addComment": "Ongeza maoni...",
"whatsHappening": "Kuna nini?"
},
"blueskyDisclaimer": "Watu wa Bluesky hawawezi kukuona kwa sababu si wa kweli waliogawanywa."
},
"postDetail": {
"repliesHeading": "Majibu",
"commentsHeading": "Maoni",
"replyCount_one": "jibu",
"replyCount_other": "majibu",
"commentCount_one": "maoni",
"commentCount_other": "maoni"
},
"noteCard": {
"botAccount": "Akaunti ya bot",
"showLess": "Onyesha kidogo",
@@ -939,20 +955,23 @@
"wallet": "Pochi ya Bitcoin",
"myWalletLabel": "Pochi ya {{name}}",
"myWalletDefault": "Pochi yangu",
"walletHeroNote": "Michango huingia moja kwa moja kwenye pochi yako mwenyewe ya Agora.\nHakuna mtu wa kati.",
"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 +1006,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 +1030,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 +1055,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 +1097,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 +1197,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.",
@@ -1198,7 +1208,35 @@
"searchPlaceholder": "Tafuta kampeni…",
"searchAriaLabel": "Tafuta kampeni",
"noMatch": "Hakuna kampeni zinazolingana na “{{query}}”",
"noMatchHint": "Jaribu neno tofauti la utafutaji, au futa utafutaji."
"noMatchHint": "Jaribu neno tofauti la utafutaji, au futa utafutaji.",
"whyDifferent": {
"eyebrow": "Kwa nini {{appName}}",
"title": "Imejengwa tofauti.",
"lede": "Bitcoin moja kwa moja kutoka kwa mfadhili hadi mwanaharakati. Hakuna jukwaa katikati, hakuna mtunzaji anayeshikilia fedha, hakuna ruhusa inayohitajika.",
"block1": {
"heading": "Tofauti na GoFundMe",
"body": "Hakuna jukwaa linaloweza kufungia michango yako, kudai marejesho, au kusitisha kampeni yako kwa sababu ya tofauti za sera. Hakuna Stripe, Visa, au benki iliyo katikati inayoweza kukukata wakati kampeni inaendelea.",
"bullet1": "Haiwezi kufungiwa — hakuna kura ya turufu ya jukwaa",
"bullet2": "Hakuna mchakatishaji malipo anayeweza kukata huduma",
"bullet3": "Ada za jukwaa ni sifuri"
},
"block2": {
"heading": "Tofauti na majukwaa mengine ya Bitcoin",
"body": "Hakuna node kuu ya Lightning, mtunzaji, au LSP inayoweza kushindwa au kwenda nje ya mtandao. Fedha hukamilika moja kwa moja kwenye Bitcoin hadi pochi unayodhibiti. Ikiwa {{appName}} ingetoweka kesho, kila kampeni ingeendelea kufanya kazi.",
"bullet1": "Hakuna pochi ya mtunzaji ya kufilisiwa au kufungiwa",
"bullet2": "Hukamilika on-chain kwenye pochi yako mwenyewe",
"bullet3": "Hufanya kazi hata {{appName}} ikitoweka"
},
"block3": {
"heading": "Hadharani au kwa faragha. Chaguo ni lako.",
"body": "Wanaharakati huchagua njia ya kupokea inayolingana na hatari wanazokabiliana nazo. Wafadhili huona QR moja; pochi huchagua itifaki sahihi.",
"publicLabel": "Hadharani",
"publicSummary": "Hufanya kazi kwenye kila pochi ya Bitcoin. Haraka na inaweza kuthibitishwa on-chain.",
"privateLabel": "Faragha",
"privateSummary": "Malipo ya kimya ya BIP-352. Michango hufika kwenye outputs zisizoweza kuunganishwa."
},
"readMore": "Soma uchambuzi kamili"
}
},
"all": {
"title": "Kampeni",
@@ -1229,6 +1267,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 +1584,7 @@
"walletSend": {
"title": "Tuma Bitcoin",
"send": "Tuma Bitcoin",
"max": "MAX",
"tapAgainToConfirm": "Bonyeza tena kuthibitisha",
"satPerVB": "sat/vB {{rate}}",
"notEnoughBitcoin": "Bitcoin haitoshi",
+65 -22
View File
@@ -63,7 +63,8 @@
"privacy": "Gizlilik",
"safety": "Güvenlik",
"changelog": "Sürüm notları",
"sourceCode": "Kaynak kod"
"sourceCode": "Kaynak kod",
"getApp": "Uygulamayı edinin"
},
"auth": {
"join": "Katıl",
@@ -131,6 +132,12 @@
},
"feed": {
"indexTagline": "İçeriğin senin. Tarzın senin. Kuralların senin.",
"getApp": {
"eyebrow": "Uygulamayı edinin",
"title": "Android için {{appName}}",
"subtitle": "Tüm {{appName}} deneyimi",
"download": "İndir"
},
"compose": {
"placeholder": "Neler oluyor?"
},
@@ -256,11 +263,20 @@
},
"placeholder": {
"writeComment": "Yorum yaz...",
"writeReply": "Yanıt yaz...",
"addComment": "Yorum ekle...",
"whatsHappening": "Neler oluyor?"
},
"blueskyDisclaimer": "Bluesky'daki insanlar sizi göremez çünkü aslında merkeziyetsiz değiller."
},
"postDetail": {
"repliesHeading": "Yanıtlar",
"commentsHeading": "Yorumlar",
"replyCount_one": "yanıt",
"replyCount_other": "yanıt",
"commentCount_one": "yorum",
"commentCount_other": "yorum"
},
"noteCard": {
"botAccount": "Bot hesabı",
"showLess": "Daha az göster",
@@ -939,20 +955,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.\nAracı 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 +1006,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 +1030,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 +1046,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 +1057,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 +1099,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 +1199,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.",
@@ -1198,7 +1210,35 @@
"searchPlaceholder": "Kampanya ara…",
"searchAriaLabel": "Kampanyaları ara",
"noMatch": "“{{query}}” ile eşleşen kampanya yok",
"noMatchHint": "Farklı bir arama terimi deneyin ya da aramayı temizleyin."
"noMatchHint": "Farklı bir arama terimi deneyin ya da aramayı temizleyin.",
"whyDifferent": {
"eyebrow": "Neden {{appName}}",
"title": "Farklı inşa edildi.",
"lede": "Bağışçıdan aktiviste doğrudan Bitcoin. Arada platform yok, parayı tutan emanetçi yok, izin gerekmiyor.",
"block1": {
"heading": "GoFundMe'den farklı",
"body": "Hiçbir platform bağışlarınızı donduramaz, iade talep edemez veya politika anlaşmazlıkları nedeniyle kampanyanızı sonlandıramaz. Arada Stripe, Visa veya banka yok; kampanya ortasında sizi kesemezler.",
"bullet1": "Dondurmaya dayanıklı — platform vetosu yok",
"bullet2": "Hiçbir ödeme işlemcisi fişi çekemez",
"bullet3": "Sıfır platform ücreti"
},
"block2": {
"heading": "Diğer Bitcoin platformlarından farklı",
"body": "Arızalanacak veya çevrimdışı kalacak merkezi Lightning node'u, emanetçi ya da LSP yok. Fonlar doğrudan Bitcoin üzerinde kontrol ettiğiniz cüzdana aktarılır. {{appName}} yarın ortadan kaybolsa bile her kampanya çalışmaya devam eder.",
"bullet1": "Boşaltılacak veya dondurulacak emanet cüzdan yok",
"bullet2": "Size ait cüzdana on-chain aktarım",
"bullet3": "{{appName}} yok olsa bile çalışır"
},
"block3": {
"heading": "Herkese açık veya özel. Seçim sizin.",
"body": "Aktivistler tehdit modellerine uygun alım seçeneğini seçer. Bağışçılar tek bir QR görür; cüzdan doğru protokolü seçer.",
"publicLabel": "Herkese açık",
"publicSummary": "Her Bitcoin cüzdanında çalışır. Hızlıdır ve on-chain doğrulanabilir.",
"privateLabel": "Özel",
"privateSummary": "BIP-352 silent payments. Bağışlar ilişkilendirilemeyen çıktılara ulaşır."
},
"readMore": "Tam dökümü oku"
}
},
"all": {
"title": "Kampanyalar",
@@ -1229,6 +1269,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 +1626,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",
+65 -22
View File
@@ -64,7 +64,8 @@
"privacy": "隱私",
"safety": "安全",
"changelog": "更新日誌",
"sourceCode": "原始碼"
"sourceCode": "原始碼",
"getApp": "取得應用程式"
},
"auth": {
"join": "加入",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "你的內容。你的風格。你的規則。",
"getApp": {
"eyebrow": "取得應用程式",
"title": "適用於 Android 的 {{appName}}",
"subtitle": "完整的 {{appName}} 體驗",
"download": "下載"
},
"compose": {
"placeholder": "有什麼新鮮事?"
},
@@ -508,20 +515,23 @@
"wallet": "比特幣錢包",
"myWalletLabel": "{{name}} 的錢包",
"myWalletDefault": "我的錢包",
"walletHeroNote": "捐款會直接進入您自己的 Agora 錢包。\n沒有中間人。",
"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 +566,6 @@
"goal": "目標",
"goalPlaceholder": "25,000",
"goalNote": "整數美元。捐贈者以比特幣支付;客戶端在檢視時估算美元等值。",
"deadline": "截止日期",
"submitCreate": "發起活動",
"submitEdit": "更新活動",
"publishing": "釋出中……",
@@ -581,8 +590,6 @@
"errorSpInvalid": "靜默支付代碼不是已識別的 BIP-352 程式碼(sp1…)。",
"errorWalletRequired": "至少提供一個錢包端點 — 一個比特幣主網地址(bc1q… / bc1p…)或一個靜默支付代碼(sp1…)。",
"errorGoalInvalid": "目標必須是正整數美元金額。",
"errorDeadlinePast": "截止日期不能在過去。",
"errorDeadlineInvalid": "截止日期不是有效的日期。",
"errorEditLatestMissing": "找不到此活動的最新版本以更新。",
"errorSlugCollision": "你已有一個識別符號為「{{slug}}」的活動。請選擇其他。",
"errorBannerInvalid": "橫幅必須是有效的 https:// URL。",
@@ -599,6 +606,8 @@
"bannerStepSubtitle": "一張搶眼的圖片,讓你的活動在每張卡片上脫穎而出。",
"storyStepTitle": "說說你的故事",
"storyStepSubtitle": "誰是受益者,以及這些資金將如何運用。",
"goalStepTitle": "目標",
"goalStepSubtitle": "選填——留空即為不設期限的活動。",
"next": "下一步",
"back": "返回",
"skip": "跳過",
@@ -608,11 +617,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} 募款",
"seoDescriptionFallback": "在 {{appName}} 上支持 {{title}}。",
"deadlineEndedOn": "{{date}} 已結束",
"deadlineEndsToday": "今天截止",
"deadlineDaysLeft_one": "還剩 {{count}} 天",
"deadlineDaysLeft_other": "還剩 {{count}} 天",
"deadlineEndsOn": "{{date}} 截止",
"back": "返回",
"edit": "編輯",
"delete": "刪除",
@@ -655,7 +659,6 @@
"deleteDialogTitle": "刪除此活動?",
"deleteDialogBody": "這將釋出一個 NIP-09 刪除請求。行為良好的中繼會將活動從資訊流和直接連結中移除。過往的捐贈收據無論如何都會保留在鏈上。此操作無法撤銷 — 若要繼續接受捐贈,請改為編輯活動。",
"storyHeading": "故事",
"campaignEnded": "活動已結束",
"donate": "捐贈",
"share": "分享",
"target": "目標:{{amount}}",
@@ -756,7 +759,7 @@
"wlcDesc": "由世界自由大會(World Liberty Congress)精選的活動。",
"allCampaigns": "所有活動",
"allCampaignsDesc": "網絡上的所有活動,按時間順序排列。",
"browseAll": "瀏覽所有活動",
"browseAll": "瀏覽所有活動",
"hidden": "已隱藏",
"hiddenDesc": "已從公共首頁隱藏的活動。使用卡片上的選單可以取消隱藏。",
"hiddenEmpty": "當前沒有被隱藏的活動。",
@@ -767,7 +770,35 @@
"searchPlaceholder": "搜尋活動…",
"searchAriaLabel": "搜尋活動",
"noMatch": "沒有活動符合「{{query}}」",
"noMatchHint": "請嘗試不同的搜尋字詞,或清除搜尋。"
"noMatchHint": "請嘗試不同的搜尋字詞,或清除搜尋。",
"whyDifferent": {
"eyebrow": "為什麼選擇 {{appName}}",
"title": "生來不同。",
"lede": "比特幣從捐款者直接到活動人士。沒有平台擋在中間,沒有託管方扣住資金,也不需要任何人的許可。",
"block1": {
"heading": "不同於 GoFundMe",
"body": "沒有平台可以凍結你的捐款、要求退款,或因政策分歧終止你的活動。沒有 Stripe、Visa 或銀行卡在中間,在活動進行到一半時切斷你的資金。",
"bullet1": "抗凍結 — 沒有平台否決權",
"bullet2": "沒有支付處理商可以拔掉插頭",
"bullet3": "零平台費"
},
"block2": {
"heading": "不同於其他「Bitcoin」平台",
"body": "沒有可能故障或離線的中心化 Lightning node、託管方或 LSP。資金直接在比特幣上結算到你控制的錢包。即使 {{appName}} 明天消失,每個活動仍會繼續運作。",
"bullet1": "沒有可被清空或凍結的託管錢包",
"bullet2": "在鏈上結算到你擁有的錢包",
"bullet3": "即使 {{appName}} 消失也能運作"
},
"block3": {
"heading": "公開或私密。由你選擇。",
"body": "活動人士可選擇符合自身威脅模型的收款方式。捐款者只會看到一個 QR;錢包會選擇正確的協議。",
"publicLabel": "公開",
"publicSummary": "適用於所有比特幣錢包。快速,且可在鏈上驗證。",
"privateLabel": "私密",
"privateSummary": "BIP-352 silent payments。捐款會落到無法關聯的 outputs。"
},
"readMore": "閱讀完整說明"
}
},
"all": {
"title": "活動",
@@ -798,6 +829,8 @@
"lists": {
"stripAria": "精選活動主題清單",
"create": "新清單",
"showMore": "再顯示 {{count}} 個",
"showLess": "顯示較少",
"createDesc": "建立一個新的主題清單。可從任何活動頁面將活動加入其中。",
"createSubmit": "建立清單",
"createFailed": "無法建立清單",
@@ -1152,6 +1185,7 @@
"walletSend": {
"title": "傳送比特幣",
"send": "傳送比特幣",
"max": "MAX",
"tapAgainToConfirm": "再次點選確認",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "比特幣不足",
@@ -1875,11 +1909,20 @@
},
"placeholder": {
"writeComment": "寫一條評論...",
"writeReply": "撰寫回覆...",
"addComment": "新增評論...",
"whatsHappening": "有什麼新鮮事?"
},
"blueskyDisclaimer": "Bluesky 上的人看不到你,因為它們實際上不是去中心化的。"
},
"postDetail": {
"repliesHeading": "回覆",
"commentsHeading": "評論",
"replyCount_one": "則回覆",
"replyCount_other": "則回覆",
"commentCount_one": "則評論",
"commentCount_other": "則評論"
},
"noteCard": {
"botAccount": "機器人賬號",
"showLess": "顯示較少",
+36 -21
View File
@@ -64,7 +64,8 @@
"privacy": "隐私",
"safety": "安全",
"changelog": "更新日志",
"sourceCode": "源代码"
"sourceCode": "源代码",
"getApp": "获取应用"
},
"auth": {
"join": "加入",
@@ -132,6 +133,12 @@
},
"feed": {
"indexTagline": "你的内容。你的风格。你的规则。",
"getApp": {
"eyebrow": "获取应用",
"title": "适用于 Android 的 {{appName}}",
"subtitle": "完整的 {{appName}} 体验",
"download": "下载"
},
"compose": {
"placeholder": "有什么新鲜事?"
},
@@ -508,20 +515,23 @@
"wallet": "比特币钱包",
"myWalletLabel": "{{name}} 的钱包",
"myWalletDefault": "我的钱包",
"walletHeroNote": "捐款将直接进入你自己的 Agora 钱包。\n没有中间人。",
"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 +566,6 @@
"goal": "目标",
"goalPlaceholder": "25,000",
"goalNote": "整数美元。捐赠者以比特币支付;客户端在查看时估算美元等值。",
"deadline": "截止日期",
"submitCreate": "发起活动",
"submitEdit": "更新活动",
"publishing": "发布中……",
@@ -581,8 +590,6 @@
"errorSpInvalid": "静默支付代码不是已识别的 BIP-352 代码(sp1…)。",
"errorWalletRequired": "至少提供一个钱包端点 — 一个比特币主网地址(bc1q… / bc1p…)或一个静默支付代码(sp1…)。",
"errorGoalInvalid": "目标必须是正整数美元金额。",
"errorDeadlinePast": "截止日期不能在过去。",
"errorDeadlineInvalid": "截止日期不是有效的日期。",
"errorEditLatestMissing": "找不到此活动的最新版本以更新。",
"errorSlugCollision": "你已有一个标识符为「{{slug}}」的活动。请选择其他。",
"errorBannerInvalid": "横幅必须是有效的 https:// URL。",
@@ -599,6 +606,8 @@
"bannerStepSubtitle": "一张醒目的图片,让活动在每张卡片上脱颖而出。",
"storyStepTitle": "讲述你的故事",
"storyStepSubtitle": "谁将从中受益,资金将如何使用。",
"goalStepTitle": "目标",
"goalStepSubtitle": "可选 — 留空则为开放式活动。",
"next": "下一步",
"back": "返回",
"skip": "跳过",
@@ -608,11 +617,6 @@
"campaignsDetail": {
"seoTitle": "{{title}} | {{appName}} 募款",
"seoDescriptionFallback": "在 {{appName}} 上支持 {{title}}。",
"deadlineEndedOn": "{{date}} 已结束",
"deadlineEndsToday": "今天截止",
"deadlineDaysLeft_one": "还剩 {{count}} 天",
"deadlineDaysLeft_other": "还剩 {{count}} 天",
"deadlineEndsOn": "{{date}} 截止",
"back": "返回",
"edit": "编辑",
"delete": "删除",
@@ -655,7 +659,6 @@
"deleteDialogTitle": "删除此活动?",
"deleteDialogBody": "这将发布一个 NIP-09 删除请求。行为良好的中继会将活动从信息流和直接链接中移除。过往的捐赠收据无论如何都会保留在链上。此操作无法撤销 — 若要继续接受捐赠,请改为编辑活动。",
"storyHeading": "故事",
"campaignEnded": "活动已结束",
"donate": "捐赠",
"share": "分享",
"target": "目标:{{amount}}",
@@ -756,7 +759,7 @@
"wlcDesc": "由世界自由大会(World Liberty Congress)精选的活动。",
"allCampaigns": "所有活动",
"allCampaignsDesc": "网络上的所有活动,按时间顺序排列。",
"browseAll": "浏览所有活动",
"browseAll": "浏览所有活动",
"hidden": "已隐藏",
"hiddenDesc": "已从公共首页隐藏的活动。使用卡片上的菜单可以取消隐藏。",
"hiddenEmpty": "当前没有被隐藏的活动。",
@@ -826,6 +829,8 @@
"lists": {
"stripAria": "精选活动主题列表",
"create": "新建列表",
"showMore": "再显示 {{count}} 个",
"showLess": "显示较少",
"createDesc": "创建一个新的主题列表。可以从任意活动页面将活动收录其中。",
"createSubmit": "创建列表",
"createFailed": "创建列表失败",
@@ -1244,6 +1249,7 @@
"walletSend": {
"title": "发送比特币",
"send": "发送比特币",
"max": "MAX",
"tapAgainToConfirm": "再次点击确认",
"satPerVB": "{{rate}} sat/vB",
"notEnoughBitcoin": "比特币不足",
@@ -1967,11 +1973,20 @@
},
"placeholder": {
"writeComment": "写一条评论...",
"writeReply": "写一条回复...",
"addComment": "添加评论...",
"whatsHappening": "有什么新鲜事?"
},
"blueskyDisclaimer": "Bluesky 上的人看不到你,因为它们实际上不是去中心化的。"
},
"postDetail": {
"repliesHeading": "回复",
"commentsHeading": "评论",
"replyCount_one": "条回复",
"replyCount_other": "条回复",
"commentCount_one": "条评论",
"commentCount_other": "条评论"
},
"noteCard": {
"botAccount": "机器人账号",
"showLess": "显示较少",
+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
+29 -29
View File
@@ -19,11 +19,10 @@ import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { CampaignListsStrip } from '@/components/campaign-lists/CampaignListsStrip';
import { HeroLightningMap } from '@/components/HeroLightningMap';
import { StartCampaignLink } from '@/components/StartCampaignLink';
import { AppDownloadNudge } from '@/components/AppDownloadNudge';
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';
import { cn } from '@/lib/utils';
@@ -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`).
@@ -93,7 +92,6 @@ const WLC_NPUB = 'npub126e6hwd6a5std2upv9a22xwgvd8fyrhsx5wjjchv99g6nv3n4vhs5fr9g
export function CampaignsPage() {
const { t } = useTranslation();
const { config } = useAppContext();
const { user } = useCurrentUser();
const wlcAuthor = useAuthor(WLC_PUBKEY);
const wlcName = wlcAuthor.data?.metadata?.display_name
@@ -127,23 +125,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}`,
@@ -161,8 +155,8 @@ export function CampaignsPage() {
(heroLoading && cappedCoords.length > 0);
return (
<main className="min-h-screen pb-16">
<Hero loggedIn={!!user} />
<main className="min-h-screen">
<Hero />
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="campaigns">
{showWlcSection && (
@@ -218,18 +212,26 @@ export function CampaignsPage() {
<section className="space-y-5">
<CampaignListsStrip />
<div className="pt-2 flex justify-center sm:justify-start">
<div className="pt-2 flex flex-col sm:flex-row gap-3 items-center justify-center sm:justify-start">
<Button asChild size="lg" variant="outline" className="rounded-full">
<Link to="/campaigns">
{t('campaigns.home.browseAll')}
<ArrowRight className="ml-2 size-4 rtl:rotate-180" />
</Link>
</Button>
<Button asChild size="lg" className="rounded-full">
<StartCampaignLink>
<PlusCircle className="mr-2 size-4" />
{t('campaigns.home.startCampaign')}
</StartCampaignLink>
</Button>
</div>
</section>
</div>
<WhyDifferentSection />
<AppDownloadNudge className="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto pt-8 pb-0" />
</main>
);
}
@@ -238,7 +240,7 @@ export function CampaignsPage() {
// Hero
// ═══════════════════════════════════════════════════════════════════════════════
function Hero({ loggedIn }: { loggedIn: boolean }) {
function Hero() {
const { t } = useTranslation();
return (
@@ -337,16 +339,14 @@ function Hero({ loggedIn }: { loggedIn: boolean }) {
<ArrowRight className="ml-2 rtl:rotate-180" />
</Link>
</Button>
{!loggedIn && (
<Button
variant="outline"
size="lg"
asChild
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50"
>
<a href="#campaigns">{t('campaigns.home.exploreCampaigns')}</a>
</Button>
)}
<Button
variant="outline"
size="lg"
asChild
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50"
>
<a href="#campaigns">{t('campaigns.home.exploreCampaigns')}</a>
</Button>
</div>
</div>
</div>
@@ -508,7 +508,7 @@ function WhyDifferentSection() {
return (
<section
aria-labelledby="why-different-title"
className="relative bg-background py-20 md:py-28 overflow-hidden"
className="relative bg-background pt-20 pb-12 md:pt-28 md:pb-16 overflow-hidden"
>
{/* Decorative spine: a soft vertical brand-orange line on
the far left, evoking the manifesto / editorial feel.
+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="whitespace-pre-line 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
+2 -2
View File
@@ -3,7 +3,7 @@ import { Check, Languages } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '@/components/PageHeader';
import { useAppContext } from '@/hooks/useAppContext';
import i18n, { SUPPORTED_LANGUAGES, isRTLLanguage } from '@/i18n';
import { SUPPORTED_LANGUAGES, changeAppLanguage, isRTLLanguage } from '@/i18n';
import { cn } from '@/lib/utils';
export function LanguageSettingsPage() {
@@ -42,7 +42,7 @@ export function LanguageSettingsPage() {
const handleSelect = (code: string) => {
if (code === currentLng) return;
void i18n.changeLanguage(code);
void changeAppLanguage(code);
};
return (
+6 -6
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={() =>
@@ -2079,14 +2079,14 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<div className="mt-6 pb-16 sidebar:pb-0">
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">
{isKind1 ? 'Replies' : 'Comments'}
{isKind1 ? t('postDetail.repliesHeading') : t('postDetail.commentsHeading')}
</h2>
{replyTree.length > 0 ? (
<span className="text-sm text-muted-foreground tabular-nums">
{formatNumber(replyTree.length)}{' '}
{replyTree.length === 1
? isKind1 ? 'reply' : 'comment'
: isKind1 ? 'replies' : 'comments'}
{isKind1
? t('postDetail.replyCount', { count: replyTree.length })
: t('postDetail.commentCount', { count: replyTree.length })}
</span>
) : null}
</div>
@@ -2094,7 +2094,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<DetailCommentComposer
event={event}
className="mb-3"
placeholder={isKind1 ? 'Write a reply...' : t('compose.activityPlaceholder')}
placeholder={isKind1 ? t('replyModal.placeholder.writeReply') : t('replyModal.placeholder.writeComment')}
/>
{repliesLoading ? (
+11 -1
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
@@ -171,7 +180,7 @@ export function WalletPage() {
>
<span className="flex items-center gap-2 text-primary group-hover:opacity-80 transition-opacity">
<span
className="font-display font-normal tracking-wide leading-none uppercase text-5xl inline-block tabular-nums"
className="latin-display font-display font-normal tracking-wide leading-none uppercase text-5xl inline-block tabular-nums"
style={{
WebkitTextStroke: '0.022em currentColor',
transform: 'skewX(-6deg) scaleX(1.1)',
@@ -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" />
+10 -20
View File
@@ -4,16 +4,7 @@ name: Agora
summary: Power to the people.
description: |
Agora is an open-source Nostr client built from the Ditto codebase, focused on
customization, creativity, and open social interoperability on Nostr.
Features:
- Theming — 9 built-in presets, shareable themes
- Infinite content types — notes, articles, short videos (Vines), live streams,
polls, follow packs, color moments, magic decks, geocaching, Webxdc mini-apps
- Lightning payments — zap posts and profiles via NWC or WebLN
- Comments on anything — posts, URLs, profiles, hashtags, books, and more
description: Agora is a censorship-resistant donation platform built on Nostr and Bitcoin. No frozen bank accounts. No corporate shut-downs. Just direct support from people who believe in your cause.
tags:
- nostr
@@ -29,14 +20,15 @@ website: https://agora.spot
icon: ./public/logo.png
images:
- https://blossom.ditto.pub/47fcb3bdc414ced245fbba53b0456d9bfdf112d711ecf5cc628361a47002392a.png
- https://blossom.ditto.pub/e387a4c4566545c650477cee66e638131eb874a90761cf0a371e5abf1e2c7af2.png
- https://blossom.ditto.pub/73585c9547868da215a91f8a13543251a20967a74f6e8329231544add50e3dee.png
- https://blossom.ditto.pub/4216d18d5854444c64b482dbbac9e077a453f85a9d72d91bc6adf89fd1f42c36.png
- https://blossom.ditto.pub/a7521e652d625c57cf66fd97ea92c66b8559bcedcb805aaf375bc047415625a3.png
- https://blossom.ditto.pub/daf008f146391f0172b89595500f640b84eac4e146b2e081db80791819443fa0.png
- https://blossom.ditto.pub/22c144a792e32559a838c8f69fbb4ae22264f47ff3d04341e656748d264064bc.png
- https://blossom.ditto.pub/fa609e4a03e984063fbc6c474bd8a73bb088666b7c0e5561f993264f372c90ef.png
- https://blossom.soapbox.pub/91b95e5acb7a199db8dacf8fdaa9b5c3bf46e0c7658c4034eb57657fdd52120d.png
- https://blossom.soapbox.pub/49c5eb12fc9c06c4b379dfa8942f6db8719c53ce75f9008a390c1b47d94b417e.png
- https://blossom.soapbox.pub/cad4bdb439c210fcb7afc9ae945c6ebe6bc24902c6aabf0d8bc8069d6f6d28c4.png
- https://blossom.soapbox.pub/d84536fa1e8aeb73fda74bef6dde2c695b75cb0ff172ce3b11b489d2c27dec5d.png
- https://blossom.soapbox.pub/92460118d03db475e5993fe7980a67f5df0b812daa85326c6386eac74124dcd5.png
- https://blossom.soapbox.pub/ffc7a4739b957c471e79631e69e7abf2f19738e3b2485953a34925858590f2f4.png
- https://blossom.soapbox.pub/d38edf1f02230d353ff872ed238d85c6d5aa98f2b3e4bdf9d0dc30d33c7fcb57.png
- https://blossom.soapbox.pub/559e82282f7ad29f2676e02d33e0bb462bf48101878be26910397e6a6513c8e3.png
- https://blossom.soapbox.pub/3efbb0b3b65e932217a784cd7e2e3c01479db50864d183d449571a9911625b10.png
release_notes: ./CHANGELOG.md
@@ -88,5 +80,3 @@ supported_nips:
- "94" # File metadata (kind 1063)
- "98" # HTTP authentication (kind 27235, used for AI API calls)
- "A0" # Voice messages (kind 1222 root, kind 1244 reply)
- "GC" # Geocaching (kind 37516 cache listings, kind 7516 found logs)
- "MG" # Magic decks (kind 37381)