Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 565a2d1eca | |||
| aa936b59f9 | |||
| ba8ba5fef9 | |||
| 68fdc01fc9 | |||
| 3f8c14ab69 | |||
| d19cdace19 | |||
| 7c173af097 | |||
| 4e75f56d4d | |||
| 8933c00974 | |||
| efb13c7fbe | |||
| ce63127e69 | |||
| f131198feb | |||
| 24009cabdd | |||
| 817144c146 | |||
| 6e03a4abb9 | |||
| 89eb5fb6ac | |||
| 89f78914c2 | |||
| eb35eeaf63 | |||
| 96335c1472 | |||
| ee189f6455 | |||
| 88bb768658 | |||
| 9362ebf8d6 | |||
| fe16f2ac07 | |||
| 8adf351ae6 | |||
| 3f4746a72d | |||
| 059dff3686 | |||
| 9bfc3498da | |||
| 47a35d35cf | |||
| 734be77be7 | |||
| 301309f03c | |||
| 05c1d1bf39 | |||
| cf9535ff39 | |||
| a76d53e686 | |||
| 7e6fe43b86 | |||
| 809f4aa7dc | |||
| c380ef70ae | |||
| 97f5f82b05 | |||
| b01f573bf9 | |||
| 59538e1798 | |||
| e132587b6d | |||
| c78a7ac089 | |||
| 9498411e13 | |||
| 5e07f72fb8 | |||
| e7ca4cc9b1 | |||
| 4d148218ac | |||
| 716579d91e | |||
| bba7fb045a | |||
| 557063c0f3 | |||
| 65aeb724a3 | |||
| 21306afdb2 | |||
| 34b5babc01 | |||
| 43dee910b8 | |||
| 979cc1d097 | |||
| 00579e5de2 | |||
| 978ee5f77e | |||
| 66bc7e80f2 | |||
| 1b5d2bf771 | |||
| 495c2c7ce0 | |||
| cdf47e2900 | |||
| 4d7a7acbae | |||
| 790787aa43 | |||
| ca972704fb | |||
| 514db0cc59 | |||
| b34660f7d4 | |||
| 4e0c97482a | |||
| bf1770eec2 | |||
| e0f122aaad | |||
| b244e9d645 | |||
| 728a56af3f | |||
| 5553a31ffa | |||
| 7a4259db6a | |||
| 1e4c0735e6 | |||
| 2e0defa977 | |||
| 0e75799cf0 | |||
| e4b0f9a80d | |||
| 374a1d62de | |||
| ccf6b59ae1 | |||
| 5375466e15 | |||
| d1e579dda3 | |||
| 786938f9fc | |||
| 0486ff3c9f | |||
| f6c5b69b5c | |||
| b034716fca | |||
| 3534a0c4f7 | |||
| 222968286b | |||
| 37dd7dc3e7 | |||
| c39caab068 | |||
| b0fc77ada1 | |||
| 7c4ee25379 | |||
| 96c7acfca2 | |||
| 741ce22d97 | |||
| 1e7bfae560 | |||
| cf635a19ff | |||
| 0a6a5830d0 | |||
| a7a9ed06a3 | |||
| 6ac6c0b22f | |||
| c27dfb212a | |||
| 35b8786e77 | |||
| 9ef5d2cf6a | |||
| 814589e535 | |||
| 1a27b30e77 | |||
| fb10250d1b | |||
| 560aca69ee | |||
| b916b1193e | |||
| 4d271a11ec | |||
| fb66ed284b | |||
| f534500075 | |||
| e07f04fad2 | |||
| c6eef561b3 | |||
| 00daa2dfaa | |||
| 30da87ddf4 | |||
| 3e1de5bf4f | |||
| f58128ede0 | |||
| 1dd51c7029 | |||
| b34a7e0d54 | |||
| 7c46f4cd73 | |||
| da9862c53a | |||
| 2b3b6900dc | |||
| 933f1eb5c6 | |||
| b1ec7538b3 | |||
| 92091c8570 | |||
| 35ce3f87e1 | |||
| 9640d8790f | |||
| ff3804777a | |||
| 207775b6b9 | |||
| abad9eca9d | |||
| 528e87e9e4 | |||
| 716d3ee406 | |||
| 7ac8c58426 | |||
| 9cc08c4989 | |||
| be5c7ac854 | |||
| f04aa41aae | |||
| e181c01b0b | |||
| 42cb23b48a | |||
| 900689acab | |||
| 124a4d868f | |||
| 77a50189d8 | |||
| 45f882e3aa | |||
| 47a8e35334 | |||
| 7ce1ca87e9 |
@@ -266,16 +266,16 @@ The router provides automatic scroll-to-top on navigation and a 404 `NotFound` p
|
||||
|
||||
## Internationalization
|
||||
|
||||
All user-facing strings live in `src/locales/<lang>.json`. `en.json` is the source of truth; ten other locales ship alongside it: `ar`, `es`, `fa`, `fr`, `km`, `ps`, `pt`, `ru`, `sn`, `zh`.
|
||||
All user-facing strings live in `src/locales/<lang>.json`. `en.json` is the source of truth; fifteen other locales ship alongside it: `ar`, `es`, `fa`, `fr`, `hi`, `id`, `km`, `ps`, `pt`, `ru`, `sn`, `sw`, `tr`, `zh`, `zh-Hant`.
|
||||
|
||||
**When you edit, add, or remove a translated string, update every locale in the same change — not just `en.json`.** Leaving the other locales stale ships an inconsistent app: users in other languages either see outdated copy or get an English fallback in the middle of a localized screen. This applies to FAQ entries, guide bodies, button labels, error messages — every value reachable through `t()`.
|
||||
|
||||
Concrete rules:
|
||||
|
||||
- **Edits to an existing key** — change the value in `en.json` first, then update the corresponding key in all ten other locales. Translate the new content into each language; don't paste English. Preserve `{{interpolation}}` placeholders, markdown links, and technical tokens (`sp1…`, `BIP-352`, kind numbers, etc.) verbatim.
|
||||
- **Edits to an existing key** — change the value in `en.json` first, then update the corresponding key in all fifteen other locales. Translate the new content into each language; don't paste English. Preserve `{{interpolation}}` placeholders, markdown links, and technical tokens (`sp1…`, `BIP-352`, kind numbers, etc.) verbatim.
|
||||
- **New keys** — add to `en.json` first, then add the same key with a translated value in every other locale. `src/test/locales.test.ts` fails the build if any locale ships a key that doesn't exist in `en.json`, but the inverse (a key missing from a non-English locale) is allowed and falls back to English at runtime — which is exactly the user-visible mess you're trying to avoid.
|
||||
- **Removed keys** — delete from `en.json` and every other locale together. Leftover keys are dead translations and clutter future diffs.
|
||||
- **Parallelize the translation work** — when updating one English string across all ten locales, dispatch the per-language edits to subagents in parallel rather than translating ten files sequentially. Provide each subagent the new English source, the existing translation snippet (so it matches established voice), and explicit instructions to preserve placeholders and technical tokens.
|
||||
- **Parallelize the translation work** — when updating one English string across all fifteen locales, dispatch the per-language edits to subagents in parallel rather than translating fifteen files sequentially. Provide each subagent the new English source, the existing translation snippet (so it matches established voice), and explicit instructions to preserve placeholders and technical tokens.
|
||||
|
||||
Always run `npm run test` after locale changes — `locales.test.ts` catches structural drift, and the wider suite catches any `t()` calls that referenced a key you renamed.
|
||||
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
## [2.9.1] - 2026-06-27
|
||||
|
||||
The Venezuela earthquake relief appeal now rallies behind every campaign on the ground, not just one. The home banner and relief page showcase a live, swipeable carousel of all Venezuelan relief efforts, with a running total of everything raised so far — so you can pick exactly who to help.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Venezuela relief appeal now showcases every Venezuelan relief campaign in a live, auto-scrolling carousel you can swipe through, with a combined raised total across all of them, instead of featuring a single campaign.
|
||||
|
||||
## [2.9.0] - 2026-06-25
|
||||
|
||||
A big one. Private messaging arrives with a fast, searchable inbox you can reply to in your own language. Campaign organizers can now get verified by trusted reviewers through a guided sign-up, and verified badges appear right on campaigns. There's a new Venezuela earthquake relief appeal that takes you straight to donations, plus optional Tor routing on Android, separate Public and Private wallets, faster donation scanning, and refreshed profiles, settings, and login.
|
||||
|
||||
### Added
|
||||
|
||||
- Private direct messages: a dedicated inbox you can search by name, start new chats with inline recipient lookup, page back through old conversations, and read incoming messages translated into your language.
|
||||
- Get verified: a guided sign-up for trusted reviewers to set up a verifier profile, publish a public "how we verify" statement, and vouch for campaigns. Verified badges now show on campaign pages, and a short tutorial walks you through it.
|
||||
- A Venezuela earthquake relief appeal — a home-page banner, a one-time popup, and a shareable page that bakes in the relief campaign so you can read its story and donate without leaving.
|
||||
- Optional Tor routing on Android for added privacy.
|
||||
- Separate Public and Private wallets, keeping your spending and your private silent-payment funds cleanly apart.
|
||||
- A "Don't have Bitcoin?" prompt on campaign pages that points first-time donors to Cash App.
|
||||
- An always-visible language switcher in the top navigation, and a corporate sponsorship page.
|
||||
|
||||
### Changed
|
||||
|
||||
- Redesigned profiles with cleaner stats, a merged campaigns view, and a themed raised total.
|
||||
- Redesigned Settings into an Apple-style grouped layout.
|
||||
- Reworked the login and onboarding flow, including a new welcome screen built around the Agora brand.
|
||||
- Silent-payment donations now scan faster and keep working in the background, with a progress bar on the private wallet.
|
||||
- The home page now shows every featured campaign instead of capping the list.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The audio, music, and podcast pages no longer crash.
|
||||
- Backfilled and corrected translations across all sixteen languages.
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
# Agora
|
||||
# Eranos
|
||||
|
||||
Power to the people.
|
||||
|
||||
Agora is a Nostr client focused on community ownership, expressive identity, and censorship resistance. This repository (`agora-3`) is the Agora-branded app built from the Ditto codebase.
|
||||
Eranos is a Nostr client focused on community ownership, expressive identity, and censorship resistance. This repository is the Eranos-branded app, a Grin-only fork of Agora (itself built from the Ditto codebase). All Bitcoin and Lightning payment rails have been removed; Grin payments land in a later phase.
|
||||
|
||||
**[agora.spot](https://agora.spot)** | **[Source](https://gitlab.com/soapbox-pub/agora-3)**
|
||||
**[eranos.fund](https://eranos.fund)** | **Upstream: [Agora](https://gitlab.com/soapbox-pub/agora-3)**
|
||||
|
||||
## What This Repo Is
|
||||
|
||||
- Agora product identity (name, theme, assets, native IDs)
|
||||
- Eranos product identity (name, theme, assets, native IDs)
|
||||
- Ditto-derived implementation with broad Nostr feature coverage
|
||||
- Configurable deployment defaults via `agora.json`
|
||||
- Configurable deployment defaults via `eranos.json`
|
||||
|
||||
## Features
|
||||
|
||||
- **Community-first social client**: notes, articles, comments, reposts, reactions, and rich event rendering
|
||||
- **Theming system**: built-in presets + custom color/font/background themes that can be shared as events
|
||||
- **Lightning support**: zaps with Nostr Wallet Connect and WebLN
|
||||
- **Private messaging**: NIP-04 and NIP-17 direct messages
|
||||
- **Mobile app shell**: Capacitor-powered Android/iOS wrappers
|
||||
- **Self-hostable**: static web build + configurable relay and upload infrastructure
|
||||
@@ -31,8 +30,8 @@ Agora is a Nostr client focused on community ownership, expressive identity, and
|
||||
### Development
|
||||
|
||||
```sh
|
||||
git clone https://gitlab.com/soapbox-pub/agora-3.git
|
||||
cd agora-3
|
||||
git clone <this-repo>
|
||||
cd eranos
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
@@ -44,8 +43,8 @@ Development server: `http://localhost:8080`
|
||||
Use Docker Compose when you want the nginx reverse-proxy stack (necessary if you want decryptable media in messages - kind 15s of NIP 17):
|
||||
|
||||
```sh
|
||||
git clone https://gitlab.com/soapbox-pub/agora-3.git
|
||||
cd agora-3
|
||||
git clone <this-repo>
|
||||
cd eranos
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
@@ -87,7 +86,7 @@ This runs type-checking, linting, unit tests, and production build checks.
|
||||
|
||||
## Configuration
|
||||
|
||||
Build-time config is read from `agora.json` (gitignored by default so each deployment can provide its own values).
|
||||
Build-time config is read from `eranos.json` (gitignored by default so each deployment can provide its own values).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -109,7 +108,7 @@ Build-time config is read from `agora.json` (gitignored by default so each deplo
|
||||
Configuration priority (highest first):
|
||||
|
||||
1. User settings (local storage)
|
||||
2. Build config (`agora.json`)
|
||||
2. Build config (`eranos.json`)
|
||||
3. Hardcoded app defaults
|
||||
|
||||
Use a custom config path:
|
||||
@@ -120,7 +119,7 @@ CONFIG_FILE=./my-config.json npm run build
|
||||
|
||||
## Deployment
|
||||
|
||||
Agora builds to static files and can be deployed to any static host.
|
||||
Eranos builds to static files and can be deployed to any static host.
|
||||
|
||||
- GitLab/GitHub Pages
|
||||
- Netlify/Vercel
|
||||
@@ -155,3 +154,5 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a merge request.
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
||||
🤖 Built with AI pair-programming assistance (Claude)
|
||||
|
||||
@@ -7,14 +7,14 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "spot.agora.app"
|
||||
namespace = "fund.eranos.app"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "spot.agora.app"
|
||||
applicationId "fund.eranos.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.8.9"
|
||||
versionName "2.9.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
// The arti-mobile AAR bundles large native Rust libraries for every
|
||||
// ABI (~45 MB total). Restrict to the ABIs we actually ship/test:
|
||||
@@ -63,8 +63,10 @@ dependencies {
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
// Tor in Rust (arti) — prebuilt AAR from Guardian Project's gpmaven repo.
|
||||
// Tor in Rust (arti) — prebuilt AAR from Guardian Project's gpmaven repo
|
||||
// (source pinned to an immutable commit in the root build.gradle).
|
||||
// Provides org.torproject.arti.ArtiProxy used by TorController.
|
||||
// The resolved AAR is checksum-verified below (verifyArtiChecksum).
|
||||
implementation 'org.torproject:arti-mobile:1.7.0.1'
|
||||
// arti pulls androidx.webkit in transitively but only at runtime; we
|
||||
// compile against ProxyController/WebViewFeature in TorController, so
|
||||
@@ -78,6 +80,49 @@ dependencies {
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
// Supply-chain pin for the arti-mobile AAR. It carries a native library with
|
||||
// network-proxy privileges and is sourced from Guardian Project's gpmaven repo,
|
||||
// so we verify the resolved artifact's SHA-256 against a value pinned here.
|
||||
// A mismatch fails the build before any APK is assembled. To bump arti, update
|
||||
// the version + repo commit (root build.gradle) and replace this checksum after
|
||||
// re-verifying a fresh download.
|
||||
ext.artiMobileSha256 = 'cbdb34ce3cdb32f755f25f6dd05a2d1eb9a44025a17ec9202729816e2a3af05b'
|
||||
|
||||
task verifyArtiChecksum {
|
||||
doLast {
|
||||
def artifact = configurations.releaseRuntimeClasspath
|
||||
.resolvedConfiguration
|
||||
.resolvedArtifacts
|
||||
.find { it.moduleVersion.id.group == 'org.torproject' && it.moduleVersion.id.name == 'arti-mobile' }
|
||||
|
||||
if (artifact == null) {
|
||||
throw new GradleException("arti-mobile artifact not found on the runtime classpath; cannot verify Tor native library.")
|
||||
}
|
||||
|
||||
def actual = java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(artifact.file.bytes)
|
||||
.collect { String.format('%02x', it) }
|
||||
.join('')
|
||||
|
||||
if (actual != project.ext.artiMobileSha256) {
|
||||
throw new GradleException(
|
||||
"arti-mobile AAR checksum mismatch!\n" +
|
||||
" expected: ${project.ext.artiMobileSha256}\n" +
|
||||
" actual: ${actual}\n" +
|
||||
" file: ${artifact.file}\n" +
|
||||
"Refusing to build a Tor proxy from an unverified native artifact."
|
||||
)
|
||||
}
|
||||
logger.lifecycle("Verified arti-mobile AAR checksum (${actual}).")
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.matching { it.name.startsWith('assemble') || it.name.startsWith('bundle') }.configureEach {
|
||||
dependsOn verifyArtiChecksum
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
# Keep Capacitor classes (WebView JS bridge)
|
||||
-keep class com.getcapacitor.** { *; }
|
||||
-keep class spot.agora.app.** { *; }
|
||||
-keep class fund.eranos.app.** { *; }
|
||||
|
||||
# Keep WebView JS interfaces
|
||||
-keepclassmembers class * {
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep links: open agora.spot URLs in the app -->
|
||||
<!-- Deep links: open eranos.fund URLs in the app -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="agora.spot" />
|
||||
<data android:scheme="https" android:host="eranos.fund" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package spot.agora.app;
|
||||
package fund.eranos.app;
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
@@ -1,4 +1,4 @@
|
||||
package spot.agora.app;
|
||||
package fund.eranos.app;
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
@@ -106,7 +106,7 @@ public class MainActivity extends BridgeActivity {
|
||||
private void handleNotificationIntent(Intent intent) {
|
||||
if (intent == null) return;
|
||||
Uri data = intent.getData();
|
||||
if (data != null && "agora.spot".equals(data.getHost())) {
|
||||
if (data != null && "eranos.fund".equals(data.getHost())) {
|
||||
String path = data.getPath();
|
||||
if (path != null && !path.isEmpty()) {
|
||||
// Wait for WebView to be ready, then navigate
|
||||
@@ -1,4 +1,4 @@
|
||||
package spot.agora.app;
|
||||
package fund.eranos.app;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
@@ -337,7 +337,7 @@ public class NostrPoller {
|
||||
if (manager == null) return;
|
||||
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
intent.setData(Uri.parse("https://agora.spot/notifications"));
|
||||
intent.setData(Uri.parse("https://eranos.fund/notifications"));
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(
|
||||
context, id, intent,
|
||||
@@ -1,4 +1,4 @@
|
||||
package spot.agora.app;
|
||||
package fund.eranos.app;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
@@ -83,7 +83,7 @@ public class NotificationRelayService extends Service {
|
||||
// + REQ + up to 5 events + EOSE + metadata fetch + disconnect.
|
||||
private static final long FETCH_WAKELOCK_TIMEOUT_MS = 30_000;
|
||||
|
||||
private static final String ACTION_FETCH = "spot.agora.app.ACTION_FETCH";
|
||||
private static final String ACTION_FETCH = "fund.eranos.app.ACTION_FETCH";
|
||||
|
||||
// Backoff bounds for relay connect failures (separate from alarm interval).
|
||||
private static final long INITIAL_BACKOFF_MS = 1_000;
|
||||
@@ -1,4 +1,4 @@
|
||||
package spot.agora.app;
|
||||
package fund.eranos.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -36,10 +36,12 @@ import okhttp3.Response;
|
||||
* Capacitor WebView traffic (every {@code fetch} and relay {@code WebSocket})
|
||||
* is routed through Tor. No changes to the TypeScript HTTP layer are needed.
|
||||
*
|
||||
* <p>Activation is "apply on relaunch": the enabled flag is persisted to
|
||||
* {@link SharedPreferences} by {@link TorPlugin} and read here at startup from
|
||||
* {@link MainActivity}. arti is started <em>before</em> the WebView loads so
|
||||
* there is no pre-bootstrap leak window.
|
||||
* <p>The enabled flag is persisted to {@link SharedPreferences} by
|
||||
* {@link TorPlugin} and read here at startup from {@link MainActivity}, so arti
|
||||
* auto-starts on a cold launch <em>before</em> the WebView loads — there is no
|
||||
* pre-bootstrap leak window. Beyond that, activation is live: the settings
|
||||
* toggle calls {@link #start}/{@link #stop} (bridged through {@link TorPlugin}),
|
||||
* which start or stop arti immediately while also updating the persisted flag.
|
||||
*
|
||||
* <p>Pluggable transports (obfs4 via IPtProxy) are intentionally not wired up
|
||||
* yet — the builder already exposes {@code setObfs4Port}/{@code setBridgeLines}
|
||||
@@ -104,13 +106,17 @@ public class TorController {
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Whether Tor is enabled in persisted preferences (read at next launch). */
|
||||
/** Whether Tor is enabled in persisted preferences (read at cold-launch startup). */
|
||||
public static boolean isEnabled(Context context) {
|
||||
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
return prefs.getBoolean(KEY_ENABLED, false);
|
||||
}
|
||||
|
||||
/** Persist the enabled flag. Takes effect on the next app launch. */
|
||||
/**
|
||||
* Persist the enabled flag only. This controls whether arti auto-starts on
|
||||
* the next cold launch; it does not start or stop arti now. For live
|
||||
* activation call {@link #start}/{@link #stop}, which also persist the flag.
|
||||
*/
|
||||
public static void setEnabled(Context context, boolean enabled) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
@@ -1,4 +1,4 @@
|
||||
package spot.agora.app;
|
||||
package fund.eranos.app;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
@@ -10,9 +10,12 @@ import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
* Capacitor bridge for the Tor (arti) mode.
|
||||
*
|
||||
* <p>Mirrors {@link DittoNotificationPlugin}'s pattern: JS configures native
|
||||
* state, native owns the work. The enabled flag is persisted only — arti is
|
||||
* actually started at launch from {@link MainActivity} (apply on relaunch).
|
||||
* Live bootstrap status is pushed to JS via the {@code torStatus} event.
|
||||
* state, native owns the work. On a cold launch arti auto-starts from
|
||||
* {@link MainActivity} based on the persisted flag. At runtime the settings
|
||||
* toggle activates Tor live via {@link #start}/{@link #stop}, which start or
|
||||
* stop arti immediately and update the persisted flag. ({@link #setEnabled}
|
||||
* persists the flag only, without touching the running proxy.) Live bootstrap
|
||||
* status is pushed to JS via the {@code torStatus} event.
|
||||
*
|
||||
* <p>JS interface: see {@code src/lib/tor.ts}.
|
||||
*/
|
||||
@@ -43,7 +46,11 @@ public class TorPlugin extends Plugin {
|
||||
call.resolve(ret);
|
||||
}
|
||||
|
||||
/** Persist the enabled flag. Applied on the next app launch. */
|
||||
/**
|
||||
* Persist the enabled flag only, without starting or stopping arti now.
|
||||
* Controls whether arti auto-starts on the next cold launch. For live
|
||||
* activation use {@link #start}/{@link #stop}.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void setEnabled(PluginCall call) {
|
||||
Boolean enabled = call.getBoolean("enabled");
|
||||
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 487 B After Width: | Height: | Size: 652 B |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 839 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 637 B After Width: | Height: | Size: 751 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 487 B After Width: | Height: | Size: 652 B |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Agora</string>
|
||||
<string name="title_activity_main">Agora</string>
|
||||
<string name="package_name">spot.agora.app</string>
|
||||
<string name="custom_url_scheme">spot.agora.app</string>
|
||||
<string name="app_name">Eranos</string>
|
||||
<string name="title_activity_main">Eranos</string>
|
||||
<string name="package_name">fund.eranos.app</string>
|
||||
<string name="custom_url_scheme">fund.eranos.app</string>
|
||||
</resources>
|
||||
|
||||
@@ -23,7 +23,16 @@ allprojects {
|
||||
mavenCentral()
|
||||
// Guardian Project's experimental Maven repo, hosting the prebuilt
|
||||
// org.torproject:arti-mobile AAR (Tor in Rust) used for the optional Tor mode.
|
||||
maven { url "https://raw.githubusercontent.com/guardianproject/gpmaven/master" }
|
||||
//
|
||||
// Pinned to an immutable commit SHA rather than the mutable `master`
|
||||
// branch: this artifact ships a native library with network-proxy
|
||||
// privileges, so we don't want a force-push or new commit to gpmaven
|
||||
// silently changing what we resolve. To bump arti, update both the
|
||||
// commit below and the checksum pin in `app/build.gradle`, and re-verify
|
||||
// the SHA-256 against a fresh download.
|
||||
//
|
||||
// Commit: guardianproject/gpmaven@b3ee2a63eec4ce37ea22fcc6b1ff009f406f2b13
|
||||
maven { url "https://raw.githubusercontent.com/guardianproject/gpmaven/b3ee2a63eec4ce37ea22fcc6b1ff009f406f2b13" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'spot.agora.app',
|
||||
appName: 'Agora',
|
||||
appId: 'fund.eranos.app',
|
||||
appName: 'Eranos',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
androidScheme: 'https',
|
||||
@@ -16,7 +16,7 @@ const config: CapacitorConfig = {
|
||||
ios: {
|
||||
backgroundColor: '#14161f',
|
||||
contentInset: 'never',
|
||||
scheme: 'Agora'
|
||||
scheme: 'Eranos'
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
depends_on:
|
||||
- vite
|
||||
networks:
|
||||
- agora-network
|
||||
- eranos-network
|
||||
|
||||
vite:
|
||||
image: node:22-alpine
|
||||
@@ -22,9 +22,9 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
networks:
|
||||
- agora-network
|
||||
- eranos-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
agora-network:
|
||||
eranos-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<title>Agora — Power to the people.</title>
|
||||
<title>Eranos — Power to the people.</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Agora — a peer-to-peer crowdfunding app on Nostr with an integrated non-custodial on-chain Bitcoin wallet. Fund campaigns directly, no middlemen." />
|
||||
<meta name="description" content="Eranos — a peer-to-peer crowdfunding app on Nostr. Fund campaigns directly with Grin, no middlemen." />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Agora" />
|
||||
<meta property="og:title" content="Eranos" />
|
||||
<meta property="og:description" content="Power to the people." />
|
||||
<meta property="og:image" content="https://agora.spot/og-image.jpg" />
|
||||
<meta property="og:image" content="https://eranos.fund/og-image.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://agora.spot" />
|
||||
<meta property="og:site_name" content="Agora" />
|
||||
<meta property="og:url" content="https://eranos.fund" />
|
||||
<meta property="og:site_name" content="Eranos" />
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Agora" />
|
||||
<meta name="twitter:title" content="Eranos" />
|
||||
<meta name="twitter:description" content="Power to the people." />
|
||||
<meta name="twitter:image" content="https://agora.spot/og-image.jpg" />
|
||||
<meta name="twitter:image" content="https://eranos.fund/og-image.jpg" />
|
||||
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; child-src 'self' blob:; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
@@ -29,7 +29,7 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#0a0c14" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ff6600" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#faa805" media="(prefers-color-scheme: light)">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<style>@keyframes agora-spin{to{transform:rotate(360deg)}}</style>
|
||||
</head>
|
||||
|
||||
@@ -323,9 +323,9 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.8.9;
|
||||
MARKETING_VERSION = 2.9.1;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = fund.eranos.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -347,8 +347,8 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.8.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
MARKETING_VERSION = 2.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = fund.eranos.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>webcredentials:agora.spot</string>
|
||||
<string>webcredentials:agora.spot?mode=developer</string>
|
||||
<string>webcredentials:eranos.fund</string>
|
||||
<string>webcredentials:eranos.fund?mode=developer</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 72 KiB |
@@ -33,7 +33,7 @@ public class DittoNotificationPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
static let bgTaskIdentifier = "spot.agora.app.notification-refresh"
|
||||
static let bgTaskIdentifier = "fund.eranos.app.notification-refresh"
|
||||
private static let prefsKey = "ditto_notification_config"
|
||||
|
||||
// MARK: - Plugin Methods
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Agora</string>
|
||||
<string>Eranos</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -48,11 +48,11 @@
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Agora needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<string>Eranos needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Agora needs camera access to take photos and videos for your posts, and to scan QR codes when sending Bitcoin.</string>
|
||||
<string>Eranos needs camera access to take photos and videos for your posts, and to scan QR codes.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Agora needs access to your microphone to record voice messages.</string>
|
||||
<string>Eranos needs access to your microphone to record voice messages.</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
@@ -61,7 +61,7 @@
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>spot.agora.app.notification-refresh</string>
|
||||
<string>fund.eranos.app.notification-refresh</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
app_identifier("spot.agora.app")
|
||||
app_identifier("fund.eranos.app")
|
||||
team_id("GZLTTH5DLM")
|
||||
|
||||
@@ -3,7 +3,7 @@ default_platform(:ios)
|
||||
platform :ios do
|
||||
# ─── Lanes ────────────────────────────────────────────────────────────
|
||||
|
||||
desc "Build and sign the App Store IPA. Output at ../artifacts/Agora.ipa."
|
||||
desc "Build and sign the App Store IPA. Output at ../artifacts/Eranos.ipa."
|
||||
lane :build_ipa do
|
||||
setup_lane_signing!
|
||||
build_release_ipa!
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
submit_release_for_review!(ipa_path)
|
||||
end
|
||||
|
||||
desc "Build, sign, and submit Agora to the App Store for review (single-step convenience)."
|
||||
desc "Build, sign, and submit Eranos to the App Store for review (single-step convenience)."
|
||||
lane :release do
|
||||
setup_lane_signing!
|
||||
build_release_ipa!
|
||||
@@ -83,7 +83,7 @@ platform :ios do
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
output_directory: "../artifacts",
|
||||
output_name: "Agora.ipa",
|
||||
output_name: "Eranos.ipa",
|
||||
clean: true,
|
||||
# Override the Xcode project's Automatic signing for this build only.
|
||||
# Match has already installed the AppStore cert + profile into the
|
||||
@@ -93,7 +93,7 @@ platform :ios do
|
||||
xcargs: [
|
||||
"CODE_SIGN_STYLE=Manual",
|
||||
"CODE_SIGN_IDENTITY='Apple Distribution'",
|
||||
"PROVISIONING_PROFILE_SPECIFIER='match AppStore spot.agora.app'",
|
||||
"PROVISIONING_PROFILE_SPECIFIER='match AppStore fund.eranos.app'",
|
||||
"DEVELOPMENT_TEAM=GZLTTH5DLM",
|
||||
].join(" "),
|
||||
export_options: {
|
||||
@@ -101,7 +101,7 @@ platform :ios do
|
||||
signingStyle: "manual",
|
||||
teamID: "GZLTTH5DLM",
|
||||
provisioningProfiles: {
|
||||
"spot.agora.app" => "match AppStore spot.agora.app",
|
||||
"fund.eranos.app" => "match AppStore fund.eranos.app",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git_url("https://gitlab.com/soapbox-pub/certificates.git")
|
||||
storage_mode("git")
|
||||
type("appstore")
|
||||
app_identifier(["spot.agora.app"])
|
||||
app_identifier(["fund.eranos.app"])
|
||||
team_id("GZLTTH5DLM")
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"name": "agora",
|
||||
"version": "2.8.9",
|
||||
"name": "eranos",
|
||||
"version": "2.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agora",
|
||||
"version": "2.8.9",
|
||||
"name": "eranos",
|
||||
"version": "2.9.1",
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
@@ -37,7 +36,6 @@
|
||||
"@fontsource/pirata-one": "^5.2.8",
|
||||
"@fontsource/silkscreen": "^5.2.8",
|
||||
"@fontsource/special-elite": "^5.2.8",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@milkdown/core": "^7.20.0",
|
||||
"@milkdown/ctx": "^7.20.0",
|
||||
@@ -50,7 +48,7 @@
|
||||
"@milkdown/prose": "^7.20.0",
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/curves": "^1.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.52.2",
|
||||
"@nostrify/react": "^0.6.2",
|
||||
@@ -80,9 +78,6 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@scure/bip32": "^2.2.0",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@scure/btc-signer": "^2.2.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
@@ -142,7 +137,6 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
@@ -367,18 +361,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@breeztech/breez-sdk-spark": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.10.0.tgz",
|
||||
"integrity": "sha512-eBsh0oX2B8uGuWfCMmtH3SNXmSkED5du/CiWQKh1Ei1r0LsO6jlVnUmh94j7R5W4siIi7M6CC7ywll3FQ47rYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^12.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/android": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.1.0.tgz",
|
||||
@@ -1487,36 +1469,6 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@getalby/lightning-tools": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz",
|
||||
"integrity": "sha512-8kBvENBTMh541VjGKhw3I29+549/C02gLSh3AQaMfoMNSZaMxfQW+7dcMcc7vbFaCKEcEe18ST5bUveTRBuXCQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "lightning",
|
||||
"url": "lightning:hello@getalby.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@getalby/sdk": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@getalby/sdk/-/sdk-5.1.1.tgz",
|
||||
"integrity": "sha512-t/kg2ljPx86qRYKqEVc5VYhDICFKtVPRlQKIz5cI/AqOLYVguLJz1AkQlDBaiOz2PW5FxoyGlLkTGmX7ONHH/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@getalby/lightning-tools": "^5.1.2",
|
||||
"nostr-tools": "2.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "lightning",
|
||||
"url": "lightning:hello@getalby.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||
@@ -2429,27 +2381,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
|
||||
"integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0"
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@@ -5336,99 +5276,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
|
||||
"integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "2.2.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
|
||||
"integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "~1.8.0",
|
||||
"@scure/base": "~1.2.5"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39/node_modules/@scure/base": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
|
||||
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/btc-signer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz",
|
||||
"integrity": "sha512-ZXZ08sZqSZKEcOuEQnxTF66ouHtl6+UA6U/QfQM06K9WiOlEkXF4LviZCaSgkdiFh9cyMt9+xdup7JtEv3p0fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~2.2.0",
|
||||
"@noble/hashes": "~2.2.0",
|
||||
"@scure/base": "~2.2.0",
|
||||
"micro-packed": "~0.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/btc-signer/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/btc-signer/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/browser-utils": {
|
||||
"version": "10.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.42.0.tgz",
|
||||
@@ -6609,17 +6456,6 @@
|
||||
"integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@webbtc/webln-types": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@webbtc/webln-types/-/webln-types-3.0.0.tgz",
|
||||
"integrity": "sha512-aXfTHLKz5lysd+6xTeWl+qHNh/p3qVYbeLo+yDN5cUDmhie2ZoGvkppfWxzbGkcFBzb6dJyQ2/i2cbmDHas+zQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "lightning",
|
||||
"url": "lightning:hello@getalby.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
@@ -6912,7 +6748,7 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6929,21 +6765,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.9.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
|
||||
"integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/big-integer": {
|
||||
"version": "1.6.52",
|
||||
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
||||
@@ -6967,53 +6788,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl/node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/blurhash": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
|
||||
@@ -7708,32 +7482,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -7794,7 +7542,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7924,16 +7672,6 @@
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz",
|
||||
@@ -8223,16 +7961,6 @@
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -8360,13 +8088,6 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -8432,13 +8153,6 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
|
||||
@@ -8510,13 +8224,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -8765,27 +8472,6 @@
|
||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -8837,7 +8523,7 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
@@ -10005,30 +9691,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/micro-packed": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.9.0.tgz",
|
||||
"integrity": "sha512-gFdaWTxEXOwtSOcpxulO4AuXVtp3HWIRmB8eq8+3m1Zku0ubgva0UGpi03YhcvsTJasHngG9gTIUK5kHNKdesg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@scure/base": "~2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/micro-packed/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||
@@ -10625,19 +10287,6 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
@@ -10661,16 +10310,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
@@ -10694,13 +10333,6 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||
@@ -10749,13 +10381,6 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/native-run": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz",
|
||||
@@ -10789,19 +10414,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.89.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
|
||||
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||
@@ -10979,16 +10591,6 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
|
||||
@@ -11419,34 +11021,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -11735,17 +11309,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -11803,39 +11366,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/rc/node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/rc/node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
@@ -12118,7 +11648,7 @@
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
@@ -12787,7 +12317,7 @@
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -12841,7 +12371,7 @@
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -12893,53 +12423,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
@@ -13031,7 +12514,7 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
@@ -13271,43 +12754,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -13519,19 +12965,6 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -13924,7 +13357,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
@@ -14359,13 +13792,6 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "agora",
|
||||
"name": "eranos",
|
||||
"private": true,
|
||||
"version": "2.8.9",
|
||||
"version": "2.9.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -15,7 +15,6 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
@@ -44,7 +43,6 @@
|
||||
"@fontsource/pirata-one": "^5.2.8",
|
||||
"@fontsource/silkscreen": "^5.2.8",
|
||||
"@fontsource/special-elite": "^5.2.8",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@milkdown/core": "^7.20.0",
|
||||
"@milkdown/ctx": "^7.20.0",
|
||||
@@ -57,8 +55,9 @@
|
||||
"@milkdown/prose": "^7.20.0",
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/curves": "^1.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@nostrify/nostrify": "^0.52.2",
|
||||
"@nostrify/react": "^0.6.2",
|
||||
"@nostrify/types": "^0.37.0",
|
||||
@@ -86,10 +85,6 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@scure/bip32": "^2.2.0",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@scure/btc-signer": "^2.2.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
@@ -149,7 +144,6 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
## [2.9.1] - 2026-06-27
|
||||
|
||||
The Venezuela earthquake relief appeal now rallies behind every campaign on the ground, not just one. The home banner and relief page showcase a live, swipeable carousel of all Venezuelan relief efforts, with a running total of everything raised so far — so you can pick exactly who to help.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Venezuela relief appeal now showcases every Venezuelan relief campaign in a live, auto-scrolling carousel you can swipe through, with a combined raised total across all of them, instead of featuring a single campaign.
|
||||
|
||||
## [2.9.0] - 2026-06-25
|
||||
|
||||
A big one. Private messaging arrives with a fast, searchable inbox you can reply to in your own language. Campaign organizers can now get verified by trusted reviewers through a guided sign-up, and verified badges appear right on campaigns. There's a new Venezuela earthquake relief appeal that takes you straight to donations, plus optional Tor routing on Android, separate Public and Private wallets, faster donation scanning, and refreshed profiles, settings, and login.
|
||||
|
||||
### Added
|
||||
|
||||
- Private direct messages: a dedicated inbox you can search by name, start new chats with inline recipient lookup, page back through old conversations, and read incoming messages translated into your language.
|
||||
- Get verified: a guided sign-up for trusted reviewers to set up a verifier profile, publish a public "how we verify" statement, and vouch for campaigns. Verified badges now show on campaign pages, and a short tutorial walks you through it.
|
||||
- A Venezuela earthquake relief appeal — a home-page banner, a one-time popup, and a shareable page that bakes in the relief campaign so you can read its story and donate without leaving.
|
||||
- Optional Tor routing on Android for added privacy.
|
||||
- Separate Public and Private wallets, keeping your spending and your private silent-payment funds cleanly apart.
|
||||
- A "Don't have Bitcoin?" prompt on campaign pages that points first-time donors to Cash App.
|
||||
- An always-visible language switcher in the top navigation, and a corporate sponsorship page.
|
||||
|
||||
### Changed
|
||||
|
||||
- Redesigned profiles with cleaner stats, a merged campaigns view, and a themed raised total.
|
||||
- Redesigned Settings into an Apple-style grouped layout.
|
||||
- Reworked the login and onboarding flow, including a new welcome screen built around the Agora brand.
|
||||
- Silent-payment donations now scan faster and keep working in the background, with a progress bar on the private wallet.
|
||||
- The home page now shows every featured campaign instead of capping the list.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The audio, music, and podcast pages no longer crash.
|
||||
- Backfilled and corrected translations across all sixteen languages.
|
||||
|
||||
## [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.
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 414 B After Width: | Height: | Size: 448 B |
|
Before Width: | Height: | Size: 985 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 303 B After Width: | Height: | Size: 5.4 KiB |
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "Agora",
|
||||
"short_name": "Agora",
|
||||
"name": "Eranos",
|
||||
"short_name": "Eranos",
|
||||
"description": "Power to the people. Organize, create, and connect across the open Nostr network.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0c14",
|
||||
"theme_color": "#ff6600",
|
||||
"theme_color": "#faa805",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
@@ -38,12 +38,6 @@
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"related_applications": [
|
||||
{
|
||||
"platform": "play",
|
||||
"url": "https://play.google.com/store/apps/details?id=spot.agora.app",
|
||||
"id": "spot.agora.app"
|
||||
}
|
||||
],
|
||||
"related_applications": [],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 113 KiB |
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Agora Service Worker
|
||||
* Eranos Service Worker
|
||||
*
|
||||
* Handles incoming Web Push notifications from the nostr-push server and
|
||||
* opens/focuses the app when the user taps a notification.
|
||||
@@ -14,17 +14,17 @@ self.addEventListener('push', (event) => {
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
payload = { title: 'Agora', body: event.data.text() };
|
||||
payload = { title: 'Eranos', body: event.data.text() };
|
||||
}
|
||||
|
||||
const title = payload.title ?? 'Agora';
|
||||
const title = payload.title ?? 'Eranos';
|
||||
const options = {
|
||||
body: payload.body ?? '',
|
||||
icon: payload.icon ?? '/icon-192.png',
|
||||
badge: payload.badge ?? '/icon-192.png',
|
||||
data: payload.data ?? {},
|
||||
requireInteraction: false,
|
||||
tag: payload.data?.subscription_id ?? 'agora-notification',
|
||||
tag: payload.data?.subscription_id ?? 'eranos-notification',
|
||||
renotify: true,
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
// Focus an existing Agora tab if one is open
|
||||
// Focus an existing Eranos tab if one is open
|
||||
for (const client of clientList) {
|
||||
if (new URL(client.url).origin === self.location.origin) {
|
||||
client.navigate('/notifications');
|
||||
@@ -58,7 +58,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
// --- Activate immediately ---
|
||||
//
|
||||
// On activate:
|
||||
// 1. Wipe every Cache Storage entry. A previous version of Agora deployed
|
||||
// 1. Wipe every Cache Storage entry. A previous version of Eranos deployed
|
||||
// a precaching service worker (Workbox-style) that's still serving stale
|
||||
// HTML/JS to returning users on this origin. Clearing caches means future
|
||||
// requests bypass anything the old SW left behind.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// preloader background before first paint. Runs as a blocking <script> so
|
||||
// there's no flash of the wrong theme.
|
||||
//
|
||||
// Agora's colors are hardcoded in src/index.css via :root {} and .dark {}
|
||||
// Eranos's colors are hardcoded in src/index.css via :root {} and .dark {}
|
||||
// blocks. There is no custom-theme branch; the only thing this script
|
||||
// does is set the right class on <html> and paint the preloader with the
|
||||
// matching background + primary color so the page doesn't flash white
|
||||
|
||||
@@ -55,16 +55,17 @@ COLORED_SVG="$TMPDIR/logo_colored.svg"
|
||||
RAW_PNG="$TMPDIR/raw.png"
|
||||
MASTER_PNG="$TMPDIR/master.png"
|
||||
|
||||
# Recolor the SVG's black fill to the brand color, on transparent background.
|
||||
# The phoenix logo.svg carries its own brand yellow (#fcd414); the sed only
|
||||
# recolors legacy black-fill sources, so it's a no-op for the phoenix.
|
||||
sed 's/fill="black"/fill="'"$LOGO_COLOR"'"/g' "$SOURCE_SVG" > "$COLORED_SVG"
|
||||
|
||||
# The SVG's viewBox is 720x880 (taller than wide). Render at its native
|
||||
# aspect ratio first so we don't squish the logo horizontally.
|
||||
# The SVG's viewBox is 1446x1246 (wider than tall). Render at its native
|
||||
# aspect ratio first so we don't squish the logo vertically.
|
||||
MASTER_BOX=512 # final square canvas size
|
||||
MASTER_H=$MASTER_BOX # render the longer side at full size
|
||||
MASTER_W=$(( MASTER_BOX * 720 / 880 )) # preserve 720:880 aspect
|
||||
MASTER_W=$MASTER_BOX # render the longer side at full size
|
||||
MASTER_H=$(( MASTER_BOX * 1246 / 1446 )) # preserve 1446:1246 aspect
|
||||
|
||||
echo "Rendering ${MASTER_W}x${MASTER_H} from $SOURCE_SVG (preserving 720:880 aspect)..."
|
||||
echo "Rendering ${MASTER_W}x${MASTER_H} from $SOURCE_SVG (preserving 1446:1246 aspect)..."
|
||||
if [ "$SVG_RENDERER" = "inkscape" ]; then
|
||||
inkscape --export-type=png --export-filename="$RAW_PNG" \
|
||||
-w "$MASTER_W" -h "$MASTER_H" --export-background-opacity=0 \
|
||||
|
||||
@@ -48,11 +48,12 @@ TMPDIR=$(mktemp -d)
|
||||
LOGO_WHITE_SVG="$TMPDIR/logo_white.svg"
|
||||
LOGO_WHITE="$TMPDIR/logo_white.png"
|
||||
|
||||
# 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.
|
||||
# Recolor the SVG fill to white before rasterizing. The phoenix logo.svg
|
||||
# declares fill="#fcd414"; older sources used black/purple, kept for safety.
|
||||
sed -e 's/fill="black"/fill="#ffffff"/g' \
|
||||
-e 's/#000000/#ffffff/g' \
|
||||
-e 's/#7c52e0/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
|
||||
-e 's/#7c52e0/#ffffff/g' \
|
||||
-e 's/#fcd414/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
|
||||
|
||||
echo "Rendering white SVG (preserving aspect ratio)..."
|
||||
|
||||
@@ -66,6 +67,19 @@ else
|
||||
rsvg-convert -h 1024 "$LOGO_WHITE_SVG" -o "$LOGO_WHITE"
|
||||
fi
|
||||
|
||||
# Orange-fill render, for marks placed on a white/light background (iOS splash).
|
||||
LOGO_ORANGE_SVG="$TMPDIR/logo_orange.svg"
|
||||
LOGO_ORANGE="$TMPDIR/logo_orange.png"
|
||||
sed -e 's/fill="black"/fill="'"$BG_COLOR"'"/g' \
|
||||
-e 's/#000000/'"$BG_COLOR"'/g' \
|
||||
-e 's/#7c52e0/'"$BG_COLOR"'/g' \
|
||||
-e 's/#fcd414/'"$BG_COLOR"'/g' "$SOURCE_SVG" > "$LOGO_ORANGE_SVG"
|
||||
if [ "$SVG_RENDERER" = "inkscape" ]; then
|
||||
inkscape --export-type=png --export-filename="$LOGO_ORANGE" -h 1024 "$LOGO_ORANGE_SVG" 2>/dev/null
|
||||
else
|
||||
rsvg-convert -h 1024 "$LOGO_ORANGE_SVG" -o "$LOGO_ORANGE"
|
||||
fi
|
||||
|
||||
# ── Adaptive icon foreground PNGs (transparent bg, white logo, safe-zone padding) ──
|
||||
# Content at 47% of canvas to fit within Android's adaptive icon safe zone.
|
||||
|
||||
@@ -133,12 +147,44 @@ make_legacy_square 96 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
|
||||
make_legacy_square 144 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
|
||||
make_legacy_square 192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
|
||||
|
||||
# Web logo.png (AppDownloadNudge) uses the same round brand-mark style.
|
||||
make_legacy_round 512 public/logo.png
|
||||
|
||||
# PWA install icons (manifest.webmanifest "any" + "maskable" purposes).
|
||||
make_legacy_round 192 public/icon-192.png
|
||||
make_legacy_round 512 public/icon-512.png
|
||||
|
||||
make_legacy_round 48 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
|
||||
make_legacy_round 72 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
|
||||
make_legacy_round 96 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
|
||||
make_legacy_round 144 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
|
||||
make_legacy_round 192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
|
||||
|
||||
# ── Android push-notification status-bar icon (ic_stat_ditto) ──
|
||||
# Must be a flat white silhouette on transparent — the OS tints it, any
|
||||
# color/gradient gets ignored or looks wrong. Referenced directly by
|
||||
# NostrPoller.java / NotificationRelayService.java via R.drawable.ic_stat_ditto.
|
||||
|
||||
echo "Generating notification status-bar icon (ic_stat_ditto)..."
|
||||
|
||||
make_notification_icon() {
|
||||
local size=$1
|
||||
local content_size=$(echo "$size * 80 / 100" | bc)
|
||||
local dest=$2
|
||||
$MAGICK -size "${size}x${size}" "xc:none" \
|
||||
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
|
||||
-gravity center -compose over -composite \
|
||||
"$dest"
|
||||
}
|
||||
|
||||
for dir in drawable mipmap; do
|
||||
make_notification_icon 24 android/app/src/main/res/${dir}-mdpi/ic_stat_ditto.png
|
||||
make_notification_icon 48 android/app/src/main/res/${dir}-xhdpi/ic_stat_ditto.png
|
||||
make_notification_icon 72 android/app/src/main/res/${dir}-xxhdpi/ic_stat_ditto.png
|
||||
make_notification_icon 96 android/app/src/main/res/${dir}-xxxhdpi/ic_stat_ditto.png
|
||||
done
|
||||
make_notification_icon 36 android/app/src/main/res/drawable-hdpi/ic_stat_ditto.png
|
||||
|
||||
# Update background color
|
||||
BACKGROUND_COLOR_FILE="android/app/src/main/res/values/ic_launcher_background.xml"
|
||||
mkdir -p android/app/src/main/res/values
|
||||
@@ -167,6 +213,26 @@ else
|
||||
echo -e " ${YELLOW}Skipped: $IOS_ICON_DIR not found${NC}"
|
||||
fi
|
||||
|
||||
# ── iOS launch screen (Splash.imageset, 2732x2732, white bg + small mark) ──
|
||||
# Referenced by LaunchScreen.storyboard. Ships one image for all 3 scale
|
||||
# slots (1x/2x/3x), matching how the project already had it set up.
|
||||
|
||||
echo "Generating iOS launch screen..."
|
||||
|
||||
IOS_SPLASH_DIR="ios/App/App/Assets.xcassets/Splash.imageset"
|
||||
|
||||
if [ -d "$IOS_SPLASH_DIR" ]; then
|
||||
$MAGICK -size "2732x2732" "xc:white" \
|
||||
\( "$LOGO_ORANGE" -resize "160x160" \) \
|
||||
-gravity center -compose over -composite \
|
||||
"$IOS_SPLASH_DIR/splash-2732x2732.png"
|
||||
cp "$IOS_SPLASH_DIR/splash-2732x2732.png" "$IOS_SPLASH_DIR/splash-2732x2732-1.png"
|
||||
cp "$IOS_SPLASH_DIR/splash-2732x2732.png" "$IOS_SPLASH_DIR/splash-2732x2732-2.png"
|
||||
echo -e " ${GREEN}✓${NC} $IOS_SPLASH_DIR (3 files)"
|
||||
else
|
||||
echo -e " ${YELLOW}Skipped: $IOS_SPLASH_DIR not found${NC}"
|
||||
fi
|
||||
|
||||
# Cleanup temp files
|
||||
rm -rf "$TMPDIR"
|
||||
|
||||
|
||||
@@ -20,9 +20,7 @@ import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import { useTor } from "@/hooks/useTor";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { OnboardingProvider } from "@/contexts/OnboardingProvider";
|
||||
import { HdWalletSpProvider } from "@/contexts/HdWalletSpProvider";
|
||||
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import AppRouter from "./AppRouter";
|
||||
@@ -43,8 +41,8 @@ const queryClient = new QueryClient({
|
||||
|
||||
/** Hardcoded fallback values. Always provides every required field. */
|
||||
const hardcodedConfig: AppConfig = {
|
||||
appName: "Agora",
|
||||
appId: "agora",
|
||||
appName: "Eranos",
|
||||
appId: "eranos",
|
||||
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
|
||||
homePage: "campaigns",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkzem0wfssdl264k",
|
||||
@@ -61,7 +59,6 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeReactions: false,
|
||||
feedIncludeZaps: true,
|
||||
feedIncludeArticles: true,
|
||||
showArticles: true,
|
||||
showHighlights: true,
|
||||
@@ -121,7 +118,6 @@ const hardcodedConfig: AppConfig = {
|
||||
"feed",
|
||||
"communities",
|
||||
"world",
|
||||
"wallet",
|
||||
"agent",
|
||||
"messages",
|
||||
"profile",
|
||||
@@ -151,13 +147,6 @@ const hardcodedConfig: AppConfig = {
|
||||
lowBandwidthMode: false,
|
||||
torEnabled: false,
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
esploraApis: [
|
||||
'https://mempool.emzy.de/api',
|
||||
'https://mempool.space/api',
|
||||
'https://blockstream.info/api',
|
||||
],
|
||||
blockbookBaseUrl: 'https://btc.trezor.io',
|
||||
bip352IndexerUrl: 'https://silentpayments.dev/blindbit/mainnet',
|
||||
sidebarWidgets: [
|
||||
{ id: 'trends' },
|
||||
{ id: 'hot-posts' },
|
||||
@@ -168,6 +157,13 @@ const hardcodedConfig: AppConfig = {
|
||||
aiModel: 'google/gemma-4-26b',
|
||||
aiSystemPrompt: '',
|
||||
translateWorkerUrl: import.meta.env.VITE_TRANSLATE_WORKER_URL || '',
|
||||
// Grin payments (Plan 2, C1). The GoblinPay instance URL/token are
|
||||
// deployment-specific and land via build config (APP_CONFIG) or env;
|
||||
// empty disables the in-app GoblinPay path. The node is read-only
|
||||
// (kernel lookups for the payment-proof tally).
|
||||
goblinPayUrl: import.meta.env.VITE_GOBLINPAY_URL || '',
|
||||
goblinPayApiToken: import.meta.env.VITE_GOBLINPAY_API_TOKEN || '',
|
||||
grinNodeUrl: import.meta.env.VITE_GRIN_NODE_URL || 'https://api.grin.money',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -231,17 +227,13 @@ export function App() {
|
||||
<InitialSyncRunner />
|
||||
<NativeNotifications />
|
||||
|
||||
<NWCProvider>
|
||||
<OnboardingProvider>
|
||||
<TooltipProvider>
|
||||
<HdWalletSpProvider>
|
||||
<AudioPlayerProvider>
|
||||
<AppRouter />
|
||||
</AudioPlayerProvider>
|
||||
</HdWalletSpProvider>
|
||||
</TooltipProvider>
|
||||
</OnboardingProvider>
|
||||
</NWCProvider>
|
||||
<OnboardingProvider>
|
||||
<TooltipProvider>
|
||||
<AudioPlayerProvider>
|
||||
<AppRouter />
|
||||
</AudioPlayerProvider>
|
||||
</TooltipProvider>
|
||||
</OnboardingProvider>
|
||||
</RelayProvider>
|
||||
</NostrLoginProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -39,10 +39,12 @@ const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({
|
||||
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
|
||||
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
|
||||
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
|
||||
const MessagesPage = lazy(() => import("./pages/MessagesPage").then(m => ({ default: m.MessagesPage })));
|
||||
const MyDashboardPage = lazy(() => import("./pages/MyDashboardPage").then(m => ({ default: m.MyDashboardPage })));
|
||||
const AboutPage = lazy(() => import("./pages/AboutPage").then(m => ({ default: m.AboutPage })));
|
||||
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
|
||||
const RecipientGuidePage = lazy(() => import("./pages/RecipientGuidePage").then(m => ({ default: m.RecipientGuidePage })));
|
||||
const CorporateSponsorshipPage = lazy(() => import("./pages/CorporateSponsorshipPage").then(m => ({ default: m.CorporateSponsorshipPage })));
|
||||
const LanguageSettingsPage = lazy(() => import("./pages/LanguageSettingsPage").then(m => ({ default: m.LanguageSettingsPage })));
|
||||
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
|
||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||
@@ -55,12 +57,6 @@ const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m
|
||||
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
|
||||
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
|
||||
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
|
||||
const WalletMigrateV1Page = lazy(() => import("./pages/WalletMigrateV1Page").then(m => ({ default: m.WalletMigrateV1Page })));
|
||||
const WalletDoubleTweakFixPage = lazy(() => import("./pages/WalletDoubleTweakFixPage").then(m => ({ default: m.WalletDoubleTweakFixPage })));
|
||||
const WalletRecoveryPage = lazy(() => import("./pages/WalletRecoveryPage").then(m => ({ default: m.WalletRecoveryPage })));
|
||||
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const LegacyWalletRecoveryPage = lazy(() => import("./pages/LegacyWalletRecoveryPage").then(m => ({ default: m.LegacyWalletRecoveryPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
|
||||
@@ -99,7 +95,8 @@ function SiteFooter() {
|
||||
</button>
|
||||
<nav className="flex items-center gap-5">
|
||||
<Link to="/about" className="hover:text-foreground motion-safe:transition-colors">{t('nav.about')}</Link>
|
||||
<Link to="/organizations" className="hover:text-foreground motion-safe:transition-colors">{t('nav.organizations')}</Link>
|
||||
<Link to="/sponsors" className="hover:text-foreground motion-safe:transition-colors">{t('nav.sponsors')}</Link>
|
||||
<Link to="/verify" className="hover:text-foreground motion-safe:transition-colors">{t('nav.verify')}</Link>
|
||||
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">{t('nav.privacy')}</Link>
|
||||
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">{t('nav.safety')}</Link>
|
||||
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">{t('nav.changelog')}</Link>
|
||||
@@ -116,18 +113,18 @@ function SiteFooter() {
|
||||
* form/prose-style pages, wide (full width) for landing / dashboard / detail
|
||||
* pages that render their own internal layout.
|
||||
*/
|
||||
function FundraiserLayout({ narrow }: { narrow: boolean }) {
|
||||
function FundraiserLayout({ narrow, hideFooter }: { narrow: boolean; hideFooter?: boolean }) {
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col bg-background">
|
||||
<div className={cn('flex flex-col bg-background', hideFooter ? 'h-dvh overflow-hidden' : 'min-h-dvh')}>
|
||||
<TopNav />
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<div
|
||||
className={cn("flex-1 min-w-0 w-full mx-auto", narrow && "max-w-3xl")}
|
||||
className={cn('min-w-0 w-full flex-1 mx-auto', hideFooter && 'min-h-0', narrow && 'max-w-3xl')}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Suspense>
|
||||
<SiteFooter />
|
||||
{!hideFooter && <SiteFooter />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,26 +156,9 @@ export function AppRouter() {
|
||||
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
|
||||
<Route path="/settings/language" element={<LanguageSettingsPage />} />
|
||||
<Route path="/settings/profile" element={<ProfileSettings />} />
|
||||
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
|
||||
<Route path="/settings/notifications" element={<NotificationSettings />} />
|
||||
<Route path="/settings/advanced" element={<AdvancedSettingsPage />} />
|
||||
<Route path="/settings/network" element={<NetworkSettingsPage />} />
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/wallet/legacy" element={<LegacyWalletRecoveryPage />} />
|
||||
{/* Old nested paths kept as redirects so any existing links / muscle
|
||||
memory still land on the right page. `/wallet/settings` was an
|
||||
intermediate hub that has been replaced by an overflow menu on
|
||||
`/wallet`, so it redirects to the wallet home. `/wallet/backup`
|
||||
is now an in-page dialog opened from that menu, so it also
|
||||
redirects home. */}
|
||||
<Route path="/wallet/settings" element={<Navigate to="/wallet" replace />} />
|
||||
<Route path="/wallet/backup" element={<Navigate to="/wallet" replace />} />
|
||||
<Route path="/wallet/settings/backup" element={<Navigate to="/wallet" replace />} />
|
||||
<Route path="/wallet/settings/legacy" element={<Navigate to="/wallet/legacy" replace />} />
|
||||
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
|
||||
<Route path="/wallet/migrate-v1" element={<WalletMigrateV1Page />} />
|
||||
<Route path="/wallet/double-tweak-fix" element={<WalletDoubleTweakFixPage />} />
|
||||
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
|
||||
{/* Legacy /help routes redirect to /about so existing links keep
|
||||
working. The About page and the two guides themselves live
|
||||
under the wide layout below. */}
|
||||
@@ -190,13 +170,17 @@ export function AppRouter() {
|
||||
<Route path="/safety" element={<CSAEPolicyPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
<Route path="/organizers" element={<OrganizersPage />} />
|
||||
{/* `/settings/verifier` moved to the public `/organizations` onboarding
|
||||
{/* `/settings/verifier` moved to the public `/verify` onboarding
|
||||
page. Keep the old path as a redirect so existing links resolve. */}
|
||||
<Route path="/settings/verifier" element={<Navigate to="/organizations" replace />} />
|
||||
<Route path="/settings/verifier" element={<Navigate to="/verify" replace />} />
|
||||
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
|
||||
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<FundraiserLayout narrow={false} hideFooter />}>
|
||||
<Route path="/messages" element={<MessagesPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Wide layout — no max-width on the center column. Used by landing /
|
||||
list / detail pages that render their own internal width
|
||||
constraints. */}
|
||||
@@ -221,9 +205,13 @@ export function AppRouter() {
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/about/donors" element={<DonorGuidePage />} />
|
||||
<Route path="/about/recipients" element={<RecipientGuidePage />} />
|
||||
{/* Organizations onboarding / marketing page. Wide layout so the
|
||||
{/* Corporate sponsorship / partnership marketing page. Wide layout
|
||||
so the hero and section backgrounds span the viewport like /about. */}
|
||||
<Route path="/sponsors" element={<CorporateSponsorshipPage />} />
|
||||
{/* Verification onboarding / marketing page. Wide layout so the
|
||||
hero and section backgrounds can span the viewport like /about. */}
|
||||
<Route path="/organizations" element={<OrganizationsPage />} />
|
||||
<Route path="/verify" element={<OrganizationsPage />} />
|
||||
<Route path="/organizations" element={<Navigate to="/verify" replace />} />
|
||||
{/* Legacy URL: the recipient guide lived at `/about/activists`
|
||||
before the "activist" → "recipient" copy change. Redirect so
|
||||
external links and bookmarks still resolve. */}
|
||||
|
||||
@@ -5,11 +5,10 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Camera, Clock, DollarSign, Info, Megaphone, Palette } from 'lucide-react';
|
||||
|
||||
import { parseAction, type Action } from '@/hooks/useActions';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { countryCodeToFlag, getGeoDisplayName } from '@/lib/countries';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
||||
import { formatSats, satsToUSDWhole } from '@/lib/bitcoin';
|
||||
import { formatPledgeAmount } from '@/lib/pledges';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ACTION_ICONS = {
|
||||
@@ -28,7 +27,6 @@ function actionNaddr(action: Action): string {
|
||||
}
|
||||
|
||||
export function ActionContent({ event, compact = true }: { event: NostrEvent; compact?: boolean }) {
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const action = parseAction(event);
|
||||
if (!action) return null;
|
||||
|
||||
@@ -98,9 +96,8 @@ export function ActionContent({ event, compact = true }: { event: NostrEvent; co
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<DollarSign className="size-4 shrink-0 text-primary" />
|
||||
<span className="font-semibold">
|
||||
{btcPrice ? satsToUSDWhole(action.bounty, btcPrice) : `${formatSats(action.bounty)} sats`}
|
||||
{formatPledgeAmount(action.bounty)}
|
||||
</span>
|
||||
{btcPrice && <span className="text-xs text-muted-foreground">~{formatSats(action.bounty)} sats</span>}
|
||||
{action.countryCode && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
|
||||
@@ -20,26 +20,6 @@ const DEFAULT_AI_MODEL = 'google/gemma-4-26b';
|
||||
/** Build-time default translation worker URL from the environment variable. */
|
||||
const DEFAULT_TRANSLATE_WORKER_URL = import.meta.env.VITE_TRANSLATE_WORKER_URL || '';
|
||||
|
||||
/** Hardcoded defaults for the Bitcoin backend fields. Used for reset buttons. */
|
||||
const DEFAULT_ESPLORA_APIS = [
|
||||
'https://mempool.emzy.de/api',
|
||||
'https://mempool.space/api',
|
||||
'https://blockstream.info/api',
|
||||
];
|
||||
const DEFAULT_BLOCKBOOK_BASE_URL = 'https://btc.trezor.io';
|
||||
const DEFAULT_BIP352_INDEXER_URL = 'https://silentpayments.dev/blindbit/mainnet';
|
||||
|
||||
/** Validate an http(s) URL with no trailing slash. */
|
||||
function isValidEndpoint(url: string): boolean {
|
||||
if (!/^https?:\/\//i.test(url)) return false;
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The build-time default DSN from the environment variable. */
|
||||
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
|
||||
|
||||
@@ -51,7 +31,6 @@ export function AdvancedSettings() {
|
||||
const [systemOpen, setSystemOpen] = useState(true);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
const [sentryOpen, setSentryOpen] = useState(false);
|
||||
const [bitcoinOpen, setBitcoinOpen] = useState(false);
|
||||
const [dangerOpen, setDangerOpen] = useState(false);
|
||||
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
|
||||
const [statsPubkey, setStatsPubkey] = useState(config.nip85StatsPubkey);
|
||||
@@ -66,15 +45,6 @@ export function AdvancedSettings() {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [systemPromptDraft, setSystemPromptDraft] = useState(config.aiSystemPrompt || DEFAULT_SYSTEM_PROMPT_TEMPLATE);
|
||||
|
||||
// Bitcoin backend drafts. `esploraApis` is an ordered array edited as one URL per line.
|
||||
const [esploraApisDraft, setEsploraApisDraft] = useState(config.esploraApis.join('\n'));
|
||||
const [blockbookDraft, setBlockbookDraft] = useState(config.blockbookBaseUrl);
|
||||
const [bip352Draft, setBip352Draft] = useState(config.bip352IndexerUrl);
|
||||
|
||||
useEffect(() => { setEsploraApisDraft(config.esploraApis.join('\n')); }, [config.esploraApis]);
|
||||
useEffect(() => { setBlockbookDraft(config.blockbookBaseUrl); }, [config.blockbookBaseUrl]);
|
||||
useEffect(() => { setBip352Draft(config.bip352IndexerUrl); }, [config.bip352IndexerUrl]);
|
||||
|
||||
useEffect(() => { setBaseUrlDraft(config.aiBaseURL); }, [config.aiBaseURL]);
|
||||
useEffect(() => { setApiKeyDraft(config.aiApiKey); }, [config.aiApiKey]);
|
||||
useEffect(() => { setModelDraft(config.aiModel); }, [config.aiModel]);
|
||||
@@ -148,67 +118,6 @@ export function AdvancedSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const commitEsploraApis = () => {
|
||||
const urls = esploraApisDraft
|
||||
.split('\n')
|
||||
.map((line) => line.trim().replace(/\/+$/, ''))
|
||||
.filter(Boolean);
|
||||
if (urls.length === 0) {
|
||||
setEsploraApisDraft(DEFAULT_ESPLORA_APIS.join('\n'));
|
||||
updateConfig((current) => ({ ...current, esploraApis: DEFAULT_ESPLORA_APIS }));
|
||||
toast({ title: 'Esplora endpoints reset to defaults' });
|
||||
return;
|
||||
}
|
||||
const invalid = urls.find((url) => !isValidEndpoint(url));
|
||||
if (invalid) {
|
||||
toast({ title: 'Invalid Esplora endpoint', description: invalid, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const changed =
|
||||
urls.length !== config.esploraApis.length ||
|
||||
urls.some((url, i) => url !== config.esploraApis[i]);
|
||||
if (changed) {
|
||||
updateConfig((current) => ({ ...current, esploraApis: urls }));
|
||||
toast({ title: 'Esplora endpoints updated' });
|
||||
}
|
||||
// Normalize the textarea to the cleaned list.
|
||||
setEsploraApisDraft(urls.join('\n'));
|
||||
};
|
||||
|
||||
const commitBlockbook = () => {
|
||||
const trimmed = blockbookDraft.trim().replace(/\/+$/, '');
|
||||
if (!trimmed) {
|
||||
setBlockbookDraft(DEFAULT_BLOCKBOOK_BASE_URL);
|
||||
if (config.blockbookBaseUrl !== DEFAULT_BLOCKBOOK_BASE_URL) {
|
||||
updateConfig((current) => ({ ...current, blockbookBaseUrl: DEFAULT_BLOCKBOOK_BASE_URL }));
|
||||
toast({ title: 'Blockbook URL reset to default' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isValidEndpoint(trimmed)) {
|
||||
toast({ title: 'Invalid Blockbook URL', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (trimmed !== config.blockbookBaseUrl) {
|
||||
updateConfig((current) => ({ ...current, blockbookBaseUrl: trimmed }));
|
||||
toast({ title: 'Blockbook URL updated' });
|
||||
}
|
||||
setBlockbookDraft(trimmed);
|
||||
};
|
||||
|
||||
const commitBip352 = () => {
|
||||
const trimmed = bip352Draft.trim().replace(/\/+$/, '');
|
||||
if (trimmed && !isValidEndpoint(trimmed)) {
|
||||
toast({ title: 'Invalid indexer URL', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (trimmed !== config.bip352IndexerUrl) {
|
||||
updateConfig((current) => ({ ...current, bip352IndexerUrl: trimmed }));
|
||||
toast({ title: trimmed ? 'Silent-payment indexer updated' : 'Silent-payment scanning disabled' });
|
||||
}
|
||||
setBip352Draft(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Agent Section */}
|
||||
@@ -533,143 +442,6 @@ export function AdvancedSettings() {
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* Bitcoin Section */}
|
||||
<div>
|
||||
<Collapsible open={bitcoinOpen} onOpenChange={setBitcoinOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
||||
>
|
||||
<span className="text-base font-semibold">Bitcoin</span>
|
||||
{bitcoinOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pt-3 pb-4 space-y-5">
|
||||
|
||||
{/* Esplora API endpoints */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label htmlFor="esplora-apis" className="text-sm font-medium">
|
||||
Esplora API endpoints
|
||||
</Label>
|
||||
{esploraApisDraft.trim() !== DEFAULT_ESPLORA_APIS.join('\n') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
title="Restore to defaults"
|
||||
onClick={() => {
|
||||
setEsploraApisDraft(DEFAULT_ESPLORA_APIS.join('\n'));
|
||||
updateConfig((current) => ({ ...current, esploraApis: DEFAULT_ESPLORA_APIS }));
|
||||
toast({ title: 'Esplora endpoints reset to defaults' });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Esplora-compatible REST roots used for on-chain zaps, donations, and Bitcoin address/tx pages. One URL per line, no trailing slash. Tried in order with failover when an endpoint is rate-limited or down.
|
||||
</p>
|
||||
<Textarea
|
||||
id="esplora-apis"
|
||||
value={esploraApisDraft}
|
||||
onChange={(e) => setEsploraApisDraft(e.target.value)}
|
||||
onBlur={commitEsploraApis}
|
||||
placeholder={DEFAULT_ESPLORA_APIS.join('\n')}
|
||||
className="min-h-[88px] max-h-[200px] resize-y font-mono text-base md:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blockbook base URL */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label htmlFor="blockbook-url" className="text-sm font-medium">
|
||||
Blockbook API URL
|
||||
</Label>
|
||||
{blockbookDraft.trim() !== DEFAULT_BLOCKBOOK_BASE_URL && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
title="Restore to default"
|
||||
onClick={() => {
|
||||
setBlockbookDraft(DEFAULT_BLOCKBOOK_BASE_URL);
|
||||
updateConfig((current) => ({ ...current, blockbookBaseUrl: DEFAULT_BLOCKBOOK_BASE_URL }));
|
||||
toast({ title: 'Blockbook URL reset to default' });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Trezor Blockbook instance used by the HD wallet to scan balances and history. <span className="font-medium text-foreground/80">Privacy note:</span> the wallet's full xpub is sent to this server. Self-host for maximum privacy.
|
||||
</p>
|
||||
<Input
|
||||
id="blockbook-url"
|
||||
type="url"
|
||||
value={blockbookDraft}
|
||||
onChange={(e) => setBlockbookDraft(e.target.value)}
|
||||
onBlur={commitBlockbook}
|
||||
placeholder={DEFAULT_BLOCKBOOK_BASE_URL}
|
||||
className="font-mono text-base md:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* BIP-352 silent payment indexer */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label htmlFor="bip352-url" className="text-sm font-medium">
|
||||
Silent payment indexer
|
||||
</Label>
|
||||
{bip352Draft.trim() !== DEFAULT_BIP352_INDEXER_URL && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
title="Restore to default"
|
||||
onClick={() => {
|
||||
setBip352Draft(DEFAULT_BIP352_INDEXER_URL);
|
||||
updateConfig((current) => ({ ...current, bip352IndexerUrl: DEFAULT_BIP352_INDEXER_URL }));
|
||||
toast({ title: 'Silent-payment indexer reset to default' });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
BIP-352 tweak-data indexer (BlindBit Oracle) used to detect incoming silent payments (<code className="bg-muted px-1 rounded">sp1…</code>). Your scan key never leaves the device. Leave empty to disable silent-payment scanning.
|
||||
</p>
|
||||
<Input
|
||||
id="bip352-url"
|
||||
type="url"
|
||||
value={bip352Draft}
|
||||
onChange={(e) => setBip352Draft(e.target.value)}
|
||||
onBlur={commitBip352}
|
||||
placeholder={DEFAULT_BIP352_INDEXER_URL}
|
||||
className="font-mono text-base md:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* Error Reporting Section */}
|
||||
<div>
|
||||
<Collapsible open={sentryOpen} onOpenChange={setSentryOpen}>
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { CalendarClock, HandHeart, MapPin, Megaphone, ShieldCheck, Users } from 'lucide-react';
|
||||
import { CalendarClock, HandHeart, MapPin, Megaphone, Users } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useInView } from '@/hooks/useInView';
|
||||
import { parseAction } from '@/hooks/useActions';
|
||||
import { getGeoDisplayName } from '@/lib/countries';
|
||||
import { parseCampaign, getCampaignCountryLabel } from '@/lib/campaign';
|
||||
import { parseCommunityEvent } from '@/lib/communityUtils';
|
||||
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
|
||||
import { formatUsdGoal } from '@/lib/formatCampaignAmount';
|
||||
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -55,14 +51,8 @@ function InlineShell({
|
||||
}
|
||||
|
||||
export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
|
||||
const { t } = useTranslation();
|
||||
const campaign = parseCampaign(event);
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
// Defer the Esplora-backed donation lookup until the preview scrolls into
|
||||
// view — feeds can render many of these, and fetching all of them eagerly
|
||||
// contributed to the Esplora rate-limiting storm.
|
||||
const previewRef = useRef<HTMLAnchorElement>(null);
|
||||
const inView = useInView(previewRef);
|
||||
const { data: stats } = useCampaignDonations(campaign ?? undefined, { enabled: inView });
|
||||
const author = useAuthor(event.pubkey);
|
||||
if (!campaign) return null;
|
||||
|
||||
@@ -72,46 +62,27 @@ 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 isSilentPayment = !campaign.wallets.onchain;
|
||||
const goalLabel = campaign.goalUsd && campaign.goalUsd > 0 ? formatUsdGoal(campaign.goalUsd) : undefined;
|
||||
const raisedSats = stats?.totalSats ?? 0;
|
||||
const raisedLabel = isSilentPayment ? undefined : formatCampaignAmount(raisedSats, btcPrice);
|
||||
const raisedUsd = isSilentPayment ? undefined : satsToUsd(raisedSats, btcPrice);
|
||||
const progress = campaign.goalUsd && raisedUsd !== undefined
|
||||
? Math.min(100, Math.round((raisedUsd / campaign.goalUsd) * 100))
|
||||
: 0;
|
||||
const goalLabel = campaign.goalUsd && campaign.goalUsd > 0
|
||||
? t('campaignsDetail.target', { amount: formatUsdGoal(campaign.goalUsd) })
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Link ref={previewRef} to={`/${naddr}`} onClick={(e) => e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background">
|
||||
<Link to={`/${naddr}`} onClick={(e) => e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background">
|
||||
<InlineShell
|
||||
image={cover}
|
||||
fallbackIcon={<HandHeart className="size-12" />}
|
||||
title={campaign.title}
|
||||
description={campaign.story}
|
||||
meta={(
|
||||
<div className="space-y-2 pt-1">
|
||||
{campaign.goalUsd && !isSilentPayment ? (
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-foreground/15">
|
||||
<div className="h-full rounded-full bg-primary" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
||||
{isSilentPayment ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
{goalLabel ?? 'Private campaign'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-semibold text-foreground">
|
||||
{raisedLabel}<span className="font-normal text-muted-foreground"> {goalLabel ? `/ ${goalLabel}` : 'raised'}</span>
|
||||
</span>
|
||||
)}
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{countryLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-xs text-muted-foreground">
|
||||
{goalLabel && (
|
||||
<span className="font-semibold text-foreground">{goalLabel}</span>
|
||||
)}
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{countryLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
@@ -122,7 +93,6 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
|
||||
export function PledgeInlinePreview({ event }: { event: NostrEvent }) {
|
||||
const { t } = useTranslation();
|
||||
const pledge = parseAction(event);
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
if (!pledge) return null;
|
||||
|
||||
const naddr = nip19.naddrEncode({ kind: 36639, pubkey: pledge.pubkey, identifier: pledge.id });
|
||||
@@ -140,7 +110,7 @@ export function PledgeInlinePreview({ event }: { event: NostrEvent }) {
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-baseline gap-1.5">
|
||||
<span className="font-semibold uppercase tracking-wide text-primary">{t('pledges.card.pledged')}</span>
|
||||
<span className="text-sm font-bold text-foreground">{formatPledgeAmount(pledge.bounty, btcPrice)}</span>
|
||||
<span className="text-sm font-bold text-foreground">{formatPledgeAmount(pledge.bounty)}</span>
|
||||
</span>
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function BitcoinAmountPicker({
|
||||
usdAmount,
|
||||
onUsdAmountChange,
|
||||
presets,
|
||||
maxLabel = 'MAX',
|
||||
maxSelected = false,
|
||||
maxDisabled = false,
|
||||
onMaxSelect,
|
||||
insufficient = false,
|
||||
satsLabel,
|
||||
onAmountChangeStart,
|
||||
}: BitcoinAmountPickerProps) {
|
||||
const [editingAmount, setEditingAmount] = useState(false);
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingAmount) {
|
||||
amountInputRef.current?.focus();
|
||||
amountInputRef.current?.select();
|
||||
}
|
||||
}, [editingAmount]);
|
||||
|
||||
const commitAmountEdit = useCallback(() => {
|
||||
setEditingAmount(false);
|
||||
if (typeof usdAmount === 'string' && usdAmount.trim() === '') {
|
||||
onUsdAmountChange(0);
|
||||
}
|
||||
}, [onUsdAmountChange, usdAmount]);
|
||||
|
||||
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center pt-2">
|
||||
{editingAmount ? (
|
||||
<div className="flex items-baseline justify-center">
|
||||
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
|
||||
<input
|
||||
ref={amountInputRef}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={usdAmount}
|
||||
onChange={(e) => {
|
||||
onAmountChangeStart?.();
|
||||
onUsdAmountChange(e.target.value);
|
||||
}}
|
||||
onBlur={commitAmountEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitAmountEdit();
|
||||
}
|
||||
}}
|
||||
aria-label="Amount in USD"
|
||||
className={cn(
|
||||
'bg-transparent border-0 outline-none text-4xl font-semibold text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
|
||||
insufficient && 'text-destructive',
|
||||
)}
|
||||
style={{ width: `${Math.max(2, String(usdAmount).length + 1)}ch` }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{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 && (
|
||||
<span className="text-xs text-muted-foreground mt-1 tabular-nums">
|
||||
{satsLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
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);
|
||||
}
|
||||
}}
|
||||
className="grid grid-cols-5 gap-1 w-full"
|
||||
>
|
||||
{presets.map((preset) => (
|
||||
<ToggleGroupItem
|
||||
key={preset}
|
||||
value={String(preset)}
|
||||
className="h-8 min-w-0 text-xs font-semibold px-1"
|
||||
>
|
||||
${preset}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
<ToggleGroupItem
|
||||
value="max"
|
||||
disabled={maxDisabled}
|
||||
className="h-8 min-w-0 text-xs font-semibold px-1"
|
||||
>
|
||||
{maxLabel}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,641 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowDownLeft,
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
Bitcoin,
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Hash,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
Weight,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useBitcoinTx } from '@/hooks/useBitcoinTx';
|
||||
import { useBitcoinAddress } from '@/hooks/useBitcoinAddress';
|
||||
import { satsToBTC, satsToUSD, formatSats, formatBTC } from '@/lib/bitcoin';
|
||||
import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function truncateMiddle(str: string, startLen = 8, endLen = 8): string {
|
||||
if (str.length <= startLen + endLen + 3) return str;
|
||||
return `${str.slice(0, startLen)}...${str.slice(-endLen)}`;
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// clipboard not available
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Format a unix timestamp as a readable date string. */
|
||||
function formatBlockTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Format a large number with locale separators. */
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bitcoin Transaction Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BitcoinTxHeader({ txid }: { txid: string }) {
|
||||
const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid);
|
||||
|
||||
if (isLoading) return <TxSkeleton />;
|
||||
|
||||
if (error || !tx) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
|
||||
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
|
||||
<p className="text-sm text-destructive">Failed to load transaction</p>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">{txid}</p>
|
||||
<a
|
||||
href={`https://mempool.space/tx/${txid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bitcoin className="size-3.5" />
|
||||
<span>View on mempool.space</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center size-10 rounded-full ${
|
||||
tx.confirmed
|
||||
? 'bg-green-500/10 text-green-600 dark:text-green-400'
|
||||
: 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
|
||||
}`}>
|
||||
{tx.confirmed ? <Check className="size-5" /> : <Clock className="size-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">
|
||||
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
|
||||
</h2>
|
||||
{tx.blockTime && (
|
||||
<p className="text-sm text-muted-foreground">{formatBlockTime(tx.blockTime)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction ID */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transaction ID</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-mono text-foreground break-all">{tx.txid}</p>
|
||||
<CopyButton text={tx.txid} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{tx.confirmed && tx.blockHeight !== undefined && (
|
||||
<StatCard icon={<Layers className="size-3.5" />} label="Block" value={formatNumber(tx.blockHeight)} />
|
||||
)}
|
||||
<StatCard icon={<Weight className="size-3.5" />} label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
|
||||
<StatCard
|
||||
icon={<Bitcoin className="size-3.5" />}
|
||||
label="Fee"
|
||||
value={`${formatSats(tx.fee)} sat`}
|
||||
subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Hash className="size-3.5" />}
|
||||
label="Amount"
|
||||
value={`${formatBTC(tx.totalOutput)} BTC`}
|
||||
subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inputs → Outputs flow */}
|
||||
<div className="border-t border-border">
|
||||
<TxFlow tx={tx} btcPrice={btcPrice} />
|
||||
</div>
|
||||
|
||||
{/* Footer: link to mempool.space */}
|
||||
<div className="border-t border-border px-5 py-2.5">
|
||||
<a
|
||||
href={`https://mempool.space/tx/${txid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bitcoin className="size-3.5" />
|
||||
<span>View on mempool.space</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-secondary/40 px-3.5 py-2.5 space-y-0.5">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold">{value}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inputs → Outputs visualization, mempool.space-style. */
|
||||
function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
||||
<span>{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}</span>
|
||||
<ArrowRight className="size-3" />
|
||||
<span>{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Inputs */}
|
||||
<div className="space-y-1.5">
|
||||
{tx.inputs.slice(0, 10).map((input, i) => (
|
||||
<TxInputRow key={`${input.txid}-${input.vout}-${i}`} input={input} btcPrice={btcPrice} />
|
||||
))}
|
||||
{tx.inputs.length > 10 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-1">
|
||||
+{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outputs */}
|
||||
<div className="space-y-1.5">
|
||||
{tx.outputs.slice(0, 10).map((output, i) => (
|
||||
<TxOutputRow key={`${output.address ?? 'op_return'}-${i}`} output={output} btcPrice={btcPrice} />
|
||||
))}
|
||||
{tx.outputs.length > 10 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-1">
|
||||
+{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
|
||||
if (input.isCoinbase) {
|
||||
return (
|
||||
<div className="rounded-lg bg-amber-500/5 border border-amber-500/20 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">Coinbase</span>
|
||||
<span className="text-xs font-mono">{formatBTC(input.value)} BTC</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-red-500/5 border border-red-500/10 px-3 py-2 space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{input.address ? (
|
||||
<Link
|
||||
to={`/i/bitcoin:address:${input.address}`}
|
||||
className="text-xs font-mono text-red-600 dark:text-red-400 hover:underline truncate"
|
||||
>
|
||||
{truncateMiddle(input.address, 10, 6)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Unknown</span>
|
||||
)}
|
||||
<span className="text-xs font-mono shrink-0">{formatBTC(input.value)} BTC</span>
|
||||
</div>
|
||||
{btcPrice !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(input.value, btcPrice)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
|
||||
const isOpReturn = output.scriptpubkeyType === 'op_return';
|
||||
|
||||
if (isOpReturn) {
|
||||
return (
|
||||
<div className="rounded-lg bg-secondary/60 border border-border/50 px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground">OP_RETURN</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-green-500/5 border border-green-500/10 px-3 py-2 space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{output.address ? (
|
||||
<Link
|
||||
to={`/i/bitcoin:address:${output.address}`}
|
||||
className="text-xs font-mono text-green-600 dark:text-green-400 hover:underline truncate"
|
||||
>
|
||||
{truncateMiddle(output.address, 10, 6)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Unknown</span>
|
||||
)}
|
||||
<span className="text-xs font-mono shrink-0">{formatBTC(output.value)} BTC</span>
|
||||
</div>
|
||||
{btcPrice !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(output.value, btcPrice)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TxSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
<Skeleton className="h-3.5 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border p-4 space-y-3">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-12 rounded-lg" />
|
||||
<Skeleton className="h-12 rounded-lg" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-12 rounded-lg" />
|
||||
<Skeleton className="h-12 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bitcoin Address Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BitcoinAddressHeader({ address }: { address: string }) {
|
||||
const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
|
||||
|
||||
if (isLoading) return <AddressSkeleton />;
|
||||
|
||||
if (error || !addressDetail) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
|
||||
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
|
||||
<p className="text-sm text-destructive">Failed to load address</p>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">{address}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-primary/10 text-primary">
|
||||
<Bitcoin className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Bitcoin Address</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Address</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-mono text-foreground break-all">{address}</p>
|
||||
<CopyButton text={address} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance hero */}
|
||||
<div className="rounded-xl bg-secondary/40 p-4 text-center space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">Balance</p>
|
||||
<p className="text-3xl font-bold tracking-tight">
|
||||
{btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatBTC(addressDetail.totalBalance)} BTC
|
||||
</p>
|
||||
{addressDetail.pendingBalance !== 0 && (
|
||||
<p className="flex items-center justify-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
{btcPrice
|
||||
? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
|
||||
: `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
icon={<ArrowDownLeft className="size-3.5" />}
|
||||
label="Total Received"
|
||||
value={`${formatBTC(addressDetail.totalReceived)} BTC`}
|
||||
subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<ArrowUpRight className="size-3.5" />}
|
||||
label="Total Sent"
|
||||
value={`${formatBTC(addressDetail.totalSent)} BTC`}
|
||||
subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Transactions */}
|
||||
{addressDetail.recentTxs.length > 0 && (
|
||||
<div className="border-t border-border">
|
||||
<div className="px-5 py-3">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Recent Transactions
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{addressDetail.recentTxs.slice(0, 10).map((tx) => (
|
||||
<AddressTxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
|
||||
))}
|
||||
</div>
|
||||
{addressDetail.recentTxs.length > 10 && (
|
||||
<div className="px-5 py-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: link to mempool.space */}
|
||||
<div className="border-t border-border px-5 py-2.5">
|
||||
<a
|
||||
href={`https://mempool.space/address/${address}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bitcoin className="size-3.5" />
|
||||
<span>View on mempool.space</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
|
||||
const isReceive = tx.type === 'receive';
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/i/bitcoin:tx:${tx.txid}`}
|
||||
className="flex items-center justify-between py-3 px-5 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center size-8 rounded-full ${
|
||||
isReceive
|
||||
? 'bg-green-500/10 text-green-600 dark:text-green-400'
|
||||
: 'bg-red-500/10 text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{truncateMiddle(tx.txid, 8, 8)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-medium ${
|
||||
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC
|
||||
</p>
|
||||
{btcPrice && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{satsToUSD(tx.amount, btcPrice)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function AddressSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-secondary/40 p-4 space-y-2 flex flex-col items-center">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
<Skeleton className="h-16 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact previews (used in NoteCard embeds, hover cards, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Compact preview for a Bitcoin transaction — fetches real data. */
|
||||
export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
|
||||
const { tx, btcPrice, isLoading } = useBitcoinTx(txid);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-12 rounded-lg shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const amount = tx ? tx.totalOutput : 0;
|
||||
const fee = tx?.fee ?? 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
|
||||
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Bitcoin className="size-3 shrink-0" />
|
||||
<span>Bitcoin Transaction</span>
|
||||
{tx && (
|
||||
<span className={tx.confirmed
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400'
|
||||
}>
|
||||
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate mt-0.5">
|
||||
{tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)}
|
||||
{tx && btcPrice ? (
|
||||
<span className="text-muted-foreground font-normal"> ({satsToUSD(amount, btcPrice)})</span>
|
||||
) : null}
|
||||
</p>
|
||||
{tx && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
Fee {formatSats(fee)} sats
|
||||
{tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact preview for a Bitcoin address — fetches real data. */
|
||||
export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
|
||||
const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-12 rounded-lg shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const balance = addressDetail?.totalBalance ?? 0;
|
||||
const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
|
||||
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Bitcoin className="size-3 shrink-0" />
|
||||
<span>Bitcoin Address</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate mt-0.5">
|
||||
{addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)}
|
||||
{addressDetail && btcPrice ? (
|
||||
<span className="text-muted-foreground font-normal"> ({satsToUSD(balance, btcPrice)})</span>
|
||||
) : null}
|
||||
</p>
|
||||
{addressDetail && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
<span className="font-mono">{truncateMiddle(address, 8, 6)}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* - `destructive`: red, with a warning icon. Used in high-stakes contexts
|
||||
* like the wallet's Send dialog where the disclaimer also gates an
|
||||
* acknowledgement checkbox.
|
||||
* - `soft`: amber, no icon. Used as an informational notice in lower-stakes
|
||||
* contexts (e.g. campaign donation surfaces) where we don't want to
|
||||
* imply the donor is about to do something dangerous.
|
||||
*/
|
||||
type Tone = 'destructive' | 'soft';
|
||||
|
||||
interface BitcoinPublicDisclaimerProps {
|
||||
/**
|
||||
* When provided, render an "I understand this transaction is public"
|
||||
* acknowledgement checkbox below the warning. Callers should typically
|
||||
* gate the primary action (Send / Donate / Review / Open in wallet) on
|
||||
* `acknowledged === true`. When omitted, the disclaimer renders as an
|
||||
* informational notice with no interactive control.
|
||||
*/
|
||||
acknowledged?: boolean;
|
||||
onAcknowledgedChange?: (acknowledged: boolean) => void;
|
||||
/** Optional override for the lead sentence (e.g. "Donations" instead of "Money"). */
|
||||
leadText?: string;
|
||||
/** Visual treatment. Defaults to `destructive` for backwards compatibility with the wallet's Send dialog. */
|
||||
tone?: Tone;
|
||||
/**
|
||||
* Whether the "Learn more" popover should include the
|
||||
* "or cash out at an exchange" advice. Relevant in the wallet (the
|
||||
* user holds Bitcoin and could cash out) but not on a campaign page
|
||||
* (the donor is sending money away, not deciding what to do with it).
|
||||
* Defaults to `true` for backwards compatibility.
|
||||
*/
|
||||
includeCashOutAdvice?: boolean;
|
||||
/**
|
||||
* Override the popover body. When set, replaces the entire "Bitcoin
|
||||
* is a public ledger…" paragraph (including the cash-out advice). Use
|
||||
* when the calling surface has a meaningfully different audience —
|
||||
* e.g. a campaign *creator* configuring a receive address, vs. the
|
||||
* sender flow this component was originally written for.
|
||||
*/
|
||||
popoverText?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Privacy disclaimer for on-chain Bitcoin payments. Bitcoin is a public
|
||||
* ledger and the transaction can be traced back to the sender forever.
|
||||
* Used wherever the user initiates an on-chain payment — wallet sends to
|
||||
* raw addresses, campaign donations (BIP-21 panels, in-app PSBT
|
||||
* donations, external-wallet fallbacks).
|
||||
*/
|
||||
export function BitcoinPublicDisclaimer({
|
||||
acknowledged,
|
||||
onAcknowledgedChange,
|
||||
leadText,
|
||||
tone = 'destructive',
|
||||
includeCashOutAdvice = true,
|
||||
popoverText,
|
||||
}: BitcoinPublicDisclaimerProps) {
|
||||
const { t } = useTranslation();
|
||||
const showCheckbox = onAcknowledgedChange !== undefined;
|
||||
const isSoft = tone === 'soft';
|
||||
const resolvedLeadText = leadText ?? t('bitcoinPublic.lead');
|
||||
|
||||
return (
|
||||
<Alert
|
||||
// For `soft` we drop the role="alert" semantics — it's informational,
|
||||
// not an active warning the user must respond to.
|
||||
role={isSoft ? 'note' : 'alert'}
|
||||
className={cn(
|
||||
isSoft
|
||||
// Use the project's foreground token (not raw amber-900) so
|
||||
// the text always contrasts against the page in both light
|
||||
// and dark themes. The faint amber tint keeps the
|
||||
// "informational notice" cue without leaning on hard-coded
|
||||
// amber text that disappears on the wrong backdrop.
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-foreground'
|
||||
: 'border-destructive/50 bg-destructive/5 text-destructive dark:border-destructive',
|
||||
)}
|
||||
>
|
||||
{/* Icon only on the destructive variant. The shadcn Alert reserves
|
||||
left padding for an icon via `[&>svg~*]:pl-7`, so omitting the
|
||||
icon also reclaims the indent. */}
|
||||
{!isSoft && <AlertTriangle className="size-4 text-destructive" />}
|
||||
<AlertDescription className="text-xs">
|
||||
<p>
|
||||
{resolvedLeadText}{' '}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2 font-medium hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
||||
>
|
||||
{t('bitcoinPublic.learnMore')}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" className="w-72 text-xs leading-relaxed">
|
||||
{popoverText ?? (
|
||||
includeCashOutAdvice
|
||||
? t('bitcoinPublic.bodyWithCashOut')
|
||||
: t('bitcoinPublic.body')
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</p>
|
||||
{showCheckbox && (
|
||||
<label className="mt-2 flex items-start gap-2 cursor-pointer select-none">
|
||||
<Checkbox
|
||||
checked={acknowledged ?? false}
|
||||
onCheckedChange={(checked) => onAcknowledgedChange(checked === true)}
|
||||
className={cn(
|
||||
'mt-0.5',
|
||||
isSoft
|
||||
? 'border-amber-600 data-[state=checked]:bg-amber-600 data-[state=checked]:text-white dark:border-amber-400 dark:data-[state=checked]:bg-amber-500'
|
||||
: 'border-destructive data-[state=checked]:bg-destructive data-[state=checked]:text-destructive-foreground',
|
||||
)}
|
||||
aria-label={t('bitcoinPublic.iUnderstand')}
|
||||
/>
|
||||
<span>{t('bitcoinPublic.iUnderstand')}</span>
|
||||
</label>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,610 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ClipboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Bitcoin, EyeOff, QrCode, X } from 'lucide-react';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from '@/components/ui/popover';
|
||||
import { QrScannerDialog } from '@/components/QrScannerDialog';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { parseBitcoinUri, validateBitcoinAddress } from '@/lib/bitcoin';
|
||||
import {
|
||||
isSilentPaymentAddress,
|
||||
validateSilentPaymentAddress,
|
||||
} from '@/lib/hdwallet/sp/sender';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The resolved recipient produced by {@link BitcoinRecipientInput}.
|
||||
*
|
||||
* Either a bare on-chain Bitcoin address (`kind === 'address'`) or a BIP-352
|
||||
* silent payment address (`kind === 'sp'`). The dialog consumes this shape
|
||||
* directly when building the PSBT.
|
||||
*/
|
||||
export interface ResolvedRecipient {
|
||||
/**
|
||||
* For `kind === 'address'`: a validated mainnet on-chain address.
|
||||
* For `kind === 'sp'`: the `sp1…` string (the real P2TR `P_k` is derived
|
||||
* at PSBT-build time, after coin selection).
|
||||
*/
|
||||
address: string;
|
||||
/** Recipient kind — determines how the PSBT builder routes the output. */
|
||||
kind: 'address' | 'sp';
|
||||
/**
|
||||
* Raw text the user typed / pasted / scanned. Kept so the picker can
|
||||
* round-trip a chip back into the input on clear if we ever need it
|
||||
* (currently unused; the chip just dismisses).
|
||||
*/
|
||||
raw: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Candidate extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a piece of recipient text into the valid on-chain and/or
|
||||
* silent-payment candidates it carries.
|
||||
*
|
||||
* Handles bare `bc1…` / `sp1…` addresses and `bitcoin:` BIP-21 URIs (which
|
||||
* may carry an on-chain path, an `sp=` parameter, or both). Returns empty
|
||||
* strings for whichever kind isn't present/valid. Shared by the live
|
||||
* input memo and the paste handler so both agree on what counts.
|
||||
*/
|
||||
function resolveCandidates(text: string): { btc: string; sp: string } {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return { btc: '', sp: '' };
|
||||
|
||||
const bip21 = parseBitcoinUri(trimmed);
|
||||
|
||||
// On-chain: the URI path (when present) or the raw input. SP addresses
|
||||
// live in the `sp` field; don't double-count them as on-chain.
|
||||
const btcRaw = bip21 ? bip21.address : trimmed;
|
||||
const btc =
|
||||
btcRaw && !isSilentPaymentAddress(btcRaw) && validateBitcoinAddress(btcRaw)
|
||||
? btcRaw
|
||||
: '';
|
||||
|
||||
// Silent payment: prefer the URI `sp=` parameter; otherwise the path may
|
||||
// itself be an sp1 address (rare but legal — `bitcoin:sp1…` is a URI
|
||||
// without an on-chain fallback), or the raw input is a bare sp1.
|
||||
const spRaw = bip21 ? (bip21.sp ?? bip21.address) : trimmed;
|
||||
const sp =
|
||||
spRaw && isSilentPaymentAddress(spRaw) && validateSilentPaymentAddress(spRaw)
|
||||
? spRaw
|
||||
: '';
|
||||
|
||||
return { btc, sp };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BitcoinRecipientInputProps {
|
||||
/** Currently-selected recipient, or `null` when nothing has been picked. */
|
||||
value: ResolvedRecipient | null;
|
||||
/** Called when the user picks a recipient (from the dropdown / QR scan) or clears. */
|
||||
onChange: (value: ResolvedRecipient | null) => void;
|
||||
/** Input placeholder text. */
|
||||
placeholder: string;
|
||||
/**
|
||||
* Optional initial input value applied when the picker mounts with no
|
||||
* `value`. Used by callers (e.g. campaign donate flow) that want to
|
||||
* pre-fill a `bitcoin:…` URI or bare address so the donor only needs to
|
||||
* pick from the dropdown.
|
||||
*
|
||||
* Applied on mount only. Clearing a selected chip (value → null) returns
|
||||
* to an empty input rather than restoring the prefill.
|
||||
*/
|
||||
initialInput?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipient input for the Send Bitcoin dialog. Combines a text input, an
|
||||
* inline QR-scanner button, and a Radix Popover dropdown that surfaces the
|
||||
* recognised destination(s) extracted from the input.
|
||||
*
|
||||
* Recognised destinations:
|
||||
*
|
||||
* - Bare on-chain Bitcoin address (any standard mainnet type) → "Send to
|
||||
* Bitcoin address" row.
|
||||
* - Bare BIP-352 silent payment address (`sp1…`) → "Send to silent payment
|
||||
* address" row.
|
||||
* - `bitcoin:` BIP-21 URI with an on-chain path and/or an `sp=` parameter →
|
||||
* one row per valid candidate (so a URI carrying both shows two rows and
|
||||
* the donor picks privacy vs. compatibility).
|
||||
*
|
||||
* Clicking a row swaps the input out for a {@link SelectedRecipientChip} via
|
||||
* `onChange`. Clicking the chip's X button calls `onChange(null)`, which
|
||||
* returns to the input view.
|
||||
*
|
||||
* Anything else (npub, nprofile, free text) is silently ignored — there is
|
||||
* no account search here, by design. The dropdown stays open as long as the
|
||||
* input holds at least one valid candidate; it doesn't dismiss when the
|
||||
* input loses focus or the user taps elsewhere. It closes only on selection,
|
||||
* when the input is cleared, or on Escape.
|
||||
*/
|
||||
export function BitcoinRecipientInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
initialInput,
|
||||
}: BitcoinRecipientInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Local input state. Independent of `value` so the user can keep typing
|
||||
// after dismissing the dropdown without losing their query, and so the
|
||||
// chip-cleared view starts blank instead of repopulating the previous
|
||||
// selection. `initialInput` only seeds the field on first mount —
|
||||
// clearing the chip (value → null) returns to an empty input, not the
|
||||
// prefill.
|
||||
const [query, setQuery] = useState<string>(initialInput ?? '');
|
||||
const [open, setOpen] = useState(false);
|
||||
// Tracks whether the popover has been opened at least once for the
|
||||
// current query. The "choose a payment method" hint suppresses on the
|
||||
// very first render so callers prefilling the input don't see the hint
|
||||
// flash for one frame before the auto-open effect runs.
|
||||
const [hasOpenedForQuery, setHasOpenedForQuery] = useState(false);
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Candidate extraction ──────────────────────────────────────────────
|
||||
//
|
||||
// BIP-21 `bitcoin:` URI handling. If the input is a URI, we route the
|
||||
// same way the QR scanner does: surface every valid candidate as its own
|
||||
// row so the user explicitly picks privacy (sp) vs. compatibility
|
||||
// (on-chain). A raw bc1…/sp1… input falls through here unchanged: `bip21`
|
||||
// is null and the candidate is just the trimmed query.
|
||||
const trimmed = query.trim();
|
||||
const { btc: btcCandidate, sp: spCandidate } = useMemo(
|
||||
() => resolveCandidates(trimmed),
|
||||
[trimmed],
|
||||
);
|
||||
|
||||
const hasBtc = !!btcCandidate;
|
||||
const hasSp = !!spCandidate;
|
||||
const totalItems = (hasSp ? 1 : 0) + (hasBtc ? 1 : 0);
|
||||
|
||||
// Auto-open the dropdown whenever a candidate is available, auto-close on
|
||||
// empty input.
|
||||
useEffect(() => {
|
||||
if (trimmed.length === 0) {
|
||||
setOpen(false);
|
||||
setHasOpenedForQuery(false);
|
||||
return;
|
||||
}
|
||||
if (hasSp || hasBtc) setOpen(true);
|
||||
}, [trimmed, hasSp, hasBtc]);
|
||||
|
||||
// Track the first time the popover opens for the current query, so the
|
||||
// "choose a payment method" hint only appears after the donor has had a
|
||||
// chance to see (and dismiss) the dropdown — not flash for one paint
|
||||
// frame between mount and the auto-open effect above.
|
||||
useEffect(() => {
|
||||
if (open) setHasOpenedForQuery(true);
|
||||
}, [open]);
|
||||
|
||||
// ── Selection callbacks ───────────────────────────────────────────────
|
||||
const selectBtc = useCallback(
|
||||
(address: string) => {
|
||||
onChange({ address, kind: 'address', raw: query });
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
[onChange, query],
|
||||
);
|
||||
|
||||
const selectSp = useCallback(
|
||||
(address: string) => {
|
||||
onChange({ address, kind: 'sp', raw: query });
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
[onChange, query],
|
||||
);
|
||||
|
||||
// ── Mount-time auto-select for single-endpoint prefills ────────────────
|
||||
//
|
||||
// When the picker mounts pre-filled (e.g. the campaign "Pay with Agora"
|
||||
// flow) and `initialInput` resolves to exactly one valid candidate, skip
|
||||
// the dropdown and select it directly so it lands as a chip. When the
|
||||
// prefill carries *both* an on-chain address and an sp1 code we leave it
|
||||
// in the input and let the dropdown surface both rows — that's a genuine
|
||||
// choice the donor must make (privacy vs. compatibility).
|
||||
//
|
||||
// Guarded by a ref so it fires once per mount and never overrides a
|
||||
// selection the user has already made or a `clear chip → restore prefill`
|
||||
// transition (the picker is keyed on each open in the dialog, so a fresh
|
||||
// mount is the right granularity).
|
||||
const autoSelectedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoSelectedRef.current) return;
|
||||
autoSelectedRef.current = true;
|
||||
if (value || !initialInput) return;
|
||||
if (totalItems !== 1) return;
|
||||
if (hasSp) {
|
||||
selectSp(spCandidate);
|
||||
} else if (hasBtc) {
|
||||
selectBtc(btcCandidate);
|
||||
}
|
||||
// Intentionally mount-only: candidates are derived from `initialInput`
|
||||
// (via the initial `query`), so reading them here reflects the prefill.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── Paste auto-select ──────────────────────────────────────────────────
|
||||
//
|
||||
// When the user pastes text that resolves to exactly one valid candidate
|
||||
// (a bare `bc1…` / `sp1…` address or a single-endpoint `bitcoin:` URI),
|
||||
// convert it straight into a chip instead of making them click the lone
|
||||
// dropdown row. A paste carrying *both* an on-chain address and an sp1
|
||||
// code falls through to the normal dropdown so the donor picks privacy
|
||||
// vs. compatibility.
|
||||
//
|
||||
// We resolve from the pasted text directly because `query` state hasn't
|
||||
// updated yet inside the paste event. Returning early on a single match
|
||||
// lets us `preventDefault()` so the input never flickers the raw text.
|
||||
const handlePaste = useCallback(
|
||||
(e: ClipboardEvent<HTMLInputElement>) => {
|
||||
const pasted = e.clipboardData.getData('text');
|
||||
if (!pasted) return;
|
||||
const { btc, sp } = resolveCandidates(pasted);
|
||||
const count = (btc ? 1 : 0) + (sp ? 1 : 0);
|
||||
if (count !== 1) return; // 0 → let it land as text; 2 → use the dropdown.
|
||||
e.preventDefault();
|
||||
if (btc) {
|
||||
onChange({ address: btc, kind: 'address', raw: pasted.trim() });
|
||||
} else {
|
||||
onChange({ address: sp, kind: 'sp', raw: pasted.trim() });
|
||||
}
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// ── QR scan handling ──────────────────────────────────────────────────
|
||||
/**
|
||||
* Interpret a freshly-scanned QR code.
|
||||
*
|
||||
* - **BIP-21 URI with valid bc1 *and* sp1** → drop the URI into the input
|
||||
* and open the dropdown so the donor picks between them.
|
||||
* - **BIP-21 URI with only `sp=` valid** → select SP directly (creates
|
||||
* the chip, bypasses the dropdown).
|
||||
* - **Bare bitcoin address** → select on-chain directly.
|
||||
* - **Bare `sp1…` address** → select SP directly.
|
||||
* - **Anything else** → toast.
|
||||
*/
|
||||
const handleScan = useCallback(
|
||||
(scanned: string) => {
|
||||
setScannerOpen(false);
|
||||
const text = scanned.trim();
|
||||
const parsed = parseBitcoinUri(text);
|
||||
|
||||
const candidate = parsed ? parsed.address : text;
|
||||
const sp = parsed?.sp;
|
||||
|
||||
const hasValidBtc = !!candidate && validateBitcoinAddress(candidate);
|
||||
const hasValidSp =
|
||||
!!sp && isSilentPaymentAddress(sp) && validateSilentPaymentAddress(sp);
|
||||
|
||||
// Both options — show the dropdown.
|
||||
if (parsed && hasValidBtc && hasValidSp) {
|
||||
setQuery(text);
|
||||
setOpen(true);
|
||||
// Focus is best-effort; on mobile the scanner dialog dismissal will
|
||||
// already steal focus and the dropdown stays usable via tap.
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// SP-only via `bitcoin:…?sp=sp1…`.
|
||||
if (hasValidSp && sp) {
|
||||
selectSp(sp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct on-chain.
|
||||
if (hasValidBtc) {
|
||||
selectBtc(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bare sp1 (no `bitcoin:` prefix).
|
||||
if (
|
||||
isSilentPaymentAddress(candidate)
|
||||
&& validateSilentPaymentAddress(candidate)
|
||||
) {
|
||||
selectSp(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('walletSend.scanError.title'),
|
||||
description: t('walletSend.scanError.description'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
[selectBtc, selectSp, t, toast],
|
||||
);
|
||||
|
||||
// ── Chip view ─────────────────────────────────────────────────────────
|
||||
if (value) {
|
||||
return (
|
||||
<SelectedRecipientChip value={value} onClear={() => onChange(null)} />
|
||||
);
|
||||
}
|
||||
|
||||
// ── Input + dropdown ──────────────────────────────────────────────────
|
||||
//
|
||||
// `popoverOpen` derives from the manual `open` flag AND the presence of
|
||||
// actionable candidates. This prevents an empty/garbage input from
|
||||
// popping the dropdown.
|
||||
const popoverOpen = open && totalItems > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={popoverOpen} onOpenChange={setOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="hd-recipient-input"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
// Reopen on focus so a user can recover the dropdown after an
|
||||
// outside-click dismiss (the value is still in the field).
|
||||
onFocus={() => {
|
||||
if (totalItems > 0) setOpen(true);
|
||||
}}
|
||||
// `onFocus` only fires on the first tap; subsequent taps while
|
||||
// the input is still focused need their own opener so the user
|
||||
// can reopen the choice list without un-focusing first.
|
||||
onClick={() => {
|
||||
if (totalItems > 0) setOpen(true);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
role="combobox"
|
||||
aria-expanded={popoverOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
className={cn('font-mono text-base md:text-sm pr-11')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScannerOpen(true)}
|
||||
aria-label={t('walletSend.recipient.scan')}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 flex items-center justify-center motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<QrCode className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
// Keep typing focus in the input on open/close — Radix's default
|
||||
// is to focus the popover content, which would steal focus from
|
||||
// the input and dismiss the mobile keyboard mid-type.
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
// The dropdown is a persistent choice list, not a transient
|
||||
// hover-popover: it should stay open even when the input loses
|
||||
// focus or the user taps elsewhere on the page, so blurring out
|
||||
// doesn't make the candidate rows vanish. We block Radix's
|
||||
// auto-dismiss-on-outside-interaction and instead close the
|
||||
// dropdown explicitly — on selection, on a cleared input
|
||||
// (the auto-open effect), or via Escape (still honored below).
|
||||
onFocusOutside={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
||||
className="p-0 w-[--radix-popover-trigger-width] max-h-none rounded-xl border border-border bg-popover shadow-lg overflow-hidden"
|
||||
>
|
||||
<div role="listbox" className="max-h-[280px] overflow-y-auto py-1">
|
||||
{/* BTC comes before SP — the on-chain address is the
|
||||
broadly-compatible default; the silent-payment option
|
||||
follows for donors who want privacy. */}
|
||||
{hasBtc && (
|
||||
<BtcAddressRow address={btcCandidate} onClick={selectBtc} />
|
||||
)}
|
||||
{hasSp && (
|
||||
<SpAddressRow address={spCandidate} onClick={selectSp} />
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Picker-closed reminder. When the input holds parseable candidates
|
||||
but the donor hasn't actually picked one yet — typically because
|
||||
they tapped an amount preset, which counts as an outside-click
|
||||
and dismisses the popover — the Send button is disabled with no
|
||||
visible reason. Surface an actionable hint that re-opens the
|
||||
dropdown so the donor doesn't have to guess that they're meant
|
||||
to tap the recipient input again.
|
||||
|
||||
Gated on `hasOpenedForQuery` so the hint doesn't flash for one
|
||||
paint frame between mount and the auto-open effect on prefilled
|
||||
inputs (campaign donate flow). */}
|
||||
{hasOpenedForQuery && !popoverOpen && totalItems > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500 hover:text-amber-700 dark:hover:text-amber-400 motion-safe:transition-colors text-left"
|
||||
>
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<span>{t('walletSend.recipient.choosePaymentMethod')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<QrScannerDialog
|
||||
isOpen={scannerOpen}
|
||||
onClose={() => setScannerOpen(false)}
|
||||
onScan={handleScan}
|
||||
title={t('walletSend.recipient.scan')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dropdown rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Truncate long addresses with an ellipsis so they don't overflow the row. */
|
||||
function truncateAddress(address: string): string {
|
||||
return address.length > 28
|
||||
? `${address.slice(0, 14)}…${address.slice(-10)}`
|
||||
: address;
|
||||
}
|
||||
|
||||
function BtcAddressRow({
|
||||
address,
|
||||
onClick,
|
||||
}: {
|
||||
address: string;
|
||||
onClick: (address: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onClick={() => onClick(address)}
|
||||
// Prevent the input from blurring on mousedown — otherwise the popover
|
||||
// closes before `onClick` fires and the row never resolves.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
|
||||
>
|
||||
<div className="size-9 shrink-0 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Bitcoin className="size-4 text-orange-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm truncate">
|
||||
{t('walletSend.recipient.sendToOnchain')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate font-mono">
|
||||
{truncateAddress(address)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown row for BIP-352 silent payment addresses. We give it a distinct
|
||||
* label and icon (privacy eye-off) so the user can tell at a glance that
|
||||
* this is a static, unlinkable address rather than a regular Bitcoin
|
||||
* scriptPubKey — the privacy story is materially different.
|
||||
*/
|
||||
function SpAddressRow({
|
||||
address,
|
||||
onClick,
|
||||
}: {
|
||||
address: string;
|
||||
onClick: (address: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onClick={() => onClick(address)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
|
||||
>
|
||||
<div className="size-9 shrink-0 rounded-full bg-violet-500/10 flex items-center justify-center">
|
||||
<EyeOff className="size-4 text-violet-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm truncate">
|
||||
{t('walletSend.recipient.sendToSilentPayment')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate font-mono">
|
||||
{truncateAddress(address)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selected recipient chip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compact panel that replaces the input once a recipient has been picked.
|
||||
* Renders a coloured icon (orange Bitcoin / violet EyeOff for SP), the kind
|
||||
* label, a truncated monospace address, and an X button that clears the
|
||||
* selection and returns the user to the input view.
|
||||
*/
|
||||
function SelectedRecipientChip({
|
||||
value,
|
||||
onClear,
|
||||
}: {
|
||||
value: ResolvedRecipient;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { address, kind } = value;
|
||||
|
||||
const displayName =
|
||||
kind === 'sp'
|
||||
? t('walletSend.recipient.silentPayment')
|
||||
: t('walletSend.recipient.bitcoinAddress');
|
||||
const subtitle = truncateAddress(address);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-border bg-muted/40 pl-2 pr-2 py-1.5 w-full min-w-0 max-w-full">
|
||||
{kind === 'sp' ? (
|
||||
<div className="size-9 shrink-0 rounded-full bg-violet-500/10 flex items-center justify-center">
|
||||
<EyeOff className="size-4 text-violet-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="size-9 shrink-0 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Bitcoin className="size-4 text-orange-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="text-[11px] text-muted-foreground leading-tight">
|
||||
{t('walletSend.recipient.toLabel')}
|
||||
</div>
|
||||
<div className="text-sm font-medium truncate">{displayName}</div>
|
||||
<div className="text-xs text-muted-foreground truncate font-mono">
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
aria-label={t('walletSend.recipient.clear')}
|
||||
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
getUniqueBitcoinFeeSpeeds,
|
||||
type BitcoinFeeRates,
|
||||
type BitcoinFeeSpeed,
|
||||
} from '@/lib/bitcoinFeeSpeed';
|
||||
import {
|
||||
isFeeRecoverable,
|
||||
type BroadcastErrorKind,
|
||||
} from '@/lib/bitcoinBroadcastError';
|
||||
|
||||
interface BroadcastErrorAlertProps {
|
||||
/** Classifier output from {@link classifyBroadcastError}. */
|
||||
error: BroadcastErrorKind;
|
||||
/** Currently-resolved sat/vB rate, used to decide whether bump can do anything. */
|
||||
currentFeeRate: number | undefined;
|
||||
/** Currently-selected fee tier. */
|
||||
feeSpeed: BitcoinFeeSpeed;
|
||||
/** Loaded fee rates, used to compute the de-duped preset tier list. */
|
||||
feeRates: BitcoinFeeRates | undefined;
|
||||
/** Whether the underlying mutation is in flight (disables actions). */
|
||||
isPending: boolean;
|
||||
/** Bump-fee recovery action. */
|
||||
onBumpFee: () => void;
|
||||
/** Plain retry recovery action (used for `network` failures). */
|
||||
onRetry: () => void;
|
||||
/**
|
||||
* When `true` the component knows there's no custom-rate input available
|
||||
* in the consumer (e.g. {@link DonateDialog}), so we hide the bump button
|
||||
* and surface a static "you're on the fastest tier" message once the
|
||||
* user is already on the top preset.
|
||||
*/
|
||||
presetTiersOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline alert rendered above a Bitcoin transaction's Send button when a
|
||||
* broadcast attempt is rejected. The classifier in
|
||||
* {@link ../lib/bitcoinBroadcastError} maps the raw relay error onto a
|
||||
* small enum; each kind gets specific copy and, where recovery is
|
||||
* possible, an action button.
|
||||
*
|
||||
* Action button rules:
|
||||
*
|
||||
* - **Fee-recoverable kinds** (`feeTooLow`, `mempoolFull`,
|
||||
* `rbfReplacementFeeTooLow`) get **Use a higher fee**, which calls
|
||||
* `onBumpFee`. In `presetTiersOnly` consumers, the button is disabled
|
||||
* when the user is already on the top preset and a separate hint
|
||||
* suggests donating from an external wallet.
|
||||
* - **`network`** gets **Try again**, which re-fires the mutation as-is.
|
||||
* - **Everything else** gets no action button — the user has to adjust
|
||||
* amount or recipient (which the consumer's auto-dismiss effect uses
|
||||
* to clear the alert) before retrying.
|
||||
*
|
||||
* The toast surface is intentionally not used for classified failures.
|
||||
* Toasts auto-dismiss and are visually disconnected from the fee picker;
|
||||
* an inline alert directly above Send keeps the recovery in the donor's
|
||||
* line of sight.
|
||||
*/
|
||||
export function BroadcastErrorAlert({
|
||||
error,
|
||||
currentFeeRate,
|
||||
feeSpeed,
|
||||
feeRates,
|
||||
isPending,
|
||||
onBumpFee,
|
||||
onRetry,
|
||||
presetTiersOnly,
|
||||
}: BroadcastErrorAlertProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { title, body } = useMemo(() => {
|
||||
switch (error.kind) {
|
||||
case 'feeTooLow':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.feeTooLowTitle'),
|
||||
body: error.minRelayFeeRate
|
||||
? t('walletSend.broadcastError.feeTooLowBodyWithMin', { min: error.minRelayFeeRate })
|
||||
: t('walletSend.broadcastError.feeTooLowBody'),
|
||||
};
|
||||
case 'rbfReplacementFeeTooLow':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.rbfTitle'),
|
||||
body: t('walletSend.broadcastError.rbfBody'),
|
||||
};
|
||||
case 'mempoolFull':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.mempoolFullTitle'),
|
||||
body: t('walletSend.broadcastError.mempoolFullBody'),
|
||||
};
|
||||
case 'network':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.networkTitle'),
|
||||
body: t('walletSend.broadcastError.networkBody'),
|
||||
};
|
||||
case 'mempoolConflict':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.mempoolConflictTitle'),
|
||||
body: t('walletSend.broadcastError.mempoolConflictBody'),
|
||||
};
|
||||
case 'tooLongChain':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.tooLongChainTitle'),
|
||||
body: t('walletSend.broadcastError.tooLongChainBody'),
|
||||
};
|
||||
case 'badInputs':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.badInputsTitle'),
|
||||
body: t('walletSend.broadcastError.badInputsBody'),
|
||||
};
|
||||
case 'absurdlyHighFee':
|
||||
return {
|
||||
title: t('walletSend.broadcastError.absurdlyHighFeeTitle'),
|
||||
body: t('walletSend.broadcastError.absurdlyHighFeeBody'),
|
||||
};
|
||||
case 'unknown':
|
||||
default:
|
||||
return {
|
||||
title: t('walletSend.broadcastError.unknownTitle'),
|
||||
// Fall back to the raw bitcoind / framing message so the donor
|
||||
// (or a support thread) has something concrete to act on. Empty
|
||||
// when the classifier had no message to preserve.
|
||||
body: 'raw' in error && error.raw ? error.raw : '',
|
||||
};
|
||||
}
|
||||
}, [error, t]);
|
||||
|
||||
// Decide whether the bump-fee CTA is actually useful here. For consumers
|
||||
// that ship a custom-rate input (the HD wallet flow), the bump is always
|
||||
// useful — we either jump to a faster preset or escalate to a custom
|
||||
// rate seeded from the error. For preset-only consumers (the donate
|
||||
// flow), the button only makes sense while a faster preset exists; once
|
||||
// the user is on the top preset they need to switch to an external
|
||||
// wallet.
|
||||
const uniquePresets = feeRates ? getUniqueBitcoinFeeSpeeds(feeRates) : [];
|
||||
const isCustom = feeSpeed === 'custom';
|
||||
const isOnTopPreset =
|
||||
!isCustom
|
||||
&& uniquePresets.length > 0
|
||||
// Cast through the preset union to avoid `.indexOf` narrowing
|
||||
// `feeSpeed` for the rest of the function body.
|
||||
&& uniquePresets.indexOf(feeSpeed as Exclude<BitcoinFeeSpeed, 'custom'>) === 0;
|
||||
const haveFeeHint =
|
||||
error.kind === 'feeTooLow'
|
||||
&& !!(error.minRelayFeeRate || error.actualFeeRate);
|
||||
|
||||
const showBumpFee = isFeeRecoverable(error.kind) && !(presetTiersOnly && isOnTopPreset);
|
||||
const showAtMaxHint = presetTiersOnly && isOnTopPreset && isFeeRecoverable(error.kind);
|
||||
const canBumpUsefully =
|
||||
!isOnTopPreset || haveFeeHint || isCustom || !!currentFeeRate;
|
||||
|
||||
const showRetry = error.kind === 'network';
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className="py-2.5">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle className="text-sm">{title}</AlertTitle>
|
||||
{body && <AlertDescription className="text-xs mt-1">{body}</AlertDescription>}
|
||||
{showAtMaxHint && (
|
||||
<AlertDescription className="text-xs mt-1 font-medium">
|
||||
{t('walletSend.broadcastError.atMaxFeeTier')}
|
||||
</AlertDescription>
|
||||
)}
|
||||
{(showBumpFee || showRetry) && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{showBumpFee && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onBumpFee}
|
||||
disabled={isPending || !canBumpUsefully}
|
||||
>
|
||||
{t('walletSend.broadcastError.useHigherFee')}
|
||||
</Button>
|
||||
)}
|
||||
{showRetry && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('walletSend.broadcastError.tryAgain')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -504,7 +504,6 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel={t('calendarEvents.detail.comment')}
|
||||
hideZap
|
||||
showShareInSidebar
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { HandHeart, ShieldCheck } from 'lucide-react';
|
||||
import { HandHeart, Target } from 'lucide-react';
|
||||
|
||||
import { AuthorByline } from '@/components/AuthorByline';
|
||||
import { CampaignVerificationBadge } from '@/components/CampaignVerificationBadge';
|
||||
@@ -11,117 +10,38 @@ import { Progress } from '@/components/ui/progress';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ModerationOverlay } from '@/components/moderation';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
|
||||
import { useEventTranslation } from '@/hooks/useEventTranslation';
|
||||
import { useInView } from '@/hooks/useInView';
|
||||
import {
|
||||
type ParsedCampaign,
|
||||
encodeCampaignNaddr,
|
||||
getCampaignCountryLabel,
|
||||
parseCampaign,
|
||||
} from '@/lib/campaign';
|
||||
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
|
||||
import { formatUsdGoal } from '@/lib/formatCampaignAmount';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Short helper rendered both inline (cards) and in the detail page.
|
||||
*
|
||||
* Per NIP.md Kind 33863, the campaign **goal** is integer USD and the
|
||||
* **raised** total is the sum of verified sats. We render both in the
|
||||
* goal's unit (USD) for consistency, converting the sats total at view
|
||||
* time using the live BTC price. While the price is loading the raised
|
||||
* amount falls back to sats.
|
||||
* Goal row rendered both inline (cards) and in the detail page. Shows the
|
||||
* campaign goal as a target (integer USD per NIP.md Kind 33863). The
|
||||
* raised-so-far tally returns with the Grin payment-proof tally in a later
|
||||
* phase; the invisible bar keeps every card's vertical footprint identical
|
||||
* in the meantime.
|
||||
*/
|
||||
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
|
||||
? Math.min(100, Math.round((raisedUsd / goalUsd!) * 100))
|
||||
: 0;
|
||||
|
||||
// Always reserve a bar row so cards with and without a goal occupy
|
||||
// the same vertical space. The bar is rendered invisibly when
|
||||
// there's no goal — same height, no visual weight.
|
||||
//
|
||||
// The primitive's default `bg-secondary` track is too close to the
|
||||
// card surface in both light and dark modes (in dark mode they're
|
||||
// both `0 0% 18%`, making the empty portion of the bar invisible).
|
||||
// `bg-foreground/15` overrides it with a foreground-tinted track
|
||||
// that has real contrast against the card in either theme.
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
<Progress
|
||||
value={pct}
|
||||
className={cn('h-2 bg-foreground/15', !hasGoal && 'invisible')}
|
||||
aria-hidden={!hasGoal}
|
||||
/>
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="font-semibold">
|
||||
{formatCampaignAmount(raisedSats, btcPrice)}
|
||||
{!hasGoal && <span className="ml-1 font-normal text-muted-foreground">raised</span>}
|
||||
</span>
|
||||
{hasGoal && (
|
||||
<span className="text-muted-foreground">of {formatUsdGoal(goalUsd!)} goal</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces {@link CampaignProgress} for silent-payment campaigns, where
|
||||
* on-chain totals are unobservable by design. Shows the goal as a target
|
||||
* (if set) but no progress bar or raised amount.
|
||||
*/
|
||||
function CampaignPrivateNotice({
|
||||
function CampaignGoalRow({
|
||||
goalUsd,
|
||||
className,
|
||||
}: {
|
||||
goalUsd?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
// Mirrors CampaignProgress's vertical footprint (invisible bar + one
|
||||
// text row) so a silent-payment card lines up visually with a
|
||||
// public-progress card alongside it.
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
<Progress value={0} className="h-2 invisible" aria-hidden />
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
Private campaign
|
||||
<Target className="size-3.5" />
|
||||
Fundraiser
|
||||
</span>
|
||||
{goalUsd && goalUsd > 0 && (
|
||||
<span className="text-muted-foreground">Target: {formatUsdGoal(goalUsd)}</span>
|
||||
@@ -164,44 +84,25 @@ interface CampaignCardProps {
|
||||
* `<Link>` to the campaign's naddr-based detail route.
|
||||
*/
|
||||
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge, showModerationMenu = true }: CampaignCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { translatedEvent, translateAction } = useEventTranslation(campaign.event, {
|
||||
iconOnly: true,
|
||||
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
|
||||
});
|
||||
const displayCampaign = parseCampaign(translatedEvent) ?? campaign;
|
||||
const author = useAuthor(campaign.pubkey);
|
||||
// Defer the (potentially Esplora-heavy) donation lookup until the card is
|
||||
// actually on screen. A campaigns grid mounts up to ~200 cards at once;
|
||||
// fetching donations for every one eagerly fired an Esplora `/address`
|
||||
// call per card plus a `/tx` call per donation receipt, all at once,
|
||||
// which rate-limited every configured backend. `rootMargin` pre-arms the
|
||||
// fetch just before the card scrolls into view so the number is usually
|
||||
// already there by the time the user sees it.
|
||||
const cardRef = useRef<HTMLAnchorElement>(null);
|
||||
const inView = useInView(cardRef);
|
||||
const { data: stats, isLoading: donationsLoading } = useCampaignDonations(campaign, {
|
||||
enabled: inView,
|
||||
});
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
|
||||
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
|
||||
const authorMetadata = author.data?.metadata;
|
||||
const cover = sanitizeUrl(displayCampaign.banner)
|
||||
?? sanitizeUrl(authorMetadata?.banner)
|
||||
?? sanitizeUrl(authorMetadata?.picture);
|
||||
const raisedSats = stats?.totalSats ?? 0;
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
// SP-only campaigns hide aggregate totals; dual-endpoint campaigns
|
||||
// show on-chain aggregates per spec.
|
||||
const isSilentPayment = !campaign.wallets.onchain;
|
||||
|
||||
const isFeaturedVariant = variant === 'featured';
|
||||
const isShelfVariant = variant === 'shelf';
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={cardRef}
|
||||
to={`/${naddr}`}
|
||||
className={cn(
|
||||
'group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5',
|
||||
@@ -314,25 +215,11 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isSilentPayment ? (
|
||||
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
|
||||
) : (
|
||||
<CampaignProgress
|
||||
raisedSats={raisedSats}
|
||||
goalUsd={campaign.goalUsd}
|
||||
btcPrice={btcPrice}
|
||||
isLoading={donationsLoading}
|
||||
/>
|
||||
)}
|
||||
<CampaignGoalRow goalUsd={campaign.goalUsd} />
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AuthorByline pubkey={campaign.pubkey} insideLink />
|
||||
{!isSilentPayment && stats && stats.donorCount > 0 && (
|
||||
<span className="shrink-0 text-muted-foreground/80">
|
||||
· {t('common.donors', { count: stats.donorCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(footerBadge || translateAction) && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowDownLeft, ArrowUpRight, Clock, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAddressLedger } from '@/hooks/useAddressLedger';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { type AddressTransaction, formatBTC, satsToUSD } from '@/lib/bitcoin';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
|
||||
interface CampaignLedgerProps {
|
||||
/** The campaign's on-chain (`bc1…`) Bitcoin address. */
|
||||
address: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public on-chain activity for a campaign's `bc1…` address, presented as a
|
||||
* mempool.space-style ledger. Each row is one transaction touching the
|
||||
* address, with its address-relative net sat flow (inbound or outbound),
|
||||
* confirmation status, and a deep link to mempool.space for the full tx.
|
||||
*
|
||||
* Only applicable when the campaign declares a public on-chain endpoint —
|
||||
* silent-payment-only campaigns have no scannable address and should not
|
||||
* surface this tab at all.
|
||||
*/
|
||||
export function CampaignLedger({ address }: CampaignLedgerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const query = useAddressLedger(address, true);
|
||||
|
||||
const pages = query.data?.pages ?? [];
|
||||
const txs: AddressTransaction[] = pages.flat();
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-muted/60 overflow-hidden border-l border-r border-primary/20">
|
||||
<LedgerHeader address={address} />
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="divide-y divide-primary/20">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<LedgerRowSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
{t('campaignsDetail.ledger.error')}
|
||||
</div>
|
||||
) : txs.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
{t('campaignsDetail.ledger.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="divide-y divide-primary/20">
|
||||
{txs.map((tx) => (
|
||||
<LedgerRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{query.hasNextPage && (
|
||||
<div className="px-4 py-3 flex justify-center border-t border-primary/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void query.fetchNextPage()}
|
||||
disabled={query.isFetchingNextPage}
|
||||
>
|
||||
{query.isFetchingNextPage
|
||||
? t('campaignsDetail.ledger.loadingMore')
|
||||
: t('campaignsDetail.ledger.loadMore')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerHeader({ address }: { address: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-4 sm:px-5 py-3 border-b border-primary/20 bg-background/40">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t('campaignsDetail.ledger.publicAddress')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs sm:text-sm font-mono text-foreground/90 break-all">
|
||||
{address}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
onClick={() => void openUrl(`https://mempool.space/address/${address}`)}
|
||||
title={t('campaignsDetail.ledger.viewOnMempool')}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
<span className="hidden sm:inline">{t('campaignsDetail.ledger.viewOnMempool')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerRow({
|
||||
tx,
|
||||
btcPrice,
|
||||
}: {
|
||||
tx: AddressTransaction;
|
||||
btcPrice: number | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isInflow = tx.netSats >= 0;
|
||||
const absSats = Math.abs(tx.netSats);
|
||||
const Icon = isInflow ? ArrowDownLeft : ArrowUpRight;
|
||||
const tone = isInflow ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400';
|
||||
const bgTone = isInflow ? 'bg-emerald-500/10' : 'bg-amber-500/10';
|
||||
|
||||
const when = tx.confirmed && tx.blockTime
|
||||
? timeAgo(tx.blockTime)
|
||||
: t('campaignsDetail.ledger.unconfirmed');
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openUrl(`https://mempool.space/tx/${tx.txid}`)}
|
||||
className="w-full px-4 sm:px-5 py-3 flex items-center gap-3 text-left hover:bg-background/40 motion-safe:transition-colors"
|
||||
title={t('campaignsDetail.ledger.openOnMempool')}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`inline-flex size-9 shrink-0 items-center justify-center rounded-full ${bgTone} ${tone}`}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{isInflow
|
||||
? t('campaignsDetail.ledger.received')
|
||||
: t('campaignsDetail.ledger.sent')}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-muted-foreground truncate">
|
||||
{tx.txid.slice(0, 8)}…{tx.txid.slice(-6)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{!tx.confirmed && <Clock className="size-3" aria-hidden />}
|
||||
<span>{when}</span>
|
||||
{tx.confirmed && tx.blockHeight ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{t('campaignsDetail.ledger.block', { height: tx.blockHeight })}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
{btcPrice ? (
|
||||
<div className={`text-sm font-semibold tabular-nums ${tone}`}>
|
||||
{isInflow ? '+' : '−'}
|
||||
{satsToUSD(absSats, btcPrice)}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={`tabular-nums ${
|
||||
btcPrice ? 'text-xs text-muted-foreground' : `text-sm font-semibold ${tone}`
|
||||
}`}
|
||||
>
|
||||
{isInflow ? '+' : '−'}
|
||||
{formatBTC(absSats)} {t('campaignsDetail.ledger.btcUnit')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerRowSkeleton() {
|
||||
return (
|
||||
<div className="px-4 sm:px-5 py-3 flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, Copy, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { CampaignWallets } from '@/lib/campaign';
|
||||
|
||||
interface CampaignWalletDonatePanelProps {
|
||||
/** Parsed wallet endpoints declared by the campaign's `w` tags. At least one must be present. */
|
||||
wallets: CampaignWallets;
|
||||
/**
|
||||
* Optional primary action rendered immediately above the
|
||||
* "Open external wallet" button — typically a "Pay with Agora"
|
||||
* button injected by the campaign detail page when the logged-in donor
|
||||
* has an HD wallet available.
|
||||
*
|
||||
* When supplied, the "Open external wallet" button switches to the
|
||||
* `outline` variant so the in-app pay action visually leads. When
|
||||
* absent, the external-wallet button keeps its default (primary)
|
||||
* styling — the panel still works on its own for logged-out donors
|
||||
* and SP-only campaigns.
|
||||
*/
|
||||
primaryAction?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the BIP-21 URI used by the QR code and the "Open in wallet"
|
||||
* button.
|
||||
*
|
||||
* - Single on-chain endpoint: `bitcoin:<bc1>`
|
||||
* - Single silent-payment endpoint: `bitcoin:?sp=<sp1>`
|
||||
* - Both endpoints (combined BIP-21 URI): `bitcoin:<bc1>?sp=<sp1>`
|
||||
*
|
||||
* BIP-352-aware wallets pick the `sp=` parameter; legacy wallets fall
|
||||
* back to the on-chain address.
|
||||
*/
|
||||
function buildQrPayload(wallets: CampaignWallets): string {
|
||||
const { onchain, sp } = wallets;
|
||||
if (onchain && sp) return `bitcoin:${onchain.value}?sp=${sp.value}`;
|
||||
if (onchain) return `bitcoin:${onchain.value}`;
|
||||
if (sp) return `bitcoin:?sp=${sp.value}`;
|
||||
// parseCampaign rejects events without any wallet; the panel should
|
||||
// never be rendered in this state.
|
||||
return 'bitcoin:';
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline panel rendering the campaign's wallet endpoints as a scannable
|
||||
* QR code, a copyable string, and an "Open in wallet" button.
|
||||
*
|
||||
* Behavior — the QR and the copyable row always carry a `bitcoin:`
|
||||
* BIP-21 URI, regardless of which endpoints the campaign exposes:
|
||||
*
|
||||
* - **on-chain only** (`bc1q…` / `bc1p…`) — `bitcoin:<bc1>`.
|
||||
* - **silent payment only** (`sp1…`) — `bitcoin:?sp=<sp1>`.
|
||||
* - **both** — combined `bitcoin:<bc1>?sp=<sp1>` URI; BIP-352-aware
|
||||
* wallets pick the SP path automatically, legacy wallets fall back to
|
||||
* the on-chain address.
|
||||
*
|
||||
* Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
|
||||
* that's `DonateDialog`'s job. This panel is the always-available
|
||||
* "scan and pay from any wallet" affordance.
|
||||
*/
|
||||
export function CampaignWalletDonatePanel({
|
||||
wallets,
|
||||
primaryAction,
|
||||
}: CampaignWalletDonatePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const qrPayload = buildQrPayload(wallets);
|
||||
|
||||
// Donors always copy the same BIP-21 URI that the QR encodes — modern
|
||||
// wallets parse it in their recipient field, and a `bitcoin:` URI
|
||||
// round-trips through any wallet whether the campaign exposes an
|
||||
// on-chain address, a silent-payment code, or both.
|
||||
const copyValue = qrPayload;
|
||||
const copyLabel = 'Payment URI';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* QR — large, centered on a clean white tile with the Agora logo
|
||||
embedded in an orange circular badge in the center.
|
||||
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 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="flex aspect-square w-[28%] items-center justify-center rounded-full bg-primary ring-[6px] ring-white">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt=""
|
||||
className="aspect-square w-3/5 object-contain brightness-0 invert"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyable value — single row mirroring the QR payload. */}
|
||||
<WalletCopyRow value={copyValue} label={copyLabel} />
|
||||
|
||||
{/* Optional in-app pay action rendered immediately above the
|
||||
external-wallet button. When present it becomes the primary
|
||||
CTA; the external button below downgrades to `outline` so
|
||||
there's only ever one orange button stacked here. */}
|
||||
{primaryAction}
|
||||
|
||||
{/* "Open in wallet" — relies on the `bitcoin:` URI handler. SP
|
||||
codes inside `bitcoin:?sp=` are still understood by BIP-352-
|
||||
aware wallets. Older wallets that don't know about SP will
|
||||
ignore the parameter and either refuse the link or show an
|
||||
error — at which point the donor falls back to copy/paste
|
||||
anyway.
|
||||
|
||||
The label switches to "Open external wallet" only when a
|
||||
`primaryAction` slot is filled (i.e. the in-app "Pay with
|
||||
Agora" button is right above it) — that's the one situation
|
||||
where we need to disambiguate between "external" and "Agora's
|
||||
own wallet". When the slot is empty the qualifier is just
|
||||
noise. */}
|
||||
<Button
|
||||
asChild
|
||||
variant={primaryAction ? 'outline' : 'default'}
|
||||
className={primaryAction ? 'w-full' : 'w-full text-white'}
|
||||
>
|
||||
<a href={qrPayload}>
|
||||
<ExternalLink className="size-4 mr-1.5" />
|
||||
{primaryAction
|
||||
? t('campaignsDetail.openExternalWallet')
|
||||
: t('campaignsDetail.openInWallet')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single copyable row for the wallet payload. Renders the value in a
|
||||
* monospace font and copies it to the clipboard on click. The label is
|
||||
* used in the aria-label and the success toast so donors know what
|
||||
* they just copied.
|
||||
*/
|
||||
function WalletCopyRow({ value, label }: { value: string; label: string }) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
toast({ title: `${label} copied` });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: 'Select and copy the value manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="w-full flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 text-left hover:bg-muted/60 motion-safe:transition-colors"
|
||||
aria-label={`Copy ${label.toLowerCase()}`}
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-xs" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Award, BarChart3, Bird, BookOpen, Camera, Clapperboard, FileText, Film,
|
||||
GitBranch, GitPullRequest, HandHeart, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
|
||||
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus,
|
||||
Stars, Target, Users, UserCheck, Vote, Zap,
|
||||
Stars, Target, Users, UserCheck, Vote,
|
||||
} from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -155,7 +155,6 @@ const KIND_LABELS: Record<number, string> = {
|
||||
30000: 'a follow set',
|
||||
30621: 'a constellation',
|
||||
39089: 'a follow pack',
|
||||
9735: 'a zap',
|
||||
9802: 'a highlight',
|
||||
};
|
||||
|
||||
@@ -205,7 +204,6 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
3367: Palette,
|
||||
9041: Target,
|
||||
33863: HandHeart,
|
||||
9735: Zap,
|
||||
9802: Highlighter,
|
||||
2473: Bird,
|
||||
12473: Bird,
|
||||
|
||||
@@ -57,7 +57,6 @@ import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyLis
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
|
||||
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -226,11 +225,9 @@ function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: strin
|
||||
|
||||
function PledgeShelfCard({ pledge }: { pledge: Action }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
return (
|
||||
<PledgeCard
|
||||
action={pledge}
|
||||
btcPrice={btcPrice}
|
||||
variant="shelf"
|
||||
showAuthor
|
||||
showTranslate
|
||||
@@ -949,7 +946,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel={t('groups.detail.comment')}
|
||||
hideZap
|
||||
showShareInSidebar
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
|
||||