Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a18d500ee | |||
| 54c711b3be | |||
| 79c6e7e516 | |||
| 9717a6827f | |||
| f6c7bc366d | |||
| 4c7d059b0b | |||
| eae5e1c3a7 | |||
| e7c488af63 | |||
| 4153792e54 | |||
| c731256efb | |||
| 702d374a06 | |||
| b174152566 | |||
| 6a5c426648 | |||
| f8547668b2 | |||
| 49049f98e7 | |||
| 048878b699 | |||
| 476a3856ec | |||
| d69cfa0862 | |||
| a5cc9c5163 | |||
| 42ac269a56 | |||
| caa8e70703 | |||
| 8f53e3e53b | |||
| 5d4d0825c6 | |||
| b9b7351361 | |||
| 6dcae6385a | |||
| 13386bf0fd | |||
| 2ae2a3da18 | |||
| 1c06e070cd | |||
| f0c3ff1a80 | |||
| 13a0bb3e3a | |||
| 646ed9777f | |||
| 437613641a | |||
| d0836328a4 | |||
| 123f53e7a6 | |||
| 977fd000ea | |||
| 5132141aa2 | |||
| b6dc57eb85 | |||
| 016a7b4a7d | |||
| 7ae63883e9 | |||
| d4cf4ba0d8 | |||
| 399dc53395 | |||
| 699e505fb5 | |||
| 20839f4de3 | |||
| 4e9da2d168 | |||
| 32b477bd01 | |||
| 564459e12d | |||
| c97d0723a6 | |||
| 53da626461 | |||
| c79699ca71 | |||
| e58c031a85 | |||
| bc80dba826 | |||
| 611f97488e | |||
| a948725245 | |||
| dde9865284 | |||
| 3d825aef04 | |||
| 575603554b | |||
| dfb0a52603 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 72 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
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>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 839 B |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 637 B |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 1.8 KiB |
|
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,5 +1,5 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
minSdkVersion = 26
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 75 KiB |
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 36 KiB |
@@ -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)"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -44,9 +44,11 @@ import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import { CommunityReportDialog } from '@/components/CommunityReportDialog';
|
||||
import { AddToListDialog } from '@/components/AddToListDialog';
|
||||
import { CampaignListMembershipDialog } from '@/components/campaign-lists/CampaignListMembershipDialog';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useBookmarks } from '@/hooks/useBookmarks';
|
||||
import { usePinnedNotes } from '@/hooks/usePinnedNotes';
|
||||
import { useCampaignListActions } from '@/hooks/useCampaignListActions';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
@@ -211,6 +213,7 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [banContentOpen, setBanContentOpen] = useState(false);
|
||||
const [addToListOpen, setAddToListOpen] = useState(false);
|
||||
const [addToCampaignListOpen, setAddToCampaignListOpen] = useState(false);
|
||||
const [eventJsonOpen, setEventJsonOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
@@ -233,6 +236,16 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
|
||||
const nip19Id = encodeEventNip19(event);
|
||||
|
||||
// Campaign-specific membership-dialog inputs. Only meaningful when
|
||||
// `event.kind === CAMPAIGN_KIND`; the dialog row that uses them is
|
||||
// gated inside the menu content the same way.
|
||||
const isCampaign = event.kind === CAMPAIGN_KIND;
|
||||
const campaignDTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
const campaignCoord = isCampaign
|
||||
? `${CAMPAIGN_KIND}:${event.pubkey}:${campaignDTag}`
|
||||
: '';
|
||||
const campaignTitle = event.tags.find(([n]) => n === 'title')?.[1] ?? '';
|
||||
|
||||
const handleDelete = () => {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
deleteEvent(
|
||||
@@ -269,6 +282,10 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setAddToListOpen(true), 150);
|
||||
}}
|
||||
onAddToCampaignList={() => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setAddToCampaignListOpen(true), 150);
|
||||
}}
|
||||
onViewEventJson={() => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setEventJsonOpen(true), 150);
|
||||
@@ -307,6 +324,15 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
onOpenChange={setAddToListOpen}
|
||||
/>
|
||||
|
||||
{isCampaign && (
|
||||
<CampaignListMembershipDialog
|
||||
open={addToCampaignListOpen}
|
||||
onOpenChange={setAddToCampaignListOpen}
|
||||
campaignCoord={campaignCoord}
|
||||
campaignTitle={campaignTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EventJsonDialog
|
||||
event={event}
|
||||
nip19Id={nip19Id}
|
||||
@@ -347,11 +373,12 @@ interface NoteMoreMenuContentProps extends NoteMoreMenuProps {
|
||||
onReport: () => void;
|
||||
onBanContent: () => void;
|
||||
onAddToList: () => void;
|
||||
onAddToCampaignList: () => void;
|
||||
onViewEventJson: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
|
||||
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onAddToCampaignList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
@@ -365,6 +392,12 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
// (kind 33863 — addressable, with their own dedicated UI). Hide them there.
|
||||
const isCampaign = event.kind === CAMPAIGN_KIND;
|
||||
|
||||
// Campaign moderators get a dedicated "Add to list" row that toggles
|
||||
// the campaign's membership in the curated topic lists. `isMod` is a
|
||||
// synchronous boolean — no loading state to handle.
|
||||
const campaignListActions = useCampaignListActions();
|
||||
const canManageCampaignLists = isCampaign && campaignListActions.isMod;
|
||||
|
||||
// Country-feed pin/unpin context (organizer/admin action). `useCountryFeed`
|
||||
// returns null outside of a country page; we only enable usePinnedPosts when
|
||||
// the viewer is actually authorized to pin so we avoid extra relay traffic
|
||||
@@ -551,6 +584,13 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
onClick={() => { onAddToList(); }}
|
||||
/>
|
||||
)}
|
||||
{canManageCampaignLists && (
|
||||
<MenuItem
|
||||
icon={<ListPlus className="size-5" />}
|
||||
label={t('campaigns.lists.membershipTitle')}
|
||||
onClick={() => { onAddToCampaignList(); }}
|
||||
/>
|
||||
)}
|
||||
{!isCampaign && (
|
||||
<MenuItem
|
||||
icon={isInSidebar ? <Trash2 className="size-5" /> : <PanelLeft className="size-5" />}
|
||||
|
||||
@@ -4,6 +4,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)',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`;
|
||||
@@ -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": "عرض أقل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "نمایش کمتر",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "पर्याप्त बिटकॉइन नहीं",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "បង្ហាញតិច",
|
||||
|
||||
@@ -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": "لږ ښکاره کړئ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Недостаточно биткоинов",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "顯示較少",
|
||||
|
||||
@@ -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": "显示较少",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||