Eranos: Grin-only fundraising (rebrand + Grin payments + gold)
Rebrand Agora to Eranos and strip the non-Grin rails. Add Grin donations: a GoblinPay client + GrinPayDialog, on-chain payment-proof verification (receiver-sig + kernel-on-chain + dedupe), and a proof-verified campaign tally (kind 3414). Shift the brand from orange to gold. 118 tests green.
This commit is contained in:
@@ -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,10 +7,10 @@ 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
|
||||
|
||||
Vendored
+1
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package spot.agora.app;
|
||||
package fund.eranos.app;
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
+2
-2
@@ -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
|
||||
+2
-2
@@ -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,
|
||||
+2
-2
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package spot.agora.app;
|
||||
package fund.eranos.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package spot.agora.app;
|
||||
package fund.eranos.app;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
@@ -5,7 +5,7 @@
|
||||
android:viewportHeight="1200">
|
||||
|
||||
<!--
|
||||
Agora double-bolt logo from public/logo.svg (viewBox "0 0 720 880").
|
||||
Eranos double-bolt logo from public/logo.svg (viewBox "0 0 720 880").
|
||||
Android 12 splash masks the icon to a circle at 2/3 of the canvas
|
||||
(~800dp of 1200dp). The logo is 720x880 (portrait); scale 880 -> 800
|
||||
(factor 800/880 = 0.9091) giving a scaled width of ~654, then center:
|
||||
|
||||
@@ -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>
|
||||
|
||||
+3
-3
@@ -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'
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+9
-9
@@ -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>
|
||||
|
||||
@@ -325,7 +325,7 @@
|
||||
);
|
||||
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;
|
||||
@@ -348,7 +348,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.9.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
Generated
+18
-592
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"name": "agora",
|
||||
"version": "2.9.0",
|
||||
"name": "eranos",
|
||||
"version": "2.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agora",
|
||||
"version": "2.9.0",
|
||||
"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",
|
||||
|
||||
+3
-9
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "agora",
|
||||
"name": "eranos",
|
||||
"private": true,
|
||||
"version": "2.9.1",
|
||||
"type": "module",
|
||||
@@ -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 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="app-icon" viewBox="0 0 64 64"><g fill-rule="nonzero" fill="#FFF"><path d="M41.7 0c6.4 0 9.6 0 13.1 1.1a13.6 13.6 0 0 1 8.1 8.1C64 12.7 64 15.9 64 22.31v19.37c0 6.42 0 9.64-1.1 13.1a13.6 13.6 0 0 1-8.1 8.1C51.3 64 48.1 64 41.7 64H22.3c-6.42 0-9.64 0-13.1-1.1a13.6 13.6 0 0 1-8.1-8.1C0 51.3 0 48.1 0 41.69V22.3c0-6.42 0-9.64 1.1-13.1a13.6 13.6 0 0 1 8.1-8.1C12.7 0 15.9 0 22.3 0h19.4z" fill="#00D632"/><path d="M42.47 23.8c.5.5 1.33.5 1.8-.0l2.5-2.6c.53-.5.5-1.4-.06-1.94a19.73 19.73 0 0 0-6.72-3.84l.79-3.8c.17-.83-.45-1.61-1.28-1.61h-4.84a1.32 1.32 0 0 0-1.28 1.06l-.7 3.38c-6.44.33-11.9 3.6-11.9 10.3 0 5.8 4.51 8.29 9.28 10 4.51 1.72 6.9 2.36 6.9 4.78 0 2.49-2.38 3.95-5.9 3.95-3.2 0-6.56-1.07-9.16-3.68a1.3 1.3 0 0 0-1.84-.0l-2.7 2.7a1.36 1.36 0 0 0 .0 1.92c2.1 2.07 4.76 3.57 7.792 4.4l-.74 3.57c-.17.83.44 1.6 1.27 1.61l4.85.04a1.32 1.32 0 0 0 1.3-1.06l.7-3.39C40.28 49.07 45 44.8 45 38.57c0-5.74-4.7-8.16-10.4-10.13-3.26-1.21-6.08-2.04-6.08-4.53 0-2.42 2.63-3.38 5.27-3.38 3.36 0 6.59 1.39 8.7 3.29z" fill="#FFF"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 145 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 226 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 257 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
|
||||
}
|
||||
|
||||
+6
-6
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+9
-17
@@ -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>
|
||||
</RelayProvider>
|
||||
</NostrLoginProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { VersionCheck } from "./components/VersionCheck";
|
||||
import { MinimizedAudioBar } from "./components/MinimizedAudioBar";
|
||||
import { AudioNavigationGuard } from "./components/AudioNavigationGuard";
|
||||
import { TorStatusBanner } from "./components/TorStatusBanner";
|
||||
import { VenezuelaReliefPopup } from "./components/VenezuelaReliefPopup";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
import { useProfileUrl } from "./hooks/useProfileUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -58,14 +57,7 @@ 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 })));
|
||||
const VenezuelaReliefPage = lazy(() => import("./pages/VenezuelaReliefPage").then(m => ({ default: m.VenezuelaReliefPage })));
|
||||
|
||||
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
|
||||
function ProfileRedirect() {
|
||||
@@ -148,11 +140,6 @@ export function AppRouter() {
|
||||
{/* App-wide Tor status banner. Must live inside BrowserRouter — it
|
||||
renders a <Link> to the Tor settings, which needs Router context. */}
|
||||
<TorStatusBanner />
|
||||
{/* Site-wide Venezuela earthquake relief appeal — shows once per
|
||||
browser session on a fresh load of any route. Lives inside
|
||||
BrowserRouter because it renders <Link>s to the relief page and
|
||||
campaign browse. Remove when the relief response winds down. */}
|
||||
<VenezuelaReliefPopup />
|
||||
<OnboardingGate>
|
||||
<Routes>
|
||||
{/* Narrow layout — `max-w-3xl` center column. The default for
|
||||
@@ -169,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. */}
|
||||
@@ -246,11 +216,6 @@ export function AppRouter() {
|
||||
before the "activist" → "recipient" copy change. Redirect so
|
||||
external links and bookmarks still resolve. */}
|
||||
<Route path="/about/activists" element={<Navigate to="/about/recipients" replace />} />
|
||||
{/* Dedicated, shareable Venezuela earthquake relief page. Wide
|
||||
layout so its hero spans the viewport like /about. Must be
|
||||
declared above `/:nip19`, which would otherwise swallow this
|
||||
single-segment path. */}
|
||||
<Route path="/venezuela-relief" element={<VenezuelaReliefPage />} />
|
||||
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1.
|
||||
Goes through the wide layout because the dispatch may resolve to
|
||||
a profile, campaign, action, or community page — all of which
|
||||
|
||||
@@ -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,39 +62,21 @@ 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>
|
||||
<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">
|
||||
@@ -112,7 +84,6 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
@@ -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)}
|
||||
|
||||
+12
-124
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
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';
|
||||
@@ -10,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>
|
||||
@@ -169,43 +90,19 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
|
||||
});
|
||||
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);
|
||||
// Cards only show the raised total (the progress bar), never the donor
|
||||
// list — so we skip the kind 8333 receipt fetch and the per-receipt
|
||||
// `/tx` verification fan-out. Only the single Esplora `/address` balance
|
||||
// lookup runs, which keeps a ~200-card grid from firing an N-receipt
|
||||
// `/tx` storm per card.
|
||||
const { data: stats, isLoading: donationsLoading } = useCampaignDonations(campaign, {
|
||||
enabled: inView,
|
||||
receipts: false,
|
||||
});
|
||||
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',
|
||||
@@ -318,16 +215,7 @@ 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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Trophy, Users, Hash, Zap, MessageSquare, HandHeart, Flame,
|
||||
Trophy, Users, Hash, MessageSquare, HandHeart, Flame,
|
||||
} from 'lucide-react';
|
||||
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
@@ -13,7 +13,7 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useTrustedCountryStats } from '@/hooks/useTrustedCountryStats';
|
||||
import { useTrustedGlobalStats } from '@/hooks/useTrustedGlobalStats';
|
||||
import type {
|
||||
StatsTimeframe, TopAction, TopContributor, TopDonor, TopPoster, TrendingHashtag,
|
||||
StatsTimeframe, TopAction, TopDonor, TopPoster, TrendingHashtag,
|
||||
TrustedCountryStats,
|
||||
} from '@/lib/statsParser';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
@@ -120,11 +120,9 @@ function AggregateCounts({
|
||||
}) {
|
||||
const c = stats.counts;
|
||||
return (
|
||||
<div className={cn('grid gap-2', compact ? 'grid-cols-2' : 'grid-cols-2 sm:grid-cols-5')}>
|
||||
<div className={cn('grid gap-2', compact ? 'grid-cols-2' : 'grid-cols-2 sm:grid-cols-3')}>
|
||||
<CountTile icon={MessageSquare} label="Comments" value={c.commentCnt[timeframe]} />
|
||||
<CountTile icon={Users} label="Authors" value={c.authorCnt[timeframe]} />
|
||||
<CountTile icon={Zap} label="Sats zapped" value={c.zapAmount[timeframe]} />
|
||||
<CountTile icon={HandHeart} label="Zaps" value={c.zapCnt[timeframe]} />
|
||||
<CountTile icon={Flame} label="Submissions" value={c.submissionCnt[timeframe]} />
|
||||
</div>
|
||||
);
|
||||
@@ -162,7 +160,6 @@ function Leaderboards({
|
||||
<div className={cn('grid gap-4', compact ? 'grid-cols-1' : 'md:grid-cols-2')}>
|
||||
<TopActionsList actions={tfData.topActions} />
|
||||
<TopPostersList posters={tfData.topPosters} />
|
||||
<TopZappedList contributors={tfData.topContributors} />
|
||||
<TopDonorsList donors={tfData.topDonors} />
|
||||
<TrendingHashtagsList tags={tfData.trendingHashtags} className={compact ? undefined : 'md:col-span-2'} />
|
||||
</div>
|
||||
@@ -208,7 +205,7 @@ function TopActionsList({ actions }: { actions: TopAction[] }) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{a.title}</div>
|
||||
<div className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{a.submissions} submissions · {formatCompact(a.zapAmount)} sats zapped
|
||||
{a.submissions} submissions
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -246,33 +243,6 @@ function TopPostersList({ posters }: { posters: TopPoster[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TopZappedList({ contributors }: { contributors: TopContributor[] }) {
|
||||
if (!contributors.length) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader icon={Zap} title="Most zapped" />
|
||||
<EmptyRow label="zapped contributors" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader icon={Zap} title="Most zapped" />
|
||||
<ul className="space-y-1.5">
|
||||
{contributors.slice(0, 5).map((c, i) => (
|
||||
<PubkeyRow
|
||||
key={c.pubkey}
|
||||
rank={i + 1}
|
||||
pubkey={c.pubkey}
|
||||
primary={`${formatCompact(c.totalSats)} sats`}
|
||||
secondary={`${c.postCount} posts · avg ${formatCompact(c.avgSats)}`}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopDonorsList({ donors }: { donors: TopDonor[] }) {
|
||||
if (!donors.length) {
|
||||
return (
|
||||
@@ -292,7 +262,6 @@ function TopDonorsList({ donors }: { donors: TopDonor[] }) {
|
||||
rank={i + 1}
|
||||
pubkey={d.pubkey}
|
||||
primary={`${formatCompact(d.totalSats)} sats`}
|
||||
secondary={`${d.zapCount} zaps`}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -393,13 +362,13 @@ function PanelSkeleton({ className, compact }: { className?: string; compact?: b
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function DetailCommentComposer({
|
||||
replyTo={event}
|
||||
placeholder={placeholder}
|
||||
onSuccess={onSuccess}
|
||||
className="!bg-[hsl(24_100%_99%)] dark:!bg-[hsl(24_30%_12%)] border-t border-b border-t-primary/40 border-b-primary/20 rounded-t-2xl"
|
||||
className="!bg-[hsl(40_100%_99%)] dark:!bg-[hsl(40_30%_12%)] border-t border-b border-t-primary/40 border-b-primary/20 rounded-t-2xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,800 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
HandHeart,
|
||||
Heart,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Sparkle,
|
||||
Sparkles,
|
||||
Star,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CampaignWalletDonatePanel } from '@/components/CampaignWalletDonatePanel';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import AuthDialog from '@/components/auth/AuthDialog';
|
||||
import { BroadcastErrorAlert } from '@/components/BroadcastErrorAlert';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDonateCampaign, type DonateCampaignResult, type DonationFeeSpeed } from '@/hooks/useDonateCampaign';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
BITCOIN_DUST_LIMIT,
|
||||
estimateFee,
|
||||
fetchUTXOs,
|
||||
formatSats,
|
||||
getFeeRates,
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
satsToUSD,
|
||||
usdToSats,
|
||||
type FeeRates,
|
||||
} from '@/lib/bitcoin';
|
||||
import {
|
||||
classifyBroadcastError,
|
||||
type BroadcastErrorKind,
|
||||
} from '@/lib/bitcoinBroadcastError';
|
||||
import {
|
||||
type ParsedCampaign,
|
||||
} from '@/lib/campaign';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Donation presets in USD. The signed event and Bitcoin transaction still use
|
||||
* sats; USD is only the user-facing input currency.
|
||||
*/
|
||||
const PRESET_AMOUNTS: readonly { amountUsd: number; icon: React.ComponentType<{ className?: string }>; label: string }[] = [
|
||||
{ amountUsd: 10, icon: Sparkle, label: '$10' },
|
||||
{ amountUsd: 25, icon: Sparkles, label: '$25' },
|
||||
{ amountUsd: 100, icon: Star, label: '$100' },
|
||||
{ amountUsd: 500, icon: Heart, label: '$500' },
|
||||
{ amountUsd: 1_000, icon: HandHeart, label: '$1K' },
|
||||
];
|
||||
|
||||
function parseUsdInput(input: string): number {
|
||||
const cleaned = input.replace(/[, $]/g, '').trim();
|
||||
if (!cleaned) return 0;
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
}
|
||||
|
||||
function feeRateForSpeed(rates: FeeRates, speed: DonationFeeSpeed): number {
|
||||
return {
|
||||
fastest: rates.fastestFee,
|
||||
halfHour: rates.halfHourFee,
|
||||
hour: rates.hourFee,
|
||||
economy: rates.economyFee,
|
||||
}[speed];
|
||||
}
|
||||
|
||||
function estimateDonationFee({
|
||||
feeRate,
|
||||
utxoCount,
|
||||
}: {
|
||||
feeRate: number;
|
||||
utxoCount: number;
|
||||
}): number {
|
||||
// Single recipient + change output.
|
||||
return estimateFee(utxoCount, 2, feeRate);
|
||||
}
|
||||
|
||||
interface DonateDialogProps {
|
||||
campaign: ParsedCampaign;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Spot price of BTC in USD, used for inline USD previews. Optional. */
|
||||
btcPrice?: number;
|
||||
}
|
||||
|
||||
type Step = 'form' | 'confirm' | 'success';
|
||||
|
||||
/**
|
||||
* Donate dialog for **on-chain** (`bc1q…` / `bc1p…`) campaigns. The
|
||||
* campaign's `w` wallet endpoint is the single output destination —
|
||||
* there are no recipient splits, no per-recipient previews, and no
|
||||
* dust math beyond the one-output PSBT.
|
||||
*
|
||||
* Silent-payment campaigns (`sp1…`) never open this dialog; their
|
||||
* detail-page donate column points directly at the SP code via the
|
||||
* `CampaignWalletDonatePanel` so donors can scan/copy and pay from a
|
||||
* BIP-352-aware external wallet.
|
||||
*/
|
||||
export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { canSignPsbt } = useBitcoinSigner();
|
||||
const { donateToCampaign } = useDonateCampaign();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [step, setStep] = useState<Step>('form');
|
||||
const [amountUsd, setAmountUsd] = useState<number>(PRESET_AMOUNTS[1].amountUsd);
|
||||
const [customUsd, setCustomUsd] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [feeSpeed, setFeeSpeed] = useState<DonationFeeSpeed>('fastest');
|
||||
const [result, setResult] = useState<DonateCampaignResult | null>(null);
|
||||
/**
|
||||
* Classified failure from the most recent broadcast attempt. Renders as
|
||||
* an inline {@link BroadcastErrorAlert} above the Send button in the
|
||||
* confirm step. Cleared when the donor adjusts the fee speed or returns
|
||||
* to the form step.
|
||||
*/
|
||||
const [broadcastError, setBroadcastError] = useState<BroadcastErrorKind | null>(null);
|
||||
|
||||
// Reset when the dialog reopens for a fresh donation.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('form');
|
||||
setResult(null);
|
||||
setBroadcastError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Clear the broadcast-error alert whenever the donor adjusts the fee
|
||||
// speed — the explicit recovery action — so the alert disappears when
|
||||
// they engage with the picker.
|
||||
useEffect(() => {
|
||||
setBroadcastError(null);
|
||||
}, [feeSpeed]);
|
||||
|
||||
const effectiveUsd = customUsd.trim()
|
||||
? parseUsdInput(customUsd)
|
||||
: amountUsd;
|
||||
const effectiveAmount = usdToSats(effectiveUsd, btcPrice);
|
||||
|
||||
const belowDust = Number.isFinite(effectiveAmount) && effectiveAmount > 0 && effectiveAmount < BITCOIN_DUST_LIMIT;
|
||||
|
||||
const donateMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
donateToCampaign({
|
||||
campaign,
|
||||
amountSats: effectiveAmount,
|
||||
comment,
|
||||
feeSpeed,
|
||||
}),
|
||||
onSuccess: (r) => {
|
||||
setResult(r);
|
||||
setStep('success');
|
||||
if (!r.receiptPublished) {
|
||||
toast({
|
||||
title: 'Donation sent, but the receipt failed',
|
||||
description: `On-chain tx ${r.txid.slice(0, 12)}… broadcast; the kind 8333 receipt didn't publish${r.receiptPublishError ? ` (${r.receiptPublishError})` : ''}.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Donation sent',
|
||||
description: `Thanks for supporting ${campaign.title}.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const classified = classifyBroadcastError(error);
|
||||
setBroadcastError(classified);
|
||||
// Inline `<BroadcastErrorAlert>` in the confirm step is the primary
|
||||
// recovery surface for classified failures; a destructive toast on
|
||||
// top would just be noise. Keep the toast as a fallback for the
|
||||
// catch-all `unknown` bucket so the donor always sees *something*
|
||||
// even when we can't recognise the reject reason.
|
||||
if (classified.kind === 'unknown') {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
toast({
|
||||
title: 'Donation failed',
|
||||
description: msg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
if (donateMutation.isPending) return;
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// ── Logged-out flow ──
|
||||
if (open && !user) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<LoggedOutChooserView
|
||||
campaign={campaign}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Logged-in but the signer can't build a PSBT (e.g. NIP-07 extension
|
||||
// without signPsbt). Direct the donor at the external-wallet panel on
|
||||
// the page — the in-app flow simply isn't possible without a PSBT
|
||||
// signer.
|
||||
if (open && !canSignPsbt) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<SignerUnsupportedView campaign={campaign} onClose={handleClose} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
||||
{step === 'form' && (
|
||||
<FormView
|
||||
campaign={campaign}
|
||||
amountUsd={amountUsd}
|
||||
customUsd={customUsd}
|
||||
comment={comment}
|
||||
feeSpeed={feeSpeed}
|
||||
effectiveAmount={effectiveAmount}
|
||||
effectiveUsd={effectiveUsd}
|
||||
belowDust={belowDust}
|
||||
btcPrice={btcPrice}
|
||||
isPending={donateMutation.isPending}
|
||||
onAmountChange={(usd) => {
|
||||
setAmountUsd(usd);
|
||||
setCustomUsd('');
|
||||
}}
|
||||
onCustomChange={setCustomUsd}
|
||||
onCommentChange={setComment}
|
||||
onFeeSpeedChange={setFeeSpeed}
|
||||
onContinue={() => setStep('confirm')}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<ConfirmView
|
||||
campaign={campaign}
|
||||
amountSats={effectiveAmount}
|
||||
effectiveUsd={effectiveUsd}
|
||||
comment={comment}
|
||||
feeSpeed={feeSpeed}
|
||||
btcPrice={btcPrice}
|
||||
isPending={donateMutation.isPending}
|
||||
broadcastError={broadcastError}
|
||||
onBack={() => {
|
||||
setBroadcastError(null);
|
||||
setStep('form');
|
||||
}}
|
||||
onSubmit={() => donateMutation.mutate()}
|
||||
onBumpFee={() => {
|
||||
// Step toward the fastest preset. BITCOIN_FEE_SPEED_ORDER is
|
||||
// declared fast → slow; index 0 is `fastest`, so "bump" means
|
||||
// moving toward index 0.
|
||||
const order: DonationFeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
|
||||
const idx = order.indexOf(feeSpeed);
|
||||
if (idx > 0) setFeeSpeed(order[idx - 1]);
|
||||
// `useEffect([feeSpeed])` clears the broadcastError alert.
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'success' && result && (
|
||||
<SuccessView
|
||||
campaign={campaign}
|
||||
result={result}
|
||||
btcPrice={btcPrice}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Form step
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FormViewProps {
|
||||
campaign: ParsedCampaign;
|
||||
amountUsd: number;
|
||||
customUsd: string;
|
||||
comment: string;
|
||||
feeSpeed: DonationFeeSpeed;
|
||||
effectiveAmount: number;
|
||||
effectiveUsd: number;
|
||||
belowDust: boolean;
|
||||
btcPrice: number | undefined;
|
||||
isPending: boolean;
|
||||
onAmountChange: (usd: number) => void;
|
||||
onCustomChange: (value: string) => void;
|
||||
onCommentChange: (value: string) => void;
|
||||
onFeeSpeedChange: (speed: DonationFeeSpeed) => void;
|
||||
onContinue: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function FormView({
|
||||
campaign,
|
||||
amountUsd,
|
||||
customUsd,
|
||||
comment,
|
||||
feeSpeed,
|
||||
effectiveAmount,
|
||||
effectiveUsd,
|
||||
belowDust,
|
||||
btcPrice,
|
||||
isPending,
|
||||
onAmountChange,
|
||||
onCustomChange,
|
||||
onCommentChange,
|
||||
onFeeSpeedChange,
|
||||
onContinue,
|
||||
}: FormViewProps) {
|
||||
const usingCustom = customUsd.trim().length > 0;
|
||||
const canContinue = effectiveAmount > 0 && !belowDust;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Donate to {campaign.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send Bitcoin to the campaign's wallet from your in-app balance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Preset amounts */}
|
||||
<div>
|
||||
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Amount
|
||||
</Label>
|
||||
<div className="mt-2 grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
{PRESET_AMOUNTS.map(({ amountUsd: usd, icon: Icon, label }) => {
|
||||
const selected = !usingCustom && amountUsd === usd;
|
||||
return (
|
||||
<button
|
||||
key={usd}
|
||||
type="button"
|
||||
onClick={() => onAmountChange(usd)}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 rounded-lg border px-2 py-2.5 text-xs font-semibold motion-safe:transition-colors',
|
||||
selected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-card hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom amount */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="donate-custom" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Or custom (USD)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="donate-custom"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="50"
|
||||
value={customUsd}
|
||||
onChange={(e) => onCustomChange(e.target.value)}
|
||||
className="pl-7"
|
||||
/>
|
||||
</div>
|
||||
{effectiveAmount > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
≈ {formatSats(effectiveAmount)} sats
|
||||
{btcPrice && effectiveUsd > 0 && (
|
||||
<> · ${effectiveUsd.toLocaleString()} at current price</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="donate-comment" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Public comment (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="donate-comment"
|
||||
value={comment}
|
||||
onChange={(e) => onCommentChange(e.target.value)}
|
||||
placeholder="Stay strong."
|
||||
rows={2}
|
||||
maxLength={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fee speed */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Confirmation speed
|
||||
</Label>
|
||||
<Select value={feeSpeed} onValueChange={(v) => onFeeSpeedChange(v as DonationFeeSpeed)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fastest">Fastest (~10 min)</SelectItem>
|
||||
<SelectItem value="halfHour">Half hour</SelectItem>
|
||||
<SelectItem value="hour">Hour</SelectItem>
|
||||
<SelectItem value="economy">Economy (cheapest)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{belowDust && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertDescription>
|
||||
Amount is below the Bitcoin dust limit ({BITCOIN_DUST_LIMIT.toLocaleString()} sats).
|
||||
Choose a larger amount.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<BitcoinPublicDisclaimer tone="soft" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onContinue}
|
||||
disabled={!canContinue || isPending}
|
||||
>
|
||||
Review donation
|
||||
<ArrowUpRight className="size-4 ml-1.5" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Confirm step
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ConfirmViewProps {
|
||||
campaign: ParsedCampaign;
|
||||
amountSats: number;
|
||||
effectiveUsd: number;
|
||||
comment: string;
|
||||
feeSpeed: DonationFeeSpeed;
|
||||
btcPrice: number | undefined;
|
||||
isPending: boolean;
|
||||
/** Classified failure from the most recent broadcast attempt, if any. */
|
||||
broadcastError: BroadcastErrorKind | null;
|
||||
onBack: () => void;
|
||||
onSubmit: () => void;
|
||||
/** Steps `feeSpeed` toward the fastest preset; no-op once at `fastest`. */
|
||||
onBumpFee: () => void;
|
||||
}
|
||||
|
||||
function ConfirmView({
|
||||
campaign,
|
||||
amountSats,
|
||||
effectiveUsd,
|
||||
comment,
|
||||
feeSpeed,
|
||||
btcPrice,
|
||||
isPending,
|
||||
broadcastError,
|
||||
onBack,
|
||||
onSubmit,
|
||||
onBumpFee,
|
||||
}: ConfirmViewProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
|
||||
const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : null;
|
||||
|
||||
// Pre-fetch UTXOs + fee rates so the confirm screen can show an
|
||||
// accurate fee estimate before the donor commits.
|
||||
const utxosQuery = useQuery({
|
||||
queryKey: ['bitcoin-utxos', senderAddress, esploraApis],
|
||||
queryFn: ({ signal }) => fetchUTXOs(senderAddress!, esploraApis, signal),
|
||||
enabled: !!senderAddress,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const feeRatesQuery = useQuery({
|
||||
queryKey: ['bitcoin-fee-rates', esploraApis],
|
||||
queryFn: ({ signal }) => getFeeRates(esploraApis, signal),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const feeEstimate = useMemo(() => {
|
||||
const utxos = utxosQuery.data;
|
||||
const rates = feeRatesQuery.data;
|
||||
if (!utxos || !rates) return null;
|
||||
return estimateDonationFee({
|
||||
feeRate: feeRateForSpeed(rates, feeSpeed),
|
||||
utxoCount: utxos.length,
|
||||
});
|
||||
}, [utxosQuery.data, feeRatesQuery.data, feeSpeed]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground motion-safe:transition-colors -ml-1"
|
||||
disabled={isPending}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back
|
||||
</button>
|
||||
<DialogTitle>Confirm donation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the details before signing the transaction.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<Row label="Campaign" value={campaign.title} />
|
||||
<Row
|
||||
label="Amount"
|
||||
value={
|
||||
<span>
|
||||
<span className="font-semibold">{formatSats(amountSats)} sats</span>
|
||||
{btcPrice && effectiveUsd > 0 && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">≈ ${effectiveUsd.toLocaleString()}</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="To wallet"
|
||||
value={
|
||||
<span className="font-mono text-xs break-all">{campaign.wallets.onchain?.value ?? ''}</span>
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Network fee"
|
||||
value={
|
||||
feeEstimate === null ? (
|
||||
<Skeleton className="h-4 w-20" />
|
||||
) : (
|
||||
<span>
|
||||
<span className="font-semibold">{formatSats(feeEstimate)} sats</span>
|
||||
{btcPrice && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
≈ ${satsToUSD(feeEstimate, btcPrice)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{comment.trim() && (
|
||||
<Row label="Comment" value={<span className="italic">"{comment}"</span>} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Classified broadcast failure with an actionable bump-fee recovery.
|
||||
Sits between the donation rows and the Send button so the donor
|
||||
sees the alert in the same visual region they're about to tap.
|
||||
`presetTiersOnly` hides the bump button once they're on the
|
||||
fastest preset — at that point the recommendation is to switch
|
||||
to an external wallet via the panel on the campaign detail page. */}
|
||||
{broadcastError && (
|
||||
<BroadcastErrorAlert
|
||||
error={broadcastError}
|
||||
currentFeeRate={feeRatesQuery.data ? feeRateForSpeed(feeRatesQuery.data, feeSpeed) : undefined}
|
||||
feeSpeed={feeSpeed}
|
||||
feeRates={feeRatesQuery.data}
|
||||
isPending={isPending}
|
||||
onBumpFee={onBumpFee}
|
||||
onRetry={onSubmit}
|
||||
presetTiersOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onSubmit}
|
||||
disabled={isPending || feeEstimate === null}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Sending donation…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HandHeart className="size-5 mr-2" />
|
||||
Send donation
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 text-sm">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="text-right min-w-0">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Success step
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SuccessView({
|
||||
campaign,
|
||||
result,
|
||||
btcPrice,
|
||||
onClose,
|
||||
}: {
|
||||
campaign: ParsedCampaign;
|
||||
result: DonateCampaignResult;
|
||||
btcPrice: number | undefined;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="mx-auto rounded-full bg-primary/15 p-3 mb-2">
|
||||
<Check className="size-8 text-primary" />
|
||||
</div>
|
||||
<DialogTitle className="text-center">Thank you!</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
Your donation to <span className="font-semibold text-foreground">{campaign.title}</span> is on its way.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
<Row
|
||||
label="Amount"
|
||||
value={
|
||||
<span className="font-semibold">
|
||||
{formatSats(result.totalSats)} sats
|
||||
{btcPrice && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
≈ ${satsToUSD(result.totalSats, btcPrice)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Network fee"
|
||||
value={<span>{formatSats(result.fee)} sats</span>}
|
||||
/>
|
||||
<Row
|
||||
label="Transaction"
|
||||
value={
|
||||
<a
|
||||
href={`https://mempool.space/tx/${result.txid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-xs text-primary hover:underline break-all"
|
||||
>
|
||||
{result.txid.slice(0, 16)}…
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button size="lg" className="w-full" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Logged-out chooser
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function LoggedOutChooserView({
|
||||
campaign,
|
||||
onClose,
|
||||
}: {
|
||||
campaign: ParsedCampaign;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Donate to {campaign.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Log in to donate from your in-app wallet, or scan the QR on the
|
||||
campaign page to pay from any external Bitcoin wallet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => setAuthOpen(true)}
|
||||
>
|
||||
<LogIn className="size-4 mr-2" />
|
||||
Log in to donate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
Pay from external wallet instead
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AuthDialog isOpen={authOpen} onClose={() => setAuthOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Signer-unsupported fallback
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SignerUnsupportedView({
|
||||
campaign,
|
||||
onClose,
|
||||
}: {
|
||||
campaign: ParsedCampaign;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Donate to {campaign.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan the QR code with your phone's Bitcoin wallet, or tap "Open in
|
||||
wallet" to send your donation. You choose the amount in your wallet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<CampaignWalletDonatePanel wallets={campaign.wallets} />
|
||||
|
||||
<Button variant="outline" size="lg" className="w-full" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Loading skeleton (for callers that need a placeholder button)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Image, Film, Music, ExternalLink, Blocks, MessageSquareOff, Zap } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Image, Film, Music, ExternalLink, Blocks, MessageSquareOff } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
|
||||
import { VanishCardCompact } from '@/components/VanishEventContent';
|
||||
import { EncryptedMessageCompact } from '@/components/EncryptedMessageContent';
|
||||
import { EncryptedLetterCompact } from '@/components/EncryptedLetterContent';
|
||||
import { EmbeddedProfileBadgesCard } from '@/components/EmbeddedNaddr';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { useEvent } from '@/hooks/useEvent';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { isProfileBadgesKind } from '@/lib/badgeUtils';
|
||||
import { extractZapAmount, extractZapSender, extractZapMessage } from '@/hooks/useEventInteractions';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
@@ -76,112 +68,9 @@ export function EmbeddedNote({ eventId, relays, authorHint, className, disableHo
|
||||
return <EmbeddedProfileBadgesCard event={event} className={className} />;
|
||||
}
|
||||
|
||||
// Kind 9735 zap receipts get a compact zap card instead of rendering raw JSON
|
||||
if (event.kind === 9735) {
|
||||
return <EmbeddedZapCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
return <EmbeddedNoteCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
/** Compact inline card for kind 9735 zap receipts. */
|
||||
function EmbeddedZapCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const neventId = useMemo(
|
||||
() => nip19.neventEncode({ id: event.id, author: event.pubkey }),
|
||||
[event.id, event.pubkey],
|
||||
);
|
||||
|
||||
const senderPubkey = useMemo(() => extractZapSender(event), [event]);
|
||||
const amountSats = useMemo(() => Math.floor(extractZapAmount(event) / 1000), [event]);
|
||||
const message = useMemo(() => extractZapMessage(event), [event]);
|
||||
|
||||
const sender = useAuthor(senderPubkey || undefined);
|
||||
const senderMeta = sender.data?.metadata;
|
||||
const senderName = senderMeta?.name || (senderPubkey ? genUserName(senderPubkey) : 'Someone');
|
||||
const senderProfileUrl = useProfileUrl(senderPubkey, senderMeta);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group block rounded-2xl border border-border overflow-hidden',
|
||||
'hover:bg-secondary/40 transition-colors cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/${neventId}`);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(`/${neventId}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-2.5 flex items-center gap-2.5 min-w-0">
|
||||
{/* Zap icon */}
|
||||
<div className="flex items-center justify-center size-9 rounded-full bg-amber-500/10 shrink-0">
|
||||
<Zap className="size-4 text-amber-500 fill-amber-500" />
|
||||
</div>
|
||||
|
||||
{/* Sender avatar */}
|
||||
{senderPubkey && (
|
||||
<MaybeHoverCard pubkey={senderPubkey} disabled={disableHoverCards}>
|
||||
<Link to={senderProfileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={senderMeta?.picture} alt={senderName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{senderName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</MaybeHoverCard>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{senderPubkey ? (
|
||||
<MaybeHoverCard pubkey={senderPubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={senderProfileUrl}
|
||||
className="text-sm font-semibold truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sender.data?.event ? (
|
||||
<EmojifiedText tags={sender.data.event.tags}>{senderName}</EmojifiedText>
|
||||
) : senderName}
|
||||
</Link>
|
||||
</MaybeHoverCard>
|
||||
) : (
|
||||
<span className="text-sm font-semibold truncate">Someone</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">zapped</span>
|
||||
{amountSats > 0 && (
|
||||
<span className="text-sm font-semibold text-amber-500 shrink-0">
|
||||
{formatNumber(amountSats)} {amountSats === 1 ? 'sat' : 'sats'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
· {timeAgo(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{message && (
|
||||
<p className="text-xs text-muted-foreground italic mt-0.5 line-clamp-2">
|
||||
“{message}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The actual card once the event has been fetched. */
|
||||
function EmbeddedNoteCard({
|
||||
event,
|
||||
@@ -469,16 +358,6 @@ function EmbeddedNoteTombstone({ eventId, relays, authorHint, className }: { eve
|
||||
);
|
||||
}
|
||||
|
||||
/** Conditionally wraps children in a ProfileHoverCard. */
|
||||
function MaybeHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
|
||||
if (disabled) return <>{children}</>;
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
{children}
|
||||
</ProfileHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('rounded-2xl border border-border overflow-hidden', className)}>
|
||||
|
||||
@@ -269,7 +269,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
hideBorder
|
||||
defaultExpanded
|
||||
placeholder={t('feed.compose.placeholder')}
|
||||
className="!bg-[hsl(24_100%_99%)] dark:!bg-[hsl(24_30%_12%)] border-t border-b border-t-primary/40 border-b-primary/20 rounded-t-2xl"
|
||||
className="!bg-[hsl(40_100%_99%)] dark:!bg-[hsl(40_30%_12%)] border-t border-b border-t-primary/40 border-b-primary/20 rounded-t-2xl"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+14
-56
@@ -1,16 +1,13 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Clock, Info, Target, Users, Zap } from 'lucide-react';
|
||||
import { Clock, Target, Users } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useGoalDisplay } from '@/hooks/useGoalDisplay';
|
||||
import { formatSats, parseGoalEvent, type ParsedGoal } from '@/lib/goalUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface GoalCardProps {
|
||||
event: NostrEvent;
|
||||
@@ -18,16 +15,18 @@ interface GoalCardProps {
|
||||
|
||||
/**
|
||||
* Inline goal content renderer for NoteCard feeds and PostDetailPage.
|
||||
* Displays progress bar, recipient info, deadline, and community link.
|
||||
* Displays the goal target, recipient info, deadline, and community link.
|
||||
* The raised tally is intentionally omitted — the Grin tally arrives in a
|
||||
* later phase.
|
||||
*/
|
||||
export function GoalCard({ event }: GoalCardProps) {
|
||||
const goal = useMemo(() => parseGoalEvent(event), [event]);
|
||||
if (!goal) return null;
|
||||
return <GoalCardInner event={event} goal={goal} />;
|
||||
return <GoalCardInner goal={goal} />;
|
||||
}
|
||||
|
||||
function GoalCardInner({ event, goal }: { event: NostrEvent; goal: ParsedGoal }) {
|
||||
const d = useGoalDisplay(event, goal);
|
||||
function GoalCardInner({ goal }: { goal: ParsedGoal }) {
|
||||
const d = useGoalDisplay(goal);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -61,11 +60,7 @@ function GoalCardInner({ event, goal }: { event: NostrEvent; goal: ParsedGoal })
|
||||
{d.communityName}
|
||||
</Link>
|
||||
)}
|
||||
{d.funded ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
|
||||
Funded
|
||||
</Badge>
|
||||
) : d.expired ? (
|
||||
{d.expired ? (
|
||||
<Badge variant="secondary">Ended</Badge>
|
||||
) : d.deadlineLabel ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -81,45 +76,15 @@ function GoalCardInner({ event, goal }: { event: NostrEvent; goal: ParsedGoal })
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">{goal.summary}</p>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{/* Goal target — raised tally arrives in a later phase */}
|
||||
<div className="space-y-1.5">
|
||||
<Progress
|
||||
value={d.percentage}
|
||||
className={cn('h-2.5', d.funded && '[&>div]:bg-emerald-500')}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{d.progressLoading ? (
|
||||
<Skeleton className="h-3.5 w-16 inline-block" />
|
||||
) : d.progressIsPartial ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help underline decoration-dotted underline-offset-2">
|
||||
~{formatSats(d.currentSats)} sats
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
||||
Showing at least this amount. More zap receipts exist than we
|
||||
could tally in a single fetch, so the real total is higher.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{formatSats(d.currentSats)} sats</>
|
||||
)}
|
||||
</span>
|
||||
<span>of {formatSats(goal.amountSats)} sats ({d.percentage}%)</span>
|
||||
<Progress value={0} className="h-2.5" />
|
||||
<div className="flex items-center justify-end text-xs text-muted-foreground">
|
||||
<span>Goal: {formatSats(goal.amountSats)} sats</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{goal.hasZapSplits ? (
|
||||
// TODO: Render and support NIP-57 zap splits for NIP-75 goals.
|
||||
<div className="flex items-start gap-2.5 rounded-lg bg-muted/50 px-3 py-2 text-muted-foreground">
|
||||
<Info className="size-4 mt-0.5 shrink-0" />
|
||||
<p className="text-xs leading-relaxed">
|
||||
This goal uses split recipients. Split zap support is not available in this app yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
{/* Recipient */}
|
||||
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2">
|
||||
<Link to={d.profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar className="size-8 ring-2 ring-background">
|
||||
@@ -130,7 +95,7 @@ function GoalCardInner({ event, goal }: { event: NostrEvent; goal: ParsedGoal })
|
||||
</Avatar>
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-muted-foreground">Receiving zaps</p>
|
||||
<p className="text-xs text-muted-foreground">Recipient</p>
|
||||
<Link
|
||||
to={d.profileUrl}
|
||||
className="text-sm font-medium truncate block hover:underline"
|
||||
@@ -138,15 +103,8 @@ function GoalCardInner({ event, goal }: { event: NostrEvent; goal: ParsedGoal })
|
||||
>
|
||||
{d.displayName}
|
||||
</Link>
|
||||
{d.lightningAddress && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={d.lightningAddress}>
|
||||
<Zap className="size-3 inline-block mr-0.5 -mt-0.5" />
|
||||
{d.lightningAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* GrinPayDialog — the Grin donate flow (Plan 2, C2/C3), replacing the old
|
||||
* BTC DonateDialog at the campaign donate trigger.
|
||||
*
|
||||
* Two optional paths, tabbed when both are available:
|
||||
*
|
||||
* 1. **GoblinPay** — the in-app automatic path. Creates an invoice on the
|
||||
* instance's GoblinPay server, renders the receiving `nprofile` as a QR
|
||||
* (scan with a Goblin wallet; the payment travels as a gift-wrapped
|
||||
* slatepack over Nostr), polls live status (waiting → received →
|
||||
* confirmed), links the hosted checkout, and offers the manual
|
||||
* slatepack fallback (paste S1 → get S2 back) when the automatic flow
|
||||
* can't be used. After payment, the donor can publish the server-signed
|
||||
* receipt as a kind-3414 event so the campaign's proof-verified tally
|
||||
* picks it up.
|
||||
*
|
||||
* 2. **Grin address** — the native path. Shows the campaign's published
|
||||
* `grin1…` Slatepack address + QR; the donor pays from any Grin wallet
|
||||
* (Tor-interactive or slatepack — the donor's wallet handles transport,
|
||||
* Eranos never runs a wallet or Tor). Afterwards the donor can paste
|
||||
* their wallet's payment proof; Eranos verifies it locally (receiver
|
||||
* signature + kernel on-chain) and publishes it for the tally.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, Copy, ExternalLink, HandHeart, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
useCreateGrinInvoice,
|
||||
useGrinInvoiceStatus,
|
||||
useGrinPayConfig,
|
||||
useGrinPayment,
|
||||
useGrinReceipt,
|
||||
useManualSlatepack,
|
||||
} from '@/hooks/useGrinPay';
|
||||
import { formatGrin, parseGrinAmount } from '@/lib/goblinPay';
|
||||
import {
|
||||
GRIN_DONATION_KIND,
|
||||
kernelOnChain,
|
||||
bytesToHex,
|
||||
decodeSlatepackAddress,
|
||||
parseReceiverProof,
|
||||
verifyReceiverProof,
|
||||
} from '@/lib/grinProof';
|
||||
import { grinDonationPaths, type ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
interface GrinPayDialogProps {
|
||||
campaign: ParsedCampaign;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** Small copy-to-clipboard row used for nprofile / grin1 / slatepack values. */
|
||||
function CopyValue({ value, label }: { value: string; label: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// clipboard unavailable — the value is still selectable below
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="flex w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={label}
|
||||
>
|
||||
{copied
|
||||
? <Check className="size-3.5 text-primary flex-shrink-0" />
|
||||
: <Copy className="size-3.5 flex-shrink-0" />}
|
||||
<span className="truncate font-mono" dir="ltr">{value}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** QR in a white surface so it scans on both themes. */
|
||||
function PayQR({ value }: { value: string }) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-2xl bg-white p-3 shadow-sm">
|
||||
<QRCodeCanvas value={value} size={224} level="M" className="h-auto w-full max-w-56" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── GoblinPay invoice flow ──────────────────────────────────────────
|
||||
|
||||
function GoblinPayFlow({ campaign, dialogOpen }: { campaign: ParsedCampaign; dialogOpen: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: publishEvent, isPending: publishing } = useNostrPublish();
|
||||
|
||||
const [amount, setAmount] = useState('');
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const [s1, setS1] = useState('');
|
||||
const [s2, setS2] = useState('');
|
||||
const [receiptPublished, setReceiptPublished] = useState(false);
|
||||
|
||||
const createInvoice = useCreateGrinInvoice();
|
||||
const invoice = createInvoice.data;
|
||||
|
||||
const { data: status } = useGrinInvoiceStatus(invoice?.token, dialogOpen && !!invoice);
|
||||
const paidPaymentId = status?.paidPaymentId ?? null;
|
||||
const { data: payment } = useGrinPayment(paidPaymentId, dialogOpen && !!paidPaymentId);
|
||||
const { data: receipt } = useGrinReceipt(paidPaymentId, dialogOpen && !!paidPaymentId);
|
||||
|
||||
const manual = useManualSlatepack();
|
||||
|
||||
const amountNanogrin = parseGrinAmount(amount);
|
||||
|
||||
const onCreate = async () => {
|
||||
if (amountNanogrin === null) return;
|
||||
try {
|
||||
await createInvoice.mutateAsync({
|
||||
amountNanogrin,
|
||||
orderRef: campaign.aTag,
|
||||
memo: t('grinPay.invoiceMemo', { title: campaign.title }),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('grinPay.errorCreateInvoice'),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onManualSubmit = async () => {
|
||||
if (!invoice || !s1.trim()) return;
|
||||
try {
|
||||
const armor = await manual.mutateAsync({ token: invoice.token, s1 });
|
||||
setS2(armor);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('grinPay.errorManual'),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Publish the server-signed receipt as a kind-3414 donation event so the
|
||||
* campaign's proof-verified tally can count it. The receipt's RAW bytes
|
||||
* are the event content — re-serializing could break the BIP-340
|
||||
* signature for readers.
|
||||
*/
|
||||
const onPublishReceipt = async () => {
|
||||
if (!receipt || !user) return;
|
||||
try {
|
||||
await publishEvent({
|
||||
kind: GRIN_DONATION_KIND,
|
||||
content: receipt.raw,
|
||||
tags: [
|
||||
['a', campaign.aTag],
|
||||
['p', campaign.pubkey],
|
||||
['alt', `Grin donation receipt for campaign "${campaign.title}"`],
|
||||
],
|
||||
});
|
||||
setReceiptPublished(true);
|
||||
void queryClient.invalidateQueries({ queryKey: ['campaign-grin-total', campaign.aTag] });
|
||||
toast({ title: t('grinPay.receiptPublished'), description: t('grinPay.receiptPublishedDesc') });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('grinPay.errorPublish'),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ── Step 1: amount ──
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('grinPay.goblinPayHelp')}</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder={t('grinPay.amountPlaceholder')}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-label={t('grinPay.amountLabel')}
|
||||
className="pr-16"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
|
||||
GRIN
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={amountNanogrin === null || createInvoice.isPending}
|
||||
onClick={onCreate}
|
||||
>
|
||||
{createInvoice.isPending
|
||||
? <Loader2 className="size-4 mr-2 animate-spin" />
|
||||
: <HandHeart className="size-4 mr-2" />}
|
||||
{t('grinPay.createInvoice')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2: pay + live status ──
|
||||
const isPaid = status?.status === 'paid' || !!paidPaymentId;
|
||||
const isExpired = status?.status === 'expired';
|
||||
const isConfirmed = payment?.status === 'confirmed';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!isPaid && !isExpired && (
|
||||
<>
|
||||
<PayQR value={invoice.nprofile} />
|
||||
<div className="space-y-1.5">
|
||||
<CopyValue value={invoice.nprofile} label={t('grinPay.copyNprofile')} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('grinPay.scanToPay', { amount: invoice.amount })}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={invoice.payUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t('grinPay.openCheckout')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Live status */}
|
||||
<div className="rounded-lg bg-muted/60 px-3 py-2.5 text-sm" aria-live="polite">
|
||||
{isExpired ? (
|
||||
<span className="text-destructive">{t('grinPay.statusExpired')}</span>
|
||||
) : isConfirmed ? (
|
||||
<span className="inline-flex items-center gap-1.5 font-medium text-primary">
|
||||
<Check className="size-4" />
|
||||
{t('grinPay.statusConfirmed', {
|
||||
amount: payment ? formatGrin(payment.amount) : invoice.amount,
|
||||
})}
|
||||
</span>
|
||||
) : isPaid ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('grinPay.statusReceived')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('grinPay.statusWaiting')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Record the donation for the public tally (needs the signed receipt). */}
|
||||
{isPaid && receipt && user && !receiptPublished && (
|
||||
<Button className="w-full" variant="outline" disabled={publishing} onClick={onPublishReceipt}>
|
||||
{publishing && <Loader2 className="size-4 mr-2 animate-spin" />}
|
||||
{t('grinPay.publishReceipt')}
|
||||
</Button>
|
||||
)}
|
||||
{receiptPublished && (
|
||||
<p className="text-xs text-muted-foreground">{t('grinPay.donationRecorded')}</p>
|
||||
)}
|
||||
|
||||
{/* Manual slatepack fallback */}
|
||||
{!isPaid && !isExpired && (
|
||||
<div className="border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowManual((v) => !v)}
|
||||
>
|
||||
{t('grinPay.manualToggle')}
|
||||
</button>
|
||||
{showManual && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t('grinPay.manualHelp')}</p>
|
||||
<Textarea
|
||||
value={s1}
|
||||
onChange={(e) => setS1(e.target.value)}
|
||||
placeholder="BEGINSLATEPACK. … ENDSLATEPACK."
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!s1.trim() || manual.isPending}
|
||||
onClick={onManualSubmit}
|
||||
>
|
||||
{manual.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
|
||||
{t('grinPay.manualSubmit')}
|
||||
</Button>
|
||||
{s2 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-muted-foreground">{t('grinPay.manualResponseHelp')}</p>
|
||||
<Textarea readOnly value={s2} rows={4} className="font-mono text-xs" dir="ltr" />
|
||||
<CopyValue value={s2} label={t('grinPay.copySlatepack')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Direct GoblinPay endpub (campaign's own receiving identity) ─────
|
||||
|
||||
function EndpubPane({ campaign }: { campaign: ParsedCampaign }) {
|
||||
const { t } = useTranslation();
|
||||
const endpub = campaign.goblinPayEndpub!;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PayQR value={endpub} />
|
||||
<CopyValue value={endpub} label={t('grinPay.copyNprofile')} />
|
||||
<p className="text-xs text-muted-foreground">{t('grinPay.endpubHelp')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Native grin1 address + proof registration ───────────────────────
|
||||
|
||||
function GrinAddressPane({ campaign, dialogOpen }: { campaign: ParsedCampaign; dialogOpen: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { grinNodeUrl } = useGrinPayConfig();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
const address = campaign.grinAddress!;
|
||||
const [proofText, setProofText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [published, setPublished] = useState(false);
|
||||
|
||||
// Reset transient proof state each time the dialog opens fresh.
|
||||
const wasOpen = useRef(dialogOpen);
|
||||
useEffect(() => {
|
||||
if (dialogOpen && !wasOpen.current) {
|
||||
setProofText('');
|
||||
setPublished(false);
|
||||
}
|
||||
wasOpen.current = dialogOpen;
|
||||
}, [dialogOpen]);
|
||||
|
||||
/**
|
||||
* Verify the pasted proof locally (parse → receiver signature → bound to
|
||||
* this campaign's address → kernel on-chain), then publish it as a
|
||||
* kind-3414 event for the tally.
|
||||
*/
|
||||
const onSubmitProof = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(proofText.trim());
|
||||
} catch {
|
||||
toast({ title: t('grinPay.proofInvalid'), description: t('grinPay.proofNotJson'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const proof = parseReceiverProof(json);
|
||||
if (!proof || !verifyReceiverProof(proof)) {
|
||||
toast({ title: t('grinPay.proofInvalid'), description: t('grinPay.proofBadSignature'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const campaignKey = decodeSlatepackAddress(address);
|
||||
if (!campaignKey || bytesToHex(proof.recipientAddress) !== bytesToHex(campaignKey)) {
|
||||
toast({ title: t('grinPay.proofInvalid'), description: t('grinPay.proofNotForCampaign'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const kernel = await kernelOnChain(grinNodeUrl, bytesToHex(proof.kernelExcess));
|
||||
if (!kernel.onChain) {
|
||||
toast({ title: t('grinPay.proofInvalid'), description: t('grinPay.proofKernelMissing'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
await publishEvent({
|
||||
kind: GRIN_DONATION_KIND,
|
||||
content: proofText.trim(),
|
||||
tags: [
|
||||
['a', campaign.aTag],
|
||||
['p', campaign.pubkey],
|
||||
['alt', `Grin payment proof for campaign "${campaign.title}"`],
|
||||
],
|
||||
});
|
||||
setPublished(true);
|
||||
void queryClient.invalidateQueries({ queryKey: ['campaign-grin-total', campaign.aTag] });
|
||||
toast({ title: t('grinPay.proofPublished'), description: t('grinPay.donationRecorded') });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('grinPay.errorPublish'),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PayQR value={address} />
|
||||
<CopyValue value={address} label={t('grinPay.copyAddress')} />
|
||||
<p className="text-xs text-muted-foreground">{t('grinPay.grinAddressHelp')}</p>
|
||||
|
||||
<div className="border-t border-border pt-3 space-y-3">
|
||||
<p className="text-sm font-medium">{t('grinPay.proofTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('grinPay.proofHelp')}</p>
|
||||
{user ? (
|
||||
published ? (
|
||||
<p className="inline-flex items-center gap-1.5 text-sm text-primary">
|
||||
<Check className="size-4" />
|
||||
{t('grinPay.proofPublished')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Textarea
|
||||
value={proofText}
|
||||
onChange={(e) => setProofText(e.target.value)}
|
||||
placeholder={t('grinPay.proofPlaceholder')}
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!proofText.trim() || submitting}
|
||||
onClick={onSubmitProof}
|
||||
>
|
||||
{submitting && <Loader2 className="size-4 mr-2 animate-spin" />}
|
||||
{t('grinPay.proofSubmit')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t('grinPay.proofLoginRequired')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── The dialog ──────────────────────────────────────────────────────
|
||||
|
||||
export function GrinPayDialog({ campaign, open, onOpenChange }: GrinPayDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { goblinPayUrl, goblinPayApiToken } = useGrinPayConfig();
|
||||
|
||||
const paths = useMemo(
|
||||
() => grinDonationPaths(campaign, goblinPayUrl, goblinPayApiToken),
|
||||
[campaign, goblinPayUrl, goblinPayApiToken],
|
||||
);
|
||||
const hasGoblinPay = paths.invoice || paths.endpub;
|
||||
const hasAddress = paths.address;
|
||||
|
||||
const goblinPayPane = paths.invoice
|
||||
? <GoblinPayFlow campaign={campaign} dialogOpen={open} />
|
||||
: <EndpubPane campaign={campaign} />;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('grinPay.title', { title: campaign.title })}</DialogTitle>
|
||||
<DialogDescription>{t('grinPay.subtitle')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{hasGoblinPay && hasAddress ? (
|
||||
<Tabs defaultValue="goblinpay">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="goblinpay">{t('grinPay.goblinPayTab')}</TabsTrigger>
|
||||
<TabsTrigger value="address">{t('grinPay.grinAddressTab')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="goblinpay" className="pt-2">{goblinPayPane}</TabsContent>
|
||||
<TabsContent value="address" className="pt-2">
|
||||
<GrinAddressPane campaign={campaign} dialogOpen={open} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : hasGoblinPay ? (
|
||||
goblinPayPane
|
||||
) : hasAddress ? (
|
||||
<GrinAddressPane campaign={campaign} dialogOpen={open} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t('grinPay.notConfigured')}</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,558 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, AlertCircle, CheckCircle2, ChevronDown, ChevronUp, HelpCircle } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useHdWalletSp } from '@/hooks/useHdWalletSpContext';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HD wallet — silent-payment "Scan history" dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Walks the user through running a BIP-352 chain scan up to the current
|
||||
// indexer tip. The primary control is a relative time window ("Since")
|
||||
// because most users know when they expect a payment, not which block it
|
||||
// landed in. The selected time window is resolved to a block height on start
|
||||
// via mempool.space's timestamp-to-block endpoint, then passed to the
|
||||
// existing scanRange API. If mempool.space is unreachable, the dialog
|
||||
// surfaces a toast pointing the user at the Advanced → From block escape
|
||||
// hatch instead of stalling on a slow indexer fallback.
|
||||
//
|
||||
// Power users can override the resolved starting height (or toggle
|
||||
// rebuild-from-spent rescans) under the "Advanced" disclosure.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HDSilentPaymentScanDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Since" presets — relative time windows expressed in wall-clock seconds.
|
||||
* Block height is resolved from real block timestamps at scan time, not by
|
||||
* assuming an average block interval.
|
||||
*/
|
||||
const PRESETS = {
|
||||
lastHour: { seconds: 60 * 60 },
|
||||
last3h: { seconds: 3 * 60 * 60 },
|
||||
last24h: { seconds: 24 * 60 * 60 },
|
||||
lastWeek: { seconds: 7 * 24 * 60 * 60 },
|
||||
lastMonth: { seconds: 30 * 24 * 60 * 60 },
|
||||
} as const;
|
||||
|
||||
type PresetId = keyof typeof PRESETS;
|
||||
|
||||
/**
|
||||
* Sentinel for the "Custom" Since option — selects a user-supplied window
|
||||
* in hours instead of a fixed preset. The hours value lives in its own
|
||||
* input rendered conditionally under the Select.
|
||||
*/
|
||||
const CUSTOM_SINCE = 'custom' as const;
|
||||
type SinceId = PresetId | typeof CUSTOM_SINCE;
|
||||
|
||||
const PRESET_ORDER: PresetId[] = ['lastHour', 'last3h', 'last24h', 'lastWeek', 'lastMonth'];
|
||||
const SINCE_ORDER: SinceId[] = [...PRESET_ORDER, CUSTOM_SINCE];
|
||||
const DEFAULT_SINCE: SinceId = 'lastHour';
|
||||
// Bitcoin block timestamps aren't strictly monotonic — the consensus rule is
|
||||
// only that a block's timestamp must exceed the median of the previous 11
|
||||
// (the "median-time-past" rule, BIP-113). So a block at height H can carry a
|
||||
// timestamp earlier than its predecessor's, but no inversion can drag a block
|
||||
// more than 11 positions out of timestamp order. Rewinding by 11 blocks from
|
||||
// mempool.space's "highest block with ts <= cutoff" guarantees we don't skip
|
||||
// past a payment whose containing block has an out-of-order timestamp near
|
||||
// the boundary. The cost is ~11 extra block fetches on the SP scanner.
|
||||
const TIME_RESOLUTION_SAFETY_BLOCKS = 11;
|
||||
const MEMPOOL_TIMESTAMP_BLOCK_URL = 'https://mempool.space/api/v1/mining/blocks/timestamp';
|
||||
|
||||
interface MempoolTimestampBlockResponse {
|
||||
height?: unknown;
|
||||
}
|
||||
|
||||
async function fetchMempoolTimestampBlockHeight(cutoffTime: number): Promise<number> {
|
||||
const response = await fetch(`${MEMPOOL_TIMESTAMP_BLOCK_URL}/${cutoffTime}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`mempool.space timestamp lookup returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MempoolTimestampBlockResponse;
|
||||
if (typeof data.height !== 'number' || !Number.isInteger(data.height) || data.height < 0) {
|
||||
throw new Error('mempool.space timestamp lookup missing valid block height');
|
||||
}
|
||||
|
||||
return data.height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a wall-clock time window (in seconds) to the scan start block.
|
||||
* mempool.space's timestamp-to-block endpoint is the only source of truth;
|
||||
* if it's unreachable the caller surfaces a toast pointing the user at
|
||||
* Advanced → From block. The 11-block rewind (see
|
||||
* TIME_RESOLUTION_SAFETY_BLOCKS) covers BIP-113 timestamp inversions.
|
||||
*
|
||||
* The start block is the literal window boundary — we deliberately don't
|
||||
* clamp it forward to the wallet's last scanned height. Re-scanning blocks
|
||||
* we've already scanned is cheap (the indexer is just iterating tweak data)
|
||||
* and is exactly what the user asked for. The previous "forward only" clamp
|
||||
* silently degraded "Last week" into a no-op when the wallet had been
|
||||
* scanned more recently, which made the dialog useless for the case it was
|
||||
* designed to fix.
|
||||
*/
|
||||
async function resolveWindowFromHeight(
|
||||
windowSeconds: number,
|
||||
tipHeight: number,
|
||||
): Promise<number> {
|
||||
const cutoffTime = Math.floor(Date.now() / 1000) - windowSeconds;
|
||||
let boundary = await fetchMempoolTimestampBlockHeight(cutoffTime);
|
||||
boundary = Math.min(boundary, tipHeight);
|
||||
return Math.max(0, boundary - TIME_RESOLUTION_SAFETY_BLOCKS);
|
||||
}
|
||||
|
||||
export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymentScanDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const sp = useHdWalletSp();
|
||||
const [since, setSince] = useState<SinceId>(DEFAULT_SINCE);
|
||||
const [customHours, setCustomHours] = useState('');
|
||||
const [fromOverride, setFromOverride] = useState('');
|
||||
const [includeSpent, setIncludeSpent] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [isResolvingSince, setIsResolvingSince] = useState(false);
|
||||
|
||||
// Reset all local state when the dialog closes so reopening always
|
||||
// starts from a clean, conservative default.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSince(DEFAULT_SINCE);
|
||||
setCustomHours('');
|
||||
setFromOverride('');
|
||||
setIncludeSpent(false);
|
||||
setAdvancedOpen(false);
|
||||
setIsResolvingSince(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Parse the override input. Empty string means "no override".
|
||||
const overrideTrimmed = fromOverride.trim();
|
||||
const overrideParsed = overrideTrimmed === '' ? undefined : Number(overrideTrimmed);
|
||||
const overrideValid =
|
||||
overrideTrimmed === '' ||
|
||||
(Number.isInteger(overrideParsed) && (overrideParsed as number) >= 0);
|
||||
|
||||
// Effective starting height for the manual override path. The Since path is
|
||||
// resolved asynchronously on submit from real block timestamps.
|
||||
const effectiveFrom = overrideTrimmed !== '' ? overrideParsed : undefined;
|
||||
|
||||
// Parse the Custom hours input. Allow fractional hours (e.g. 0.5 for 30
|
||||
// minutes) but reject zero / negative / non-finite values. Empty string
|
||||
// means "not yet entered" — the Start button stays disabled until the
|
||||
// user actually types a number.
|
||||
const customTrimmed = customHours.trim();
|
||||
const customParsed = customTrimmed === '' ? undefined : Number(customTrimmed);
|
||||
const customValid =
|
||||
customTrimmed === '' ||
|
||||
(typeof customParsed === 'number' &&
|
||||
Number.isFinite(customParsed) &&
|
||||
(customParsed as number) > 0);
|
||||
const customSeconds =
|
||||
typeof customParsed === 'number' && customValid && customParsed > 0
|
||||
? Math.round(customParsed * 60 * 60)
|
||||
: undefined;
|
||||
|
||||
const tipHeight = sp.tipHeight;
|
||||
// Only the manual From-block override has a real "nothing to scan" state —
|
||||
// typing a height past the tip would produce an empty range. The Since
|
||||
// presets don't have an equivalent: re-scanning blocks we've already
|
||||
// scanned is cheap and is what the user asked for, so we never block the
|
||||
// Start button just because scanHeight has caught up to the tip.
|
||||
const isManualUpToDate =
|
||||
tipHeight !== undefined && effectiveFrom !== undefined && effectiveFrom > tipHeight;
|
||||
|
||||
// Disable Start when:
|
||||
// - the override field has garbage in it (input is invalid)
|
||||
// - we still don't know the tip and the user hasn't overridden
|
||||
// - the manual override is past the tip (nothing to scan)
|
||||
// - the user picked Custom but hasn't entered a valid hour value yet
|
||||
const sinceReady = since === CUSTOM_SINCE ? customSeconds !== undefined : true;
|
||||
const canStart =
|
||||
overrideValid &&
|
||||
customValid &&
|
||||
(overrideTrimmed !== '' ? effectiveFrom !== undefined : tipHeight !== undefined) &&
|
||||
sinceReady &&
|
||||
!isManualUpToDate &&
|
||||
!sp.isScanning &&
|
||||
!isResolvingSince;
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!canStart) return;
|
||||
|
||||
if (overrideTrimmed !== '') {
|
||||
if (effectiveFrom === undefined) return;
|
||||
await sp.scanRange({
|
||||
fromHeight: effectiveFrom,
|
||||
includeSpent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (tipHeight === undefined) return;
|
||||
|
||||
// Resolve the selected "Since" option to a window in seconds. Custom
|
||||
// pulls from the hours input; everything else is a fixed preset.
|
||||
const windowSeconds =
|
||||
since === CUSTOM_SINCE ? customSeconds : PRESETS[since].seconds;
|
||||
if (windowSeconds === undefined) return;
|
||||
|
||||
setIsResolvingSince(true);
|
||||
try {
|
||||
const fromHeight = await resolveWindowFromHeight(
|
||||
windowSeconds,
|
||||
tipHeight,
|
||||
);
|
||||
await sp.scanRange({
|
||||
fromHeight,
|
||||
includeSpent,
|
||||
});
|
||||
} catch {
|
||||
// mempool.space is the only path now — when it's down, point the
|
||||
// user at the Advanced → From block escape hatch and auto-open it.
|
||||
toast({
|
||||
title: t('spScan.resolveFailed.title'),
|
||||
description: t('spScan.resolveFailed.description'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
setAdvancedOpen(true);
|
||||
} finally {
|
||||
setIsResolvingSince(false);
|
||||
}
|
||||
};
|
||||
|
||||
const progressPercent = sp.scanProgress
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
((sp.scanProgress.currentHeight - sp.scanProgress.fromHeight + 1) /
|
||||
Math.max(1, sp.scanProgress.toHeight - sp.scanProgress.fromHeight + 1)) *
|
||||
100,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('spScan.title')}</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-1.5">
|
||||
<span>{t('spScan.subtitle')}</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-full cursor-pointer"
|
||||
aria-label={t('spScan.descriptionHelp')}
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" className="text-xs w-72">
|
||||
{t('spScan.description')}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Automatic background scanning toggle. When on, the provider
|
||||
quietly resumes scanning from the last block and keeps up with
|
||||
the tip without the user opening this dialog. The manual
|
||||
controls below remain available for targeted/deep rescans. */}
|
||||
<div className="flex items-start justify-between gap-3 rounded-lg border bg-muted/30 px-3 py-2.5">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sp-auto-scan" className="text-sm cursor-pointer">
|
||||
{t('spScan.autoScan.label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('spScan.autoScan.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="sp-auto-scan"
|
||||
checked={sp.autoScanEnabled}
|
||||
onCheckedChange={(v) => sp.setAutoScanEnabled(v)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Primary control: relative time window. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-scan-since" className="text-xs">
|
||||
{t('spScan.since')}
|
||||
</Label>
|
||||
<Select
|
||||
value={since}
|
||||
onValueChange={(v) => setSince(v as SinceId)}
|
||||
disabled={sp.isScanning}
|
||||
>
|
||||
<SelectTrigger id="sp-scan-since">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SINCE_ORDER.map((id) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{t(`spScan.preset.${id}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Custom hours input — only renders when Custom is selected.
|
||||
Accepts fractional values (e.g. 0.5 = 30 minutes) and the
|
||||
Start button stays disabled until a positive number is
|
||||
entered, so there's no ambiguous "empty == zero" submit. */}
|
||||
{since === CUSTOM_SINCE && (
|
||||
<div className="pt-1.5 space-y-1.5">
|
||||
<Label htmlFor="sp-scan-custom-hours" className="text-xs">
|
||||
{t('spScan.customHours')}
|
||||
</Label>
|
||||
<Input
|
||||
id="sp-scan-custom-hours"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="any"
|
||||
placeholder={t('spScan.customHoursPlaceholder')}
|
||||
value={customHours}
|
||||
onChange={(e) => setCustomHours(e.target.value)}
|
||||
disabled={sp.isScanning}
|
||||
aria-invalid={!customValid}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced disclosure — collapsed by default, resets on close. */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm cursor-pointer"
|
||||
>
|
||||
{advancedOpen ? (
|
||||
<ChevronUp className="size-3" />
|
||||
) : (
|
||||
<ChevronDown className="size-3" />
|
||||
)}
|
||||
{t('spScan.advanced')}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="space-y-4 pt-3">
|
||||
{/* From-block override. Empty by default. When non-empty, this
|
||||
value wins over the Since selection at submit time. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-scan-from" className="text-xs">
|
||||
{t('spScan.fromBlock')}
|
||||
</Label>
|
||||
<Input
|
||||
id="sp-scan-from"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={fromOverride}
|
||||
onChange={(e) => setFromOverride(e.target.value)}
|
||||
disabled={sp.isScanning}
|
||||
aria-invalid={!overrideValid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Indexer tip + last-scanned helper line. Kept as before
|
||||
for power users diagnosing scan progress. */}
|
||||
{sp.tipHeight !== undefined && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('spScan.indexerTip')}: <span className="font-mono">{sp.tipHeight.toLocaleString()}</span>
|
||||
{sp.storage && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('spScan.lastFullyScanned')}:{' '}
|
||||
<span className="font-mono">
|
||||
{sp.storage.scanHeight > 0 ? sp.storage.scanHeight.toLocaleString() : t('spScan.never')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* "Include already-spent" deep-rescan toggle. Off by
|
||||
default; only useful when rebuilding receive history
|
||||
after a missed scan or storage reset. */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="sp-include-spent"
|
||||
checked={includeSpent}
|
||||
onCheckedChange={(v) => setIncludeSpent(v === true)}
|
||||
disabled={sp.isScanning}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sp-include-spent" className="text-xs cursor-pointer">
|
||||
{t('spScan.includeSpent')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('spScan.includeSpentDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Disabled-state hints below the primary control. Only the most
|
||||
relevant one renders so the dialog stays quiet. */}
|
||||
{!sp.isScanning && tipHeight === undefined && overrideTrimmed === '' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('spScan.connectingIndexer')}
|
||||
</p>
|
||||
)}
|
||||
{!sp.isScanning && isManualUpToDate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('spScan.upToDate')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sp.isScanning && sp.scanProgress && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progressPercent} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{t('spScan.blockProgress', {
|
||||
current: sp.scanProgress.currentHeight.toLocaleString(),
|
||||
to: sp.scanProgress.toHeight.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t('spScan.matches', { count: sp.scanProgress.matchesFound })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sp.isScanning && sp.scanError && (
|
||||
<div className="flex items-start gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-4 shrink-0 mt-0.5" />
|
||||
<p>{sp.scanError.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sp.isScanning && !sp.scanError && sp.scanProgress && (
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<CheckCircle2 className="size-4 shrink-0 mt-0.5 text-green-500" />
|
||||
<p>
|
||||
{t('spScan.scannedRange', {
|
||||
from: sp.scanProgress.fromHeight.toLocaleString(),
|
||||
to: sp.scanProgress.currentHeight.toLocaleString(),
|
||||
})}{' '}
|
||||
{sp.scanProgress.matchesFound > 0
|
||||
? t('spScan.foundOutputs', { count: sp.scanProgress.matchesFound })
|
||||
: t('spScan.noNewPayments')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Reconcile spent UTXOs ──────────────────────────── */}
|
||||
{/*
|
||||
* Manual fix-up path for SP UTXOs that were spent outside the
|
||||
* local send flow — different device, or a build that predates
|
||||
* the send-time prune logic. Walks the stored set, asks
|
||||
* Blockbook for each output's spent status, and drops the spent
|
||||
* ones. Capped at 50 UTXOs per click; subsequent clicks pick up
|
||||
* any remainder.
|
||||
*/}
|
||||
{sp.storage && sp.storage.utxos.length > 0 && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('spScan.reconcile.title')}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('spScan.reconcile.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sp.reconcileProgress && !sp.reconcileError && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sp.isReconciling
|
||||
? t('spScan.reconcile.checking', {
|
||||
checked: sp.reconcileProgress.checked,
|
||||
total: sp.reconcileProgress.total,
|
||||
})
|
||||
: t('spScan.reconcile.checked', {
|
||||
count: sp.reconcileProgress.checked,
|
||||
pruned: sp.reconcileProgress.prunedSoFar,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sp.reconcileError && (
|
||||
<div className="flex items-start gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-4 shrink-0 mt-0.5" />
|
||||
<p>{sp.reconcileError.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void sp.reconcileSpentUtxos();
|
||||
}}
|
||||
disabled={sp.isReconciling || sp.isScanning}
|
||||
>
|
||||
{sp.isReconciling ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin mr-2" />
|
||||
{t('spScan.reconcile.reconciling')}
|
||||
</>
|
||||
) : (
|
||||
t('spScan.reconcile.reconcileNow')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
{sp.isScanning ? (
|
||||
<Button variant="outline" className="w-full" onClick={() => sp.cancelScan()}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={handleScan} disabled={!canStart}>
|
||||
{isResolvingSince && <Loader2 className="size-4 animate-spin mr-2" />}
|
||||
{t('spScan.startScan')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ function HeroLightningMapImpl({ className }: { className?: string }) {
|
||||
className="absolute -inset-[10%]"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(60% 55% at 62% 45%, hsl(24 100% 55% / 0.12) 0%, hsl(24 95% 50% / 0.07) 28%, hsl(220 30% 8% / 0) 65%)',
|
||||
'radial-gradient(60% 55% at 62% 45%, hsl(40 100% 55% / 0.12) 0%, hsl(40 95% 50% / 0.07) 28%, hsl(220 30% 8% / 0) 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -115,15 +115,15 @@ function HeroLightningMapImpl({ className }: { className?: string }) {
|
||||
(which is what painted the visible "latitude line" along the
|
||||
equator and other country seams). */}
|
||||
<linearGradient id={arcId('land')} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(24 80% 50%)" />
|
||||
<stop offset="100%" stopColor="hsl(24 70% 45%)" />
|
||||
<stop offset="0%" stopColor="hsl(40 80% 50%)" />
|
||||
<stop offset="100%" stopColor="hsl(40 70% 45%)" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Arc gradient — bright at midpoint, fading at endpoints, so
|
||||
the line reads as energy traveling rather than a solid wire. */}
|
||||
<linearGradient id={arcId('arc')} x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="hsl(24 100% 60%)" stopOpacity="0.0" />
|
||||
<stop offset="35%" stopColor="hsl(24 100% 60%)" stopOpacity="0.85" />
|
||||
<stop offset="0%" stopColor="hsl(40 100% 60%)" stopOpacity="0.0" />
|
||||
<stop offset="35%" stopColor="hsl(40 100% 60%)" stopOpacity="0.85" />
|
||||
<stop offset="65%" stopColor="hsl(30 100% 65%)" stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor="hsl(30 100% 65%)" stopOpacity="0.0" />
|
||||
</linearGradient>
|
||||
@@ -143,8 +143,8 @@ function HeroLightningMapImpl({ className }: { className?: string }) {
|
||||
the arcs at intersections. */}
|
||||
<radialGradient id={arcId('node-halo')}>
|
||||
<stop offset="0%" stopColor="hsl(30 100% 70%)" stopOpacity="0.9" />
|
||||
<stop offset="40%" stopColor="hsl(24 100% 55%)" stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor="hsl(24 100% 50%)" stopOpacity="0" />
|
||||
<stop offset="40%" stopColor="hsl(40 100% 55%)" stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor="hsl(40 100% 50%)" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Quote, Heart, Zap, X, ChevronRight } from 'lucide-react';
|
||||
import { Quote, Heart, X, ChevronRight } from 'lucide-react';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CustomEmojiImg, EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { isCustomEmoji } from '@/lib/customEmoji';
|
||||
import { useEventInteractions, type RepostEntry, type QuoteEntry, type ReactionEntry, type ZapEntry } from '@/hooks/useEventInteractions';
|
||||
import { useEventInteractions, type RepostEntry, type QuoteEntry, type ReactionEntry } from '@/hooks/useEventInteractions';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
@@ -22,7 +22,7 @@ import { timeAgo } from '@/lib/timeAgo';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type InteractionTab = 'reposts' | 'quotes' | 'reactions' | 'zaps';
|
||||
export type InteractionTab = 'reposts' | 'quotes' | 'reactions';
|
||||
|
||||
interface InteractionsModalProps {
|
||||
eventId: string;
|
||||
@@ -48,13 +48,11 @@ export function InteractionsModal({ eventId, open, onOpenChange, initialTab = 'r
|
||||
const repostCount = data?.reposts.length ?? 0;
|
||||
const quoteCount = data?.quotes.length ?? 0;
|
||||
const reactionCount = data?.reactions.length ?? 0;
|
||||
const zapCount = data?.zaps.length ?? 0;
|
||||
|
||||
const tabConfig: { key: InteractionTab; label: string; count: number; icon: React.ReactNode }[] = [
|
||||
{ key: 'reposts', label: 'Reposts', count: repostCount, icon: <RepostIcon className="size-4" /> },
|
||||
{ key: 'quotes', label: 'Quotes', count: quoteCount, icon: <Quote className="size-4" /> },
|
||||
{ key: 'reactions', label: 'Reactions', count: reactionCount, icon: <Heart className="size-4" /> },
|
||||
{ key: 'zaps', label: 'Zaps', count: zapCount, icon: <Zap className="size-4" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -111,10 +109,8 @@ export function InteractionsModal({ eventId, open, onOpenChange, initialTab = 'r
|
||||
<RepostsTab reposts={data?.reposts ?? []} />
|
||||
) : activeTab === 'quotes' ? (
|
||||
<QuotesTab quotes={data?.quotes ?? []} />
|
||||
) : activeTab === 'reactions' ? (
|
||||
<ReactionsTab reactions={data?.reactions ?? []} />
|
||||
) : (
|
||||
<ZapsTab zaps={data?.zaps ?? []} />
|
||||
<ReactionsTab reactions={data?.reactions ?? []} />
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
@@ -204,32 +200,6 @@ function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ──── Zaps Tab ──── */
|
||||
function ZapsTab({ zaps }: { zaps: ZapEntry[] }) {
|
||||
if (zaps.length === 0) {
|
||||
return <EmptyState message="No zaps yet" />;
|
||||
}
|
||||
|
||||
const totalSats = zaps.reduce((sum, z) => sum + z.amountSats, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Total */}
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-3 bg-secondary/30 border-b border-border">
|
||||
<Zap className="size-4 text-amber-500 fill-amber-500" />
|
||||
<span className="text-sm font-bold text-amber-500">{formatNumber(totalSats)} sats</span>
|
||||
<span className="text-xs text-muted-foreground">from {zaps.length} zap{zaps.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{zaps.map((zap, i) => (
|
||||
<ZapRow key={`${zap.senderPubkey}-${i}`} zap={zap} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──── Shared Row Components ──── */
|
||||
|
||||
function RepostRow({ entry }: { entry: RepostEntry }) {
|
||||
@@ -317,51 +287,6 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) {
|
||||
}
|
||||
|
||||
|
||||
function ZapRow({ zap }: { zap: ZapEntry }) {
|
||||
const author = useAuthor(zap.senderPubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name || genUserName(zap.senderPubkey);
|
||||
const nevent = useMemo(() => nip19.neventEncode({ id: zap.eventId, author: zap.senderPubkey }), [zap.eventId, zap.senderPubkey]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${nevent}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-sm truncate">
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</span>
|
||||
{metadata?.nip05 && (
|
||||
<VerifiedNip05Text nip05={metadata.nip05} pubkey={zap.senderPubkey} className="text-xs text-muted-foreground truncate" />
|
||||
)}
|
||||
</div>
|
||||
{zap.message && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{zap.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zap amount badge */}
|
||||
<div className="flex items-center gap-1 shrink-0 bg-amber-500/10 text-amber-500 rounded-full px-2.5 py-1">
|
||||
<Zap className="size-3.5 fill-amber-500" />
|
||||
<span className="text-xs font-bold tabular-nums">{formatNumber(zap.amountSats)}</span>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function QuoteRow({ quote }: { quote: QuoteEntry }) {
|
||||
const author = useAuthor(quote.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Zap, Copy, Check, ExternalLink } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { getThemedQRColors } from '@/lib/qrColors';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LightningInvoiceCardProps {
|
||||
invoice: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Parse the sats amount from a BOLT11 invoice's human-readable part. */
|
||||
function parseBolt11Amount(bolt11: string): number | null {
|
||||
const match = bolt11.toLowerCase().match(/^ln\w+?(\d+)([munp]?)1/);
|
||||
if (!match) return null;
|
||||
const value = parseInt(match[1], 10);
|
||||
if (isNaN(value)) return null;
|
||||
const multiplier = match[2];
|
||||
switch (multiplier) {
|
||||
case 'm': return value * 100_000; // milli-BTC → sats
|
||||
case 'u': return value * 100; // micro-BTC → sats
|
||||
case 'n': return value / 10; // nano-BTC → sats
|
||||
case 'p': return value / 10_000; // pico-BTC → sats
|
||||
default: return value * 100_000_000; // BTC → sats
|
||||
}
|
||||
}
|
||||
|
||||
/** Format sats with thousands separator. */
|
||||
function formatSats(sats: number): string {
|
||||
if (sats < 1) return '<1';
|
||||
const rounded = Math.round(sats);
|
||||
return rounded.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline card for rendering a BOLT11 lightning invoice found in note content.
|
||||
* Horizontal layout with theme-aware QR that expands on tap.
|
||||
* Amount text scales to fit via container query units.
|
||||
*/
|
||||
export function LightningInvoiceCard({ invoice, className }: LightningInvoiceCardProps) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [qrExpanded, setQrExpanded] = useState(false);
|
||||
|
||||
const amount = parseBolt11Amount(invoice);
|
||||
|
||||
// Generate theme-aware QR code
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const { dark, light } = getThemedQRColors();
|
||||
QRCode.toDataURL(invoice.toUpperCase(), {
|
||||
width: 400,
|
||||
margin: 2,
|
||||
color: { dark, light },
|
||||
errorCorrectionLevel: 'M',
|
||||
}).then((url) => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [invoice]);
|
||||
|
||||
const handleCopy = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoice);
|
||||
setCopied(true);
|
||||
toast({ title: 'Copied', description: 'Lightning invoice copied to clipboard' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to copy', variant: 'destructive' });
|
||||
}
|
||||
}, [invoice, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const t = setTimeout(() => setCopied(false), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [copied]);
|
||||
|
||||
const handleOpenWallet = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await openUrl(`lightning:${invoice}`);
|
||||
}, [invoice]);
|
||||
|
||||
const handlePayWebLN = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const webln = (globalThis as { webln?: { enable?: () => Promise<void>; sendPayment?: (invoice: string) => Promise<unknown> } }).webln;
|
||||
if (!webln?.sendPayment) return;
|
||||
try {
|
||||
setPaying(true);
|
||||
if (webln.enable) await webln.enable();
|
||||
await webln.sendPayment(invoice);
|
||||
toast({ title: 'Payment sent' });
|
||||
} catch {
|
||||
toast({ title: 'Payment failed', variant: 'destructive' });
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}, [invoice, toast]);
|
||||
|
||||
const toggleQr = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setQrExpanded((v) => !v);
|
||||
}, []);
|
||||
|
||||
const hasWebLN = typeof globalThis !== 'undefined' && !!(globalThis as { webln?: unknown }).webln;
|
||||
|
||||
const qrImage = qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Lightning Invoice QR"
|
||||
className="rounded-xl"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-square rounded-xl bg-muted animate-pulse" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'isolate my-2.5 relative rounded-2xl border border-border overflow-hidden @container',
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Subtle accent glow behind QR area */}
|
||||
<div className="absolute -z-10 top-0 left-0 w-44 h-44 bg-primary/[0.06] rounded-full blur-2xl" />
|
||||
|
||||
{/* Expanded QR -- square container that replaces the normal layout */}
|
||||
{qrExpanded ? (
|
||||
<button
|
||||
onClick={toggleQr}
|
||||
className="w-full aspect-square cursor-pointer p-5"
|
||||
>
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Lightning Invoice QR"
|
||||
className="w-full h-full rounded-xl"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full rounded-xl bg-muted animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
{/* QR code -- tappable thumbnail */}
|
||||
<button onClick={toggleQr} className="shrink-0 p-3 cursor-pointer">
|
||||
<div className="size-28 sm:size-40">{qrImage}</div>
|
||||
</button>
|
||||
|
||||
{/* Info column */}
|
||||
<div className="flex flex-col justify-between py-3.5 pr-3.5 min-w-0 flex-1 gap-2">
|
||||
{/* Label + amount */}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground font-medium whitespace-nowrap" style={{ fontSize: 'clamp(0.8rem, 3.5cqw, 1.05rem)' }}>
|
||||
<span className="flex items-center justify-center size-5 sm:size-6 rounded-full bg-primary/15 shrink-0">
|
||||
<Zap className="size-3 sm:size-3.5 text-primary fill-primary" />
|
||||
</span>
|
||||
Lightning Invoice
|
||||
</div>
|
||||
{amount !== null && (
|
||||
<div className="font-bold tracking-tight leading-none mt-1 whitespace-nowrap" style={{ fontSize: 'clamp(1.5rem, 8cqw, 2.5rem)' }}>
|
||||
{formatSats(amount)}
|
||||
<span className="font-normal text-muted-foreground ml-1" style={{ fontSize: 'clamp(0.75rem, 3.5cqw, 1.125rem)' }}>sats</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice string with copy */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 group max-w-full"
|
||||
>
|
||||
<span className="truncate text-xs font-mono text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{invoice}
|
||||
</span>
|
||||
{copied
|
||||
? <Check className="size-3.5 text-primary shrink-0" />
|
||||
: <Copy className="size-3.5 text-muted-foreground group-hover:text-foreground shrink-0 transition-colors" />}
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{hasWebLN && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePayWebLN}
|
||||
disabled={paying}
|
||||
className="gap-1.5 h-9 rounded-xl"
|
||||
>
|
||||
<Zap className="size-3.5" />
|
||||
{paying ? 'Paying...' : 'Pay'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={handleOpenWallet} className="gap-1.5 h-9 rounded-xl">
|
||||
<ExternalLink className="size-3.5" />
|
||||
Open in Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Users, Radio, Zap, Clock, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Users, Radio, Clock, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
|
||||
@@ -10,14 +10,11 @@ import { LiveStreamChat } from '@/components/LiveStreamChat';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { Nip05Badge } from '@/components/Nip05Badge';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { getEffectiveStreamStatus } from '@/lib/streamStatus';
|
||||
@@ -75,7 +72,6 @@ interface LiveStreamPageProps {
|
||||
export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
const { config } = useAppContext();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
const [descExpanded, setDescExpanded] = useState(false);
|
||||
|
||||
const title = getTag(event.tags, 'title') || 'Untitled Stream';
|
||||
@@ -206,7 +202,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
|
||||
{/* Stream compact info — always visible */}
|
||||
<div className="px-4 mt-2 sidebar:mt-4 space-y-2 sidebar:space-y-3 shrink-0">
|
||||
{/* Title row with zap button on the right */}
|
||||
{/* Title row */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<h2 className="text-lg font-bold leading-snug">{title}</h2>
|
||||
@@ -226,8 +222,6 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Zap button — right-aligned */}
|
||||
{user && <ZapButton event={event} />}
|
||||
</div>
|
||||
|
||||
{/* Author / Host — desktop only (on mobile it's inside the expandable details) */}
|
||||
@@ -338,19 +332,6 @@ function StreamAuthorRow({ event, participants }: { event: NostrEvent; participa
|
||||
);
|
||||
}
|
||||
|
||||
function ZapButton({ event }: { event: NostrEvent }) {
|
||||
// ZapDialog handles the self-zap guard internally, so we only need to
|
||||
// render the trigger. On-chain zaps are always available for any author;
|
||||
// Lightning is an opt-in tab inside the dialog.
|
||||
return (
|
||||
<ZapDialog target={event}>
|
||||
<Button variant="outline" size="icon" className="shrink-0 size-9 rounded-full text-amber-500 hover:text-amber-400 hover:bg-amber-500/10">
|
||||
<Zap className="size-4" />
|
||||
</Button>
|
||||
</ZapDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantRow({ pubkey, role }: { pubkey: string; role?: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Play, Pause, Music, ListMusic, Disc3, Zap, Clock, Calendar, Tag } from 'lucide-react';
|
||||
import { ArrowLeft, Play, Pause, Music, ListMusic, Disc3, Clock, Calendar, Tag } from 'lucide-react';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
@@ -27,7 +26,6 @@ import { ReactionButton } from '@/components/ReactionButton';
|
||||
import { RepostMenu } from '@/components/RepostMenu';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
@@ -60,7 +58,6 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const stats = useEventStats(event.id, event);
|
||||
const { muteItems } = useMuteList();
|
||||
@@ -94,8 +91,6 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
}
|
||||
};
|
||||
|
||||
const zapAmount = stats.data?.zapAmount ?? 0;
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header */}
|
||||
@@ -185,25 +180,6 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
</button>
|
||||
)}
|
||||
</RepostMenu>
|
||||
|
||||
{user && user.pubkey !== event.pubkey && (
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className="size-11 rounded-full bg-secondary/50 text-muted-foreground hover:bg-secondary flex items-center justify-center transition-colors"
|
||||
title="Zap"
|
||||
>
|
||||
<Zap className="size-5" />
|
||||
</button>
|
||||
</ZapDialog>
|
||||
)}
|
||||
|
||||
{/* Zap stats */}
|
||||
{zapAmount > 0 && (
|
||||
<button onClick={() => setInteractionsTab('zaps')} className="ml-1 text-right hover:opacity-80">
|
||||
<p className="text-lg font-bold leading-tight">{formatNumber(zapAmount)} sats</p>
|
||||
<p className="text-xs text-muted-foreground">{formatNumber(stats.data?.zapCount ?? 0)} zap{(stats.data?.zapCount ?? 0) !== 1 ? 's' : ''}</p>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dur && (
|
||||
@@ -229,12 +205,6 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
|
||||
{/* Interactions tabs */}
|
||||
<div className="flex border-b border-border mt-4">
|
||||
<button
|
||||
onClick={() => setInteractionsTab('zaps')}
|
||||
className="flex-1 py-3 text-center text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/40 transition-colors"
|
||||
>
|
||||
Top Zappers
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setReplyOpen(true)}
|
||||
className="flex-1 py-3 text-center text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/40 transition-colors"
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface NoBitcoinDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* For donors who don't already hold Bitcoin. Rather than a wall of
|
||||
* instructions, this is a simple "get it here" surface — a single branded
|
||||
* Cash App badge (styled like the App Store / Google Play badges, using the
|
||||
* official Cash App logo) that deep-links to cash.app, where the donor can
|
||||
* buy Bitcoin and send it on. Agora never custodies or converts funds; this
|
||||
* just points at a mainstream on-ramp the donor controls.
|
||||
*/
|
||||
export function NoBitcoinDialog({ open, onOpenChange }: NoBitcoinDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('noBitcoin.title')}</DialogTitle>
|
||||
<DialogDescription>{t('noBitcoin.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openUrl('https://cash.app')}
|
||||
aria-label={t('noBitcoin.getCashApp')}
|
||||
className="group flex w-full items-center gap-4 rounded-2xl bg-black px-5 py-4 text-left text-white shadow-sm transition-transform hover:scale-[1.02] active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00D632] focus-visible:ring-offset-2"
|
||||
>
|
||||
<img
|
||||
src="/cashapp.svg"
|
||||
alt=""
|
||||
aria-hidden
|
||||
draggable={false}
|
||||
className="size-12 shrink-0 rounded-2xl"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-white/70">
|
||||
{t('noBitcoin.getItOn')}
|
||||
</span>
|
||||
<span className="text-xl font-semibold leading-tight">Cash App</span>
|
||||
</span>
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+7
-144
@@ -19,7 +19,6 @@ import {
|
||||
SmilePlus,
|
||||
PartyPopper,
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { type ReactNode, lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -80,21 +79,14 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { VideoPlayer } from "@/components/VideoPlayer";
|
||||
import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer";
|
||||
import { ZapDialog } from "@/components/ZapDialog";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useAuthor } from "@/hooks/useAuthor";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useOpenPost } from "@/hooks/useOpenPost";
|
||||
import { useProfileUrl } from "@/hooks/useProfileUrl";
|
||||
import { useShareOrigin } from "@/hooks/useShareOrigin";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useEventStats } from "@/hooks/useTrending";
|
||||
import { useEventTranslation } from "@/hooks/useEventTranslation";
|
||||
import { canZap } from "@/lib/canZap";
|
||||
import { extractZapSender, extractZapMessage } from "@/hooks/useEventInteractions";
|
||||
import { getZapAmountSats } from "@/lib/zapHelpers";
|
||||
import { satsToUSD } from "@/lib/bitcoin";
|
||||
import { useBtcPrice } from "@/hooks/useBtcPrice";
|
||||
import { getContentWarning } from "@/lib/contentWarning";
|
||||
import { genUserName } from "@/lib/genUserName";
|
||||
import { getDisplayName } from "@/lib/getDisplayName";
|
||||
@@ -109,7 +101,6 @@ import { publishedAtAction } from "@/lib/publishedAtAction";
|
||||
import { getEffectiveStreamStatus } from "@/lib/streamStatus";
|
||||
import { getEventRelaySource } from "@/lib/relayDebug";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { hasGoalZapSplits } from "@/lib/goalUtils";
|
||||
|
||||
|
||||
/** Profile card for use in feeds (kind 0). */
|
||||
@@ -123,14 +114,14 @@ function ProfileCardContent({ event }: { event: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ──── Shared activity card shell for reaction / repost / zap / poll vote ──── */
|
||||
/* ──── Shared activity card shell for reaction / repost / poll vote ──── */
|
||||
|
||||
interface ActivityCardProps {
|
||||
/** The round element in the left column (icon bubble or avatar). */
|
||||
icon: ReactNode;
|
||||
/** The actor row content (avatar + name + label + timestamp). */
|
||||
actorRow: ReactNode;
|
||||
/** Optional extra content below the actor row (zap message, vote label, etc.). */
|
||||
/** Optional extra content below the actor row (vote label, etc.). */
|
||||
children?: ReactNode;
|
||||
/** Threaded mode: connector line below icon, no bottom border. */
|
||||
threaded?: boolean;
|
||||
@@ -192,7 +183,7 @@ interface ActorRowProps {
|
||||
authorEvent?: NostrEvent;
|
||||
isLoading?: boolean;
|
||||
label: string;
|
||||
/** Extra inline elements after the label (e.g. zap amount). */
|
||||
/** Extra inline elements after the label. */
|
||||
extra?: ReactNode;
|
||||
/** Formatted timestamp string (e.g. timeAgo or full date). */
|
||||
timestampLabel: string;
|
||||
@@ -246,7 +237,7 @@ interface NoteCardProps {
|
||||
highlight?: boolean;
|
||||
/** If true, suppress the kind-derived action header (e.g. "created a badge"). Used when the parent already provides context. */
|
||||
hideKindHeader?: boolean;
|
||||
/** Override the NIP-22 context row prefix. Used by synthetic zap cards. */
|
||||
/** Override the NIP-22 context row prefix. Used by synthetic activity cards. */
|
||||
commentContextPrefix?: string;
|
||||
/**
|
||||
* Suppress the NIP-22 "Commenting on …" context row. Used by pages
|
||||
@@ -327,58 +318,6 @@ function isDeprecatedFollowSet(event: NostrEvent): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isStringTag(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function getZapRequestTags(event: NostrEvent): string[][] {
|
||||
if (event.kind !== 9735) return [];
|
||||
const description = event.tags.find(([name]) => name === "description")?.[1];
|
||||
if (!description) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(description) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || !("tags" in parsed)) return [];
|
||||
const tags = (parsed as { tags?: unknown }).tags;
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return tags.filter(isStringTag);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function findZapTargetTag(event: NostrEvent, requestTags: string[][], name: string): string[] | undefined {
|
||||
return event.tags.find(([tagName]) => tagName === name) ?? requestTags.find(([tagName]) => tagName === name);
|
||||
}
|
||||
|
||||
function buildZapCommentEvent(event: NostrEvent, requestTags: string[][], senderPubkey: string, content: string): NostrEvent {
|
||||
const tags: string[][] = [];
|
||||
const aTag = findZapTargetTag(event, requestTags, "a");
|
||||
const eTag = findZapTargetTag(event, requestTags, "e");
|
||||
const recipientTag = findZapTargetTag(event, requestTags, "p");
|
||||
const kindTag = findZapTargetTag(event, requestTags, "K") ?? findZapTargetTag(event, requestTags, "k");
|
||||
|
||||
if (aTag?.[1]) {
|
||||
tags.push(["A", aTag[1], aTag[2] ?? ""]);
|
||||
const targetKind = kindTag?.[1] ?? aTag[1].split(":")[0];
|
||||
if (targetKind) tags.push(["K", targetKind]);
|
||||
} else if (eTag?.[1]) {
|
||||
tags.push(["E", eTag[1], eTag[2] ?? "", eTag[3] ?? recipientTag?.[1] ?? ""]);
|
||||
if (kindTag?.[1]) tags.push(["K", kindTag[1]]);
|
||||
if (recipientTag?.[1]) tags.push(["P", recipientTag[1]]);
|
||||
} else if (recipientTag?.[1]) {
|
||||
tags.push(["A", `0:${recipientTag[1]}:`], ["K", "0"]);
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
pubkey: senderPubkey,
|
||||
kind: 1111,
|
||||
content,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
export const NoteCard = memo(function NoteCard({
|
||||
event,
|
||||
className,
|
||||
@@ -397,31 +336,16 @@ export const NoteCard = memo(function NoteCard({
|
||||
const { t } = useTranslation();
|
||||
const actionTarget = actionEvent ?? event;
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const shareOrigin = useShareOrigin();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const actionAuthor = useAuthor(actionEvent?.pubkey);
|
||||
// Kind 9735 (Lightning zap) sender lives in the receipt's `P` tag / embedded
|
||||
// zap-request `pubkey`; kind 8333 (on-chain Bitcoin zap) is signed by the
|
||||
// donor directly so the event's own pubkey IS the sender.
|
||||
const zapSenderPubkey = useMemo(() => {
|
||||
if (event.kind === 9735) return extractZapSender(event);
|
||||
if (event.kind === 8333) return event.pubkey;
|
||||
return '';
|
||||
}, [event]);
|
||||
const zapRequestTags = useMemo(() => getZapRequestTags(event), [event]);
|
||||
|
||||
const pollVoteLabel = usePollVoteLabel(event);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const actionMetadata = actionEvent ? actionAuthor.data?.metadata : metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const encodedId = useMemo(() => encodeEventId(actionTarget), [actionTarget]);
|
||||
const { data: stats } = useEventStats(actionTarget.id, actionTarget);
|
||||
// Cached BTC→USD spot price. Always queried (cheap, shared cache key) so the
|
||||
// zap-card layout below can render amounts as USD when available.
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
|
||||
@@ -437,10 +361,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
});
|
||||
}, [event.id, event.kind, actionEvent]);
|
||||
|
||||
// Check if the current user can zap this event's author
|
||||
// TODO: Enable zapping split-recipient NIP-75 goals once zap split payments are supported.
|
||||
const canZapAuthor = user && canZap(actionMetadata) && !hasGoalZapSplits(actionTarget);
|
||||
|
||||
const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost(
|
||||
`/${encodedId}`,
|
||||
);
|
||||
@@ -454,7 +374,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
target.closest("[data-radix-dialog-content]") ||
|
||||
target.closest("[data-vaul-drawer]") ||
|
||||
target.closest("[data-vaul-drawer-overlay]") ||
|
||||
target.closest('[data-testid="zap-modal"]') ||
|
||||
target.closest("button") ||
|
||||
target.closest("a")
|
||||
) {
|
||||
@@ -471,7 +390,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
target.closest("[data-radix-dialog-content]") ||
|
||||
target.closest("[data-vaul-drawer]") ||
|
||||
target.closest("[data-vaul-drawer-overlay]") ||
|
||||
target.closest('[data-testid="zap-modal"]') ||
|
||||
target.closest("button") ||
|
||||
target.closest("a")
|
||||
) {
|
||||
@@ -498,7 +416,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isBadgeAward = event.kind === 8;
|
||||
const isBadge = isBadgeDefinition || isProfileBadges || isBadgeAward;
|
||||
const isCommunity = event.kind === 34550;
|
||||
const isZapGoal = event.kind === 9041;
|
||||
const isGoal = event.kind === 9041;
|
||||
const isAction = event.kind === 36639;
|
||||
const isCampaign = event.kind === 33863;
|
||||
const isReaction = event.kind === 7;
|
||||
@@ -526,7 +444,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isEncryptedDM = event.kind === 4;
|
||||
const isLetter = event.kind === 8211;
|
||||
const isVanish = event.kind === 62;
|
||||
const isZap = event.kind === 9735 || event.kind === 8333;
|
||||
const isProfile = event.kind === 0;
|
||||
const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite;
|
||||
const isTextNote =
|
||||
@@ -545,7 +462,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isEmojiPack &&
|
||||
!isBadge &&
|
||||
!isCommunity &&
|
||||
!isZapGoal &&
|
||||
!isGoal &&
|
||||
!isAction &&
|
||||
!isCampaign &&
|
||||
!isReaction &&
|
||||
@@ -562,7 +479,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isEncryptedDM &&
|
||||
!isLetter &&
|
||||
!isVanish &&
|
||||
!isZap &&
|
||||
!isProfile;
|
||||
|
||||
const isComment = event.kind === 1111;
|
||||
@@ -712,7 +628,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
<BadgeAwardCard event={event} />
|
||||
) : isCommunity ? (
|
||||
<GroupInlinePreview event={contentEvent} />
|
||||
) : isZapGoal ? (
|
||||
) : isGoal ? (
|
||||
<GoalCard event={event} />
|
||||
|
||||
) : isAction ? (
|
||||
@@ -893,22 +809,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
variant="chip"
|
||||
/>
|
||||
|
||||
{canZapAuthor && (
|
||||
<ZapDialog target={actionTarget}>
|
||||
<button
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
|
||||
title={t('feed.actions.zap')}
|
||||
>
|
||||
<Zap className="size-[18px]" />
|
||||
{stats?.zapAmount ? (
|
||||
<span className="tabular-nums">
|
||||
{formatNumber(stats.zapAmount)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</ZapDialog>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
@@ -1050,39 +950,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Zap receipt layout (kind 9735 Lightning, kind 8333 on-chain Bitcoin) ──
|
||||
// Render as a synthetic NIP-22 card so spacing, header, body, and actions
|
||||
// stay identical to comments while keeping actions tied to the zap receipt.
|
||||
if (isZap) {
|
||||
const zapAmountSats = getZapAmountSats(event);
|
||||
const zapMessage = (event.kind === 8333 ? event.content : extractZapMessage(event)).trim();
|
||||
const usdLabel = btcPrice ? satsToUSD(zapAmountSats, btcPrice) : undefined;
|
||||
const satsLabel = t('noteCard.zap.sat', { count: zapAmountSats, formattedCount: formatNumber(zapAmountSats) });
|
||||
const amountText = usdLabel ?? satsLabel;
|
||||
const donationPrefix = zapAmountSats > 0 ? t('noteCard.zap.donatedAmountTo', { amount: amountText }) : t('noteCard.zap.donatedTo');
|
||||
const zapCommentEvent = buildZapCommentEvent(
|
||||
event,
|
||||
zapRequestTags,
|
||||
zapSenderPubkey || event.pubkey,
|
||||
zapMessage,
|
||||
);
|
||||
|
||||
return (
|
||||
<NoteCard
|
||||
event={zapCommentEvent}
|
||||
actionEvent={event}
|
||||
className={className}
|
||||
compact={compact}
|
||||
threaded={threaded}
|
||||
threadedLineClassName={threadedLineClassName}
|
||||
threadedLast={threadedLast}
|
||||
highlight={highlight}
|
||||
hideKindHeader
|
||||
commentContextPrefix={donationPrefix}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Poll vote layout (kind 1018) ──
|
||||
if (isPollVote) {
|
||||
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
|
||||
@@ -1953,10 +1820,6 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
noun: "noteCard.kindHeader.nsiteNoun",
|
||||
nounRoute: "/development",
|
||||
},
|
||||
9735: {
|
||||
icon: Zap,
|
||||
action: "noteCard.kindHeader.zapped",
|
||||
},
|
||||
36639: {
|
||||
icon: Megaphone,
|
||||
action: (event) => publishedAtKey(event, { created: "noteCard.kindHeader.pledgeCreated", updated: "noteCard.kindHeader.pledgeUpdated", fallback: "noteCard.kindHeader.pledgeCreated" }),
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
|
||||
import { ProxiedImage } from '@/components/ProxiedImage';
|
||||
import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
import { AudioVisualizer } from '@/components/AudioVisualizer';
|
||||
@@ -202,8 +201,7 @@ type ContentToken =
|
||||
| { type: 'naddr-embed'; addr: AddrCoords; url?: string }
|
||||
| { type: 'nostr-link'; id: string; raw: string }
|
||||
| { type: 'hashtag'; tag: string; raw: string }
|
||||
| { type: 'relay-link'; url: string }
|
||||
| { type: 'lightning-invoice'; invoice: string };
|
||||
| { type: 'relay-link'; url: string };
|
||||
|
||||
/**
|
||||
* Regex segment matching a single visual emoji unit, including:
|
||||
@@ -264,12 +262,10 @@ export function NoteContent({
|
||||
}: NoteContentProps) {
|
||||
const tokens = useMemo(() => {
|
||||
const text = event.content;
|
||||
// Match: BOLT11 invoices | URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
|
||||
// BOLT11: optional "lightning:" prefix + lnbc/lntb/lnbcrt/lntbs + bech32 data (case-insensitive)
|
||||
// Match: URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
|
||||
// NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed)
|
||||
const regex = new RegExp(
|
||||
'(?:lightning:)?(ln(?:bc|tb|bcrt|tbs)\\d*[munp]?1[023456789acdefghjklmnpqrstuvwxyz]+)'
|
||||
+ '|((?:https?|wss?):\\/\\/[^\\s]+)'
|
||||
'((?:https?|wss?):\\/\\/[^\\s]+)'
|
||||
+ '|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)'
|
||||
+ '|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)'
|
||||
+ `|(${HASHTAG_PATTERN})`,
|
||||
@@ -283,10 +279,9 @@ export function NoteContent({
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
let [fullMatch] = match;
|
||||
const bolt11 = match[1];
|
||||
let url = match[2];
|
||||
const hashtag = match[7];
|
||||
const { 3: nostrPrefix, 4: nostrData, 5: barePrefix, 6: bareData } = match;
|
||||
let url = match[1];
|
||||
const hashtag = match[6];
|
||||
const { 2: nostrPrefix, 3: nostrData, 4: barePrefix, 5: bareData } = match;
|
||||
const index = match.index;
|
||||
hadMatches = true;
|
||||
|
||||
@@ -295,9 +290,7 @@ export function NoteContent({
|
||||
result.push({ type: 'text', value: text.substring(lastIndex, index) });
|
||||
}
|
||||
|
||||
if (bolt11) {
|
||||
result.push({ type: 'lightning-invoice', invoice: bolt11.toLowerCase() });
|
||||
} else if (url) {
|
||||
if (url) {
|
||||
// Strip common trailing punctuation that's likely not part of the URL
|
||||
// This handles cases like "(https://example.com)" or "Check this: https://example.com."
|
||||
const trailingPunctMatch = url.match(/^(.*?)([.,;:!?)\]]+)$/);
|
||||
@@ -496,7 +489,7 @@ export function NoteContent({
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const token = result[i];
|
||||
const isBlock = token.type === 'image-embed' || token.type === 'media-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|
||||
|| (token.type === 'naddr-embed' && !token.url) || token.type === 'lightning-invoice';
|
||||
|| (token.type === 'naddr-embed' && !token.url);
|
||||
|
||||
if (isBlock) {
|
||||
// Strip all trailing whitespace from the preceding text token.
|
||||
@@ -807,11 +800,6 @@ export function NoteContent({
|
||||
{token.url}
|
||||
</Link>
|
||||
);
|
||||
case 'lightning-invoice':
|
||||
if (disableEmbeds) {
|
||||
return <span key={i} className="text-primary break-all">{token.invoice}</span>;
|
||||
}
|
||||
return <LightningInvoiceCard key={i} invoice={token.invoice} />;
|
||||
}
|
||||
})}
|
||||
|
||||
|
||||
@@ -696,7 +696,7 @@ interface SecureStepProps {
|
||||
* large linked icons make the relationship the visual centerpiece.
|
||||
* 3. A no-recovery emphasis block — calm, informational tone (not a red
|
||||
* destructive alert) but typographically dominant so the user can't
|
||||
* breeze past the "there is no way to get this back" point.
|
||||
* sail past the "there is no way to get this back" point.
|
||||
*
|
||||
* This is the only captive-flow surface that explains the coupling and the
|
||||
* permanence to brand-new users, so it has to carry weight without scaring
|
||||
|
||||
@@ -1,527 +0,0 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { AlertTriangle, Loader2, Bitcoin, Copy, Check } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { BitcoinAmountPicker } from '@/components/BitcoinAmountPicker';
|
||||
import { getBitcoinFeeRate, getUniqueBitcoinFeeSpeeds } from '@/lib/bitcoinFeeSpeed';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
||||
import { useOnchainZap, type OnchainFeeSpeed } from '@/hooks/useOnchainZap';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import {
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
fetchUTXOs,
|
||||
fetchBtcPrice,
|
||||
getFeeRates,
|
||||
estimateFee,
|
||||
isLargeAmount,
|
||||
satsToUSD,
|
||||
formatSats,
|
||||
} from '@/lib/bitcoin';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
const USD_PRESETS = [1, 5, 10, 25, 100];
|
||||
|
||||
const FEE_SPEED_LABELS: Record<OnchainFeeSpeed, string> = {
|
||||
fastest: '~10 min',
|
||||
halfHour: '~30 min',
|
||||
hour: '~1 hour',
|
||||
economy: '~1 day',
|
||||
};
|
||||
|
||||
interface OnchainZapContentProps {
|
||||
target: NostrEvent;
|
||||
/** Called with the tx result when a zap successfully broadcasts. */
|
||||
onSuccess?: (result: { txid: string; amountSats: number }) => void;
|
||||
/** Called when the user dismisses without a send (e.g. "Done" in the
|
||||
* unsupported-signer QR fallback). */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitcoin zap flow. Publishes a BTC transaction paying the target author's
|
||||
* derived Taproot address, then publishes a kind 8333 event linking the tx
|
||||
* to the target event.
|
||||
*
|
||||
* UX mirrors the Lightning zap flow: one screen, one button, no review step.
|
||||
* Balance, fee breakdown, and confirmation are all hidden unless needed.
|
||||
*/
|
||||
export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapContentProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { capability } = useBitcoinSigner();
|
||||
const { logins } = useNostrLogin();
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
const loginType = logins[0]?.type;
|
||||
|
||||
const [usdAmount, setUsdAmount] = useState<number | string>(5);
|
||||
const [feeSpeed, setFeeSpeed] = useState<OnchainFeeSpeed>('halfHour');
|
||||
const [error, setError] = useState('');
|
||||
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
|
||||
|
||||
// Tracks whether the user has manually picked a fee speed. Once true, we
|
||||
// stop auto-adjusting the fee in response to amount changes.
|
||||
const feeSpeedUserChanged = useRef(false);
|
||||
|
||||
const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : '';
|
||||
const recipientAddress = useMemo(() => nostrPubkeyToBitcoinAddress(target.pubkey), [target.pubkey]);
|
||||
const truncatedRecipient = recipientAddress
|
||||
? `${recipientAddress.slice(0, 10)}…${recipientAddress.slice(-8)}`
|
||||
: '';
|
||||
|
||||
const { data: btcPrice } = useQuery({
|
||||
queryKey: ['btc-price', esploraApis],
|
||||
queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: utxos } = useQuery({
|
||||
queryKey: ['bitcoin-utxos', esploraApis, senderAddress],
|
||||
queryFn: ({ signal }) => fetchUTXOs(senderAddress, esploraApis, signal),
|
||||
enabled: !!senderAddress && capability !== 'unsupported',
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: feeRates } = useQuery({
|
||||
queryKey: ['bitcoin-fee-rates', esploraApis],
|
||||
queryFn: ({ signal }) => getFeeRates(esploraApis, signal),
|
||||
enabled: capability !== 'unsupported',
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const totalBalance = useMemo(() => utxos?.reduce((s, u) => s + u.value, 0) ?? 0, [utxos]);
|
||||
|
||||
const currentFeeRate = useMemo(() => {
|
||||
if (!feeRates) return 0;
|
||||
return getBitcoinFeeRate(feeRates, feeSpeed);
|
||||
}, [feeRates, feeSpeed]);
|
||||
|
||||
// Convert the USD amount to sats
|
||||
const amountSats = useMemo(() => {
|
||||
if (!btcPrice) return 0;
|
||||
const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
if (!Number.isFinite(usd) || usd <= 0) return 0;
|
||||
const btc = usd / btcPrice;
|
||||
return Math.round(btc * 100_000_000);
|
||||
}, [usdAmount, btcPrice]);
|
||||
|
||||
const estimatedFeeSats = useMemo(() => {
|
||||
if (!utxos?.length || !currentFeeRate || !amountSats) return 0;
|
||||
const fee2 = estimateFee(utxos.length, 2, currentFeeRate);
|
||||
const change = totalBalance - amountSats - fee2;
|
||||
const numOutputs = change > 546 ? 2 : 1;
|
||||
return estimateFee(utxos.length, numOutputs, currentFeeRate);
|
||||
}, [utxos, currentFeeRate, amountSats, totalBalance]);
|
||||
|
||||
const totalSats = amountSats + estimatedFeeSats;
|
||||
const insufficient = totalBalance > 0 && totalSats > totalBalance;
|
||||
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
|
||||
|
||||
// Auto-adjust fee speed when the amount changes, unless the user has
|
||||
// already picked a speed manually. Aim for a fee below 40% of the amount
|
||||
// by stepping down through the unique speed tiers. If every tier still
|
||||
// blows past 40% (tiny amount), fall back to the cheapest tier so we at
|
||||
// least minimize the hit.
|
||||
useEffect(() => {
|
||||
if (feeSpeedUserChanged.current) return;
|
||||
if (!utxos?.length || !feeRates || amountSats <= 0) return;
|
||||
|
||||
const uniqueSpeeds = getUniqueBitcoinFeeSpeeds(feeRates);
|
||||
const threshold = amountSats * 0.4;
|
||||
|
||||
let target: OnchainFeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
|
||||
for (const speed of uniqueSpeeds) {
|
||||
const rate = getBitcoinFeeRate(feeRates, speed);
|
||||
const fee2 = estimateFee(utxos.length, 2, rate);
|
||||
const change = totalBalance - amountSats - fee2;
|
||||
const outputs = change > 546 ? 2 : 1;
|
||||
const fee = estimateFee(utxos.length, outputs, rate);
|
||||
if (fee <= threshold) {
|
||||
target = speed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setFeeSpeed((prev) => (prev === target ? prev : target));
|
||||
}, [amountSats, feeRates, utxos, totalBalance]);
|
||||
|
||||
const handleFeeSpeedChange = useCallback((speed: OnchainFeeSpeed) => {
|
||||
feeSpeedUserChanged.current = true;
|
||||
setFeeSpeed(speed);
|
||||
setFeePopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
// For large amounts, require a two-tap confirmation on the primary button.
|
||||
// This catches fat-finger sends without nagging on normal amounts.
|
||||
const isLarge = isLargeAmount(totalSats, btcPrice);
|
||||
const [confirmArmed, setConfirmArmed] = useState(false);
|
||||
|
||||
// Re-arm (i.e. clear confirmation) whenever the amount, fee rate, or price
|
||||
// moves — so editing after arming forces another deliberate click.
|
||||
useEffect(() => {
|
||||
setConfirmArmed(false);
|
||||
}, [amountSats, currentFeeRate, btcPrice]);
|
||||
|
||||
const { zapAsync, isZapping, progress } = useOnchainZap(target, (result) => {
|
||||
// Forward the txid + amount so the dialog can render its success screen.
|
||||
onSuccess?.({ txid: result.txid, amountSats: result.amountSats });
|
||||
});
|
||||
|
||||
const handleZap = useCallback(async () => {
|
||||
setError('');
|
||||
if (!user) { setError('You must be logged in.'); return; }
|
||||
if (user.pubkey === target.pubkey) { setError("You can't zap yourself."); return; }
|
||||
// `capability === 'unsupported'` is already handled by the UI replacement
|
||||
// above; 'supported' and 'unknown' both proceed (the latter may fail at
|
||||
// sign time, which will then flip the UI to the unsupported state).
|
||||
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
|
||||
if (amountSats <= 0) { setError('Enter an amount.'); return; }
|
||||
if (!utxos?.length) { setError("You don't have any Bitcoin yet. Receive some first."); return; }
|
||||
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
|
||||
|
||||
// Two-tap safety for large amounts: first click arms, second click sends.
|
||||
if (isLarge && !confirmArmed) {
|
||||
setConfirmArmed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await zapAsync({ amountSats, comment: '', feeSpeed });
|
||||
// onSuccess (passed to useOnchainZap) closes the dialog; toast is shown by the hook.
|
||||
} catch (err) {
|
||||
// Capability errors flip the UI via `reportSignerUnsupported` in the
|
||||
// hook's `onError`; no need to surface a form-level error for those.
|
||||
const msg = err instanceof Error ? err.message : 'Zap failed';
|
||||
const isCapability = /does not support|doesn't support|signpsbt|sign_psbt/i.test(msg);
|
||||
if (!isCapability) setError(msg);
|
||||
}
|
||||
}, [user, target.pubkey, btcPrice, amountSats, utxos, insufficient, zapAsync, feeSpeed, isLarge, confirmArmed]);
|
||||
|
||||
// ── Signer not supported ──────────────────────────────────────
|
||||
// The user's signer can't sign PSBTs locally (extension without signPsbt,
|
||||
// or a bunker that rejected sign_psbt). Instead of a dead-end, show a QR
|
||||
// they can scan with any external Bitcoin wallet. We can't observe the
|
||||
// resulting txid, so we don't publish a kind 8333 — the user is warned
|
||||
// that the zap won't be attributed to them on Nostr.
|
||||
|
||||
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
const hasValidAmount = Number.isFinite(currentUsd) && currentUsd > 0;
|
||||
const totalUsdString = btcPrice ? satsToUSD(totalSats, btcPrice) : '';
|
||||
const uniqueFeeSpeeds = useMemo(() => getUniqueBitcoinFeeSpeeds(feeRates), [feeRates]);
|
||||
|
||||
if (user && capability === 'unsupported') {
|
||||
return (
|
||||
<UnsupportedSignerQR
|
||||
recipientAddress={recipientAddress}
|
||||
truncatedRecipient={truncatedRecipient}
|
||||
amountSats={amountSats}
|
||||
btcPrice={btcPrice}
|
||||
usdAmount={usdAmount}
|
||||
setUsdAmount={setUsdAmount}
|
||||
loginType={loginType}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 px-4 py-4 w-full overflow-hidden">
|
||||
<BitcoinAmountPicker
|
||||
usdAmount={usdAmount}
|
||||
onUsdAmountChange={setUsdAmount}
|
||||
presets={USD_PRESETS}
|
||||
insufficient={insufficient}
|
||||
onAmountChangeStart={() => setError('')}
|
||||
/>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleZap}
|
||||
disabled={!btcPrice || amountSats <= 0 || isZapping || insufficient}
|
||||
variant={(insufficient || isLarge) && !isZapping ? 'destructive' : 'default'}
|
||||
className="w-full"
|
||||
>
|
||||
{isZapping ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-1.5 animate-spin" />
|
||||
{progressLabel(progress)}
|
||||
</>
|
||||
) : insufficient ? (
|
||||
<>Not enough Bitcoin</>
|
||||
) : isLarge && confirmArmed ? (
|
||||
<>Tap again to send {totalUsdString}</>
|
||||
) : (
|
||||
<>Send {totalUsdString || (hasValidAmount ? `$${currentUsd}` : '')}</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Fee line — click to open speed picker */}
|
||||
{amountSats > 0 && (
|
||||
<div className="flex items-center justify-center gap-3 -mt-1 text-xs">
|
||||
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
Fee{' '}
|
||||
{estimatedFeeSats > 0 && btcPrice
|
||||
? `≈ ${satsToUSD(estimatedFeeSats, btcPrice)}`
|
||||
: currentFeeRate
|
||||
? `${currentFeeRate} sat/vB`
|
||||
: 'loading'}
|
||||
<span className="opacity-60"> · {FEE_SPEED_LABELS[feeSpeed]}</span>
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" sideOffset={6} className="w-56 p-1">
|
||||
<div className="flex flex-col">
|
||||
{uniqueFeeSpeeds.map((speed) => {
|
||||
const rate = feeRates ? getBitcoinFeeRate(feeRates, speed) : 0;
|
||||
const selected = speed === feeSpeed;
|
||||
return (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
onClick={() => handleFeeSpeedChange(speed)}
|
||||
className={`flex items-center justify-between px-2 py-1.5 rounded-sm text-xs text-left hover:bg-muted transition-colors ${selected ? 'bg-muted font-medium' : ''}`}
|
||||
>
|
||||
<span>{FEE_SPEED_LABELS[speed]}</span>
|
||||
<span className="text-muted-foreground">{rate} sat/vB</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{showBalance && !insufficient && btcPrice && (
|
||||
<span className="text-muted-foreground">
|
||||
Balance: {satsToUSD(totalBalance, btcPrice)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function progressLabel(progress: 'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'): string {
|
||||
switch (progress) {
|
||||
case 'building': return 'Building…';
|
||||
case 'signing': return 'Signing…';
|
||||
case 'broadcasting': return 'Broadcasting…';
|
||||
case 'publishing': return 'Publishing…';
|
||||
default: return 'Processing…';
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Unsupported-signer QR fallback
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface UnsupportedSignerQRProps {
|
||||
recipientAddress: string;
|
||||
truncatedRecipient: string;
|
||||
amountSats: number;
|
||||
btcPrice: number | undefined;
|
||||
usdAmount: number | string;
|
||||
setUsdAmount: (v: number | string) => void;
|
||||
loginType: string | undefined;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback shown when the user's signer can't sign PSBTs locally. Renders a
|
||||
* BIP-21 QR the user can scan with any external Bitcoin wallet. Because we
|
||||
* never see the resulting tx, we skip publishing the kind 8333 zap event and
|
||||
* explicitly warn the user about that.
|
||||
*/
|
||||
function UnsupportedSignerQR({
|
||||
recipientAddress,
|
||||
truncatedRecipient,
|
||||
amountSats,
|
||||
btcPrice,
|
||||
usdAmount,
|
||||
setUsdAmount,
|
||||
loginType,
|
||||
onClose,
|
||||
}: UnsupportedSignerQRProps) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState<'address' | 'uri' | null>(null);
|
||||
|
||||
// BIP-21 URI. Include `amount` (in BTC, 8 decimals) only when > 0 so an
|
||||
// empty-amount placeholder QR doesn't include `?amount=0`.
|
||||
const bip21 = useMemo(() => {
|
||||
if (!recipientAddress) return '';
|
||||
if (amountSats <= 0) return `bitcoin:${recipientAddress}`;
|
||||
const btc = (amountSats / 100_000_000).toFixed(8);
|
||||
return `bitcoin:${recipientAddress}?amount=${btc}`;
|
||||
}, [recipientAddress, amountSats]);
|
||||
|
||||
const explanation =
|
||||
loginType === 'extension'
|
||||
? "Your browser extension can't sign Bitcoin transactions."
|
||||
: loginType === 'bunker'
|
||||
? "Your remote signer can't sign Bitcoin transactions."
|
||||
: "Your signer can't sign Bitcoin transactions.";
|
||||
|
||||
const copy = useCallback(
|
||||
async (value: string, which: 'address' | 'uri', label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(which);
|
||||
toast({ title: 'Copied', description: `${label} copied to clipboard` });
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch {
|
||||
toast({ title: 'Copy failed', description: 'Please copy manually.', variant: 'destructive' });
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
const hasAmount = amountSats > 0;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{explanation} You can still zap by scanning this QR from any Bitcoin wallet.
|
||||
</p>
|
||||
|
||||
{/* Amount presets (USD) */}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
|
||||
onValueChange={(v) => { if (v) setUsdAmount(Number(v)); }}
|
||||
className="grid grid-cols-5 gap-1 w-full"
|
||||
>
|
||||
{USD_PRESETS.map((v) => (
|
||||
<ToggleGroupItem
|
||||
key={v}
|
||||
value={String(v)}
|
||||
className="h-8 min-w-0 text-xs font-semibold px-1"
|
||||
>
|
||||
${v}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-muted" />
|
||||
<span className="text-xs text-muted-foreground">OR</span>
|
||||
<div className="h-px flex-1 bg-muted" />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">$</span>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder="Custom amount (USD)"
|
||||
value={usdAmount}
|
||||
onChange={(e) => setUsdAmount(e.target.value)}
|
||||
className="pl-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* QR / placeholder */}
|
||||
<div className="flex justify-center">
|
||||
{hasAmount && bip21 ? (
|
||||
<div className="bg-white p-3 rounded-xl" aria-label="Bitcoin payment QR code">
|
||||
<QRCodeCanvas value={bip21} size={220} level="M" className="block" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="size-[220px] rounded-xl border border-dashed flex items-center justify-center text-xs text-muted-foreground text-center px-4">
|
||||
{btcPrice
|
||||
? 'Choose an amount above to generate a payment QR.'
|
||||
: 'Loading BTC price…'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount summary */}
|
||||
{hasAmount && btcPrice && (
|
||||
<div className="text-center text-sm">
|
||||
<span className="font-medium">
|
||||
{currentUsd > 0 ? `$${currentUsd}` : ''}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{' · '}{formatSats(amountSats)} sats
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recipient */}
|
||||
{recipientAddress && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Bitcoin className="size-3.5 text-orange-500 shrink-0" />
|
||||
<span className="shrink-0">To:</span>
|
||||
<span className="font-mono truncate" title={recipientAddress}>{truncatedRecipient}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copy buttons */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copy(recipientAddress, 'address', 'Address')}
|
||||
disabled={!recipientAddress}
|
||||
className="text-xs"
|
||||
>
|
||||
{copied === 'address' ? <Check className="size-3.5 mr-1.5" /> : <Copy className="size-3.5 mr-1.5" />}
|
||||
Copy address
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copy(bip21, 'uri', 'Payment link')}
|
||||
disabled={!hasAmount || !bip21}
|
||||
className="text-xs"
|
||||
>
|
||||
{copied === 'uri' ? <Check className="size-3.5 mr-1.5" /> : <Copy className="size-3.5 mr-1.5" />}
|
||||
Copy link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Warning: no kind 8333 will be published */}
|
||||
<Alert>
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Because we can't see your transaction, this zap won't show up as yours on Nostr. The recipient will still get the Bitcoin.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{onClose && (
|
||||
<Button type="button" variant="secondary" onClick={onClose} className="w-full">
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PendingBadgeProps {
|
||||
/**
|
||||
* Optional formatted amount (e.g. "$1.23"). When present the badge reads
|
||||
* "{amount} pending"; when omitted it reads just "pending".
|
||||
*/
|
||||
amountLabel?: string;
|
||||
/** Additional classes appended to the base styling. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small orange inline indicator used wherever a Bitcoin amount is awaiting
|
||||
* mempool confirmation — currently on the wallet headline and on campaign
|
||||
* donation surfaces. Centralised so the visual treatment (orange + spinning
|
||||
* RefreshCw) stays consistent across pages.
|
||||
*/
|
||||
export function PendingBadge({ amountLabel, className }: PendingBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-xs text-orange-500 dark:text-orange-400',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
{amountLabel
|
||||
? t('wallet.amountPending', { amount: amountLabel })
|
||||
: t('wallet.pending')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
interface PledgeCardProps {
|
||||
action: Action;
|
||||
btcPrice: number | undefined;
|
||||
/** Presentation variant for standalone surfaces. Inline note cards use PledgeInlinePreview instead. */
|
||||
variant?: 'grid' | 'shelf' | 'rail';
|
||||
/** Force an ended badge from a parent that already split active/ended sections. */
|
||||
@@ -36,7 +35,6 @@ interface PledgeCardProps {
|
||||
|
||||
export function PledgeCard({
|
||||
action,
|
||||
btcPrice,
|
||||
variant = 'grid',
|
||||
isExpired,
|
||||
showAuthor = false,
|
||||
@@ -124,13 +122,13 @@ export function PledgeCard({
|
||||
{isRail ? (
|
||||
<div className="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground uppercase tracking-wide font-semibold">{t('pledges.card.pledged')}</span>
|
||||
<span className="text-foreground font-bold tabular-nums">{formatPledgeAmount(action.bounty, btcPrice)}</span>
|
||||
<span className="text-foreground font-bold tabular-nums">{formatPledgeAmount(action.bounty)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/10 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">{t('pledges.card.pledged')}</p>
|
||||
<p className="mt-1 text-2xl font-bold tracking-tight text-foreground">
|
||||
{formatPledgeAmount(action.bounty, btcPrice)}
|
||||
{formatPledgeAmount(action.bounty)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Play, Pause, Podcast, Zap, Clock } from 'lucide-react';
|
||||
import { ArrowLeft, Play, Pause, Podcast, Clock } from 'lucide-react';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
@@ -26,7 +25,6 @@ import { ReactionButton } from '@/components/ReactionButton';
|
||||
import { RepostMenu } from '@/components/RepostMenu';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
@@ -56,7 +54,6 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const stats = useEventStats(event.id, event);
|
||||
const { muteItems } = useMuteList();
|
||||
@@ -92,8 +89,6 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
|
||||
}
|
||||
};
|
||||
|
||||
const zapAmount = stats.data?.zapAmount ?? 0;
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header */}
|
||||
@@ -181,25 +176,6 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
|
||||
</button>
|
||||
)}
|
||||
</RepostMenu>
|
||||
|
||||
{user && user.pubkey !== event.pubkey && (
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className="size-11 rounded-full bg-secondary/50 text-muted-foreground hover:bg-secondary flex items-center justify-center transition-colors"
|
||||
title="Zap"
|
||||
>
|
||||
<Zap className="size-5" />
|
||||
</button>
|
||||
</ZapDialog>
|
||||
)}
|
||||
|
||||
{/* Zap stats */}
|
||||
{zapAmount > 0 && (
|
||||
<button onClick={() => setInteractionsTab('zaps')} className="ml-1 text-right hover:opacity-80">
|
||||
<p className="text-lg font-bold leading-tight">{formatNumber(zapAmount)} sats</p>
|
||||
<p className="text-xs text-muted-foreground">{formatNumber(stats.data?.zapCount ?? 0)} zap{(stats.data?.zapCount ?? 0) !== 1 ? 's' : ''}</p>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dur && (
|
||||
@@ -224,12 +200,6 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
|
||||
|
||||
{/* Interactions tabs */}
|
||||
<div className="flex border-b border-border mt-4">
|
||||
<button
|
||||
onClick={() => setInteractionsTab('zaps')}
|
||||
className="flex-1 py-3 text-center text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/40 transition-colors"
|
||||
>
|
||||
Top Zappers
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setReplyOpen(true)}
|
||||
className="flex-1 py-3 text-center text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/40 transition-colors"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MessageCircle, MoreHorizontal, Share2, Zap } from 'lucide-react';
|
||||
import { MessageCircle, MoreHorizontal, Share2 } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -8,15 +8,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { ReactionButton } from '@/components/ReactionButton';
|
||||
import { RepostMenu } from '@/components/RepostMenu';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { hasGoalZapSplits } from '@/lib/goalUtils';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -26,10 +21,6 @@ interface PostActionBarProps {
|
||||
replyLabel?: string;
|
||||
onReply: () => void;
|
||||
onMore: () => void;
|
||||
/** Hide the zap button entirely. Useful for events with their own donation
|
||||
* flow (e.g. fundraising campaigns) where a generic Lightning zap is the
|
||||
* wrong primary CTA. Defaults to false. */
|
||||
hideZap?: boolean;
|
||||
/** Keep the share button visible at sidebar widths. Defaults to false. */
|
||||
showShareInSidebar?: boolean;
|
||||
/** Optional action rendered next to Share, e.g. Translate. */
|
||||
@@ -43,20 +34,14 @@ export function PostActionBar({
|
||||
replyLabel,
|
||||
onReply,
|
||||
onMore,
|
||||
hideZap = false,
|
||||
showShareInSidebar = false,
|
||||
translateAction,
|
||||
className,
|
||||
}: PostActionBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const shareOrigin = useShareOrigin();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const effectiveReplyLabel = replyLabel ?? t('feed.actions.reply');
|
||||
// TODO: Enable zapping split-recipient NIP-75 goals once zap split payments are supported.
|
||||
const canZapAuthor = !hideZap && user && canZap(metadata) && !hasGoalZapSplits(event);
|
||||
|
||||
const { data: stats } = useEventStats(event.id, event);
|
||||
const repostTotal = (stats?.reposts ?? 0) + (stats?.quotes ?? 0);
|
||||
@@ -132,23 +117,6 @@ export function PostActionBar({
|
||||
variant="chip"
|
||||
/>
|
||||
|
||||
{/* Zap */}
|
||||
{canZapAuthor && (
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
|
||||
title={t('feed.actions.zap')}
|
||||
>
|
||||
<Zap className="size-[18px]" />
|
||||
{stats?.zapAmount ? (
|
||||
<span className="tabular-nums">{formatNumber(stats.zapAmount)}</span>
|
||||
) : (
|
||||
<span className="hidden sm:inline">{t('feed.actions.zap')}</span>
|
||||
)}
|
||||
</button>
|
||||
</ZapDialog>
|
||||
)}
|
||||
|
||||
{/* Spacer pushes share/more to the right */}
|
||||
<div className="flex-1" />
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ export function KindPicker({ value, options, onChange }: {
|
||||
{value === 'all'
|
||||
? 'All kinds'
|
||||
: value === 'agora'
|
||||
? 'Agora content'
|
||||
? 'Eranos content'
|
||||
: value === 'custom'
|
||||
? 'Custom...'
|
||||
: (selected?.label ?? value)}
|
||||
@@ -219,14 +219,14 @@ export function KindPicker({ value, options, onChange }: {
|
||||
<div ref={refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={onScroll}>
|
||||
{!search && (
|
||||
<>
|
||||
<KindPickerItem icon={null} label="Agora content" active={value === 'agora'} onClick={() => handleSelect('agora')} />
|
||||
<KindPickerItem icon={null} label="Eranos content" active={value === 'agora'} onClick={() => handleSelect('agora')} />
|
||||
<KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />
|
||||
</>
|
||||
)}
|
||||
{!search && presetOptions.length > 0 && (
|
||||
<>
|
||||
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Agora content
|
||||
Eranos content
|
||||
</div>
|
||||
{presetOptions.map((opt) => (
|
||||
<KindPickerItem key={opt.value} icon={opt.icon ?? null} label={opt.label} active={value === opt.value} onClick={() => handleSelect(opt.value)} />
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, CheckCircle2, PauseCircle } from 'lucide-react';
|
||||
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useHdWalletSp } from '@/hooks/useHdWalletSpContext';
|
||||
|
||||
interface SilentPaymentScanStatusProps {
|
||||
/** Opens the scan options / advanced dialog. */
|
||||
onOpenScanDialog: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact, always-visible status line for the silent-payment background
|
||||
* scanner, rendered on the Private wallet tab.
|
||||
*
|
||||
* Scanning runs automatically in the `HdWalletSpProvider` regardless of which
|
||||
* page the user is on, so this surface is purely a *reflection* of that shared
|
||||
* state plus an escape hatch into the full scan dialog (manual ranges,
|
||||
* deep rescans, reconcile). It never starts a scan itself.
|
||||
*/
|
||||
export function SilentPaymentScanStatus({ onOpenScanDialog }: SilentPaymentScanStatusProps) {
|
||||
const { t } = useTranslation();
|
||||
const sp = useHdWalletSp();
|
||||
|
||||
if (!sp.enabled) return null;
|
||||
|
||||
const scanHeight = sp.storage?.scanHeight ?? 0;
|
||||
|
||||
const scanOptionsButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenScanDialog}
|
||||
className="text-muted-foreground hover:text-foreground underline underline-offset-4 motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm cursor-pointer"
|
||||
>
|
||||
{t('spAutoScan.manualLink')}
|
||||
</button>
|
||||
);
|
||||
|
||||
// While scanning, surface a proper progress bar (matching the scan dialog)
|
||||
// instead of a lone spinner, so completeness is visible at a glance.
|
||||
if (sp.isScanning && sp.scanProgress) {
|
||||
const { currentHeight, fromHeight, toHeight } = sp.scanProgress;
|
||||
const progressPercent = Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
((currentHeight - fromHeight + 1) /
|
||||
Math.max(1, toHeight - fromHeight + 1)) *
|
||||
100,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{t('spAutoScan.scanning', {
|
||||
current: currentHeight.toLocaleString(),
|
||||
to: toHeight.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
{scanOptionsButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (!sp.autoScanEnabled) {
|
||||
content = (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<PauseCircle className="size-3" />
|
||||
{t('spAutoScan.paused')}
|
||||
</span>
|
||||
);
|
||||
} else if (scanHeight > 0 && sp.tipHeight !== undefined && scanHeight >= sp.tipHeight) {
|
||||
content = (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<CheckCircle2 className="size-3 text-green-500" />
|
||||
{t('spAutoScan.caughtUp')}
|
||||
</span>
|
||||
);
|
||||
} else if (scanHeight > 0) {
|
||||
content = (
|
||||
<span className="text-muted-foreground">
|
||||
{t('spAutoScan.lastScanned', { height: scanHeight.toLocaleString() })}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<span className="text-muted-foreground">{t('spAutoScan.neverScanned')}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{content}
|
||||
<span aria-hidden className="text-muted-foreground/40">
|
||||
·
|
||||
</span>
|
||||
{scanOptionsButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { startTransition, useEffect, useState, type ComponentType } from 'react';
|
||||
import { useState, type ComponentType } from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -12,11 +12,10 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
User,
|
||||
Wallet,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
@@ -25,11 +24,8 @@ import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useHdBtcPrice } from '@/hooks/useHdBtcPrice';
|
||||
import { useHdWallet } from '@/hooks/useHdWallet';
|
||||
import { satsToUSD } from '@/lib/bitcoin';
|
||||
import { ZAPSTORE_URL } from '@/lib/zapstore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ZAPSTORE_URL } from '@/lib/zapstore';
|
||||
|
||||
interface NavItem {
|
||||
/** i18n key under `nav.*` for the label. */
|
||||
@@ -121,9 +117,7 @@ export function TopNav() {
|
||||
|
||||
{/* Right cluster */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{user ? (
|
||||
<DeferredWalletBalancePill />
|
||||
) : (
|
||||
{!user && (
|
||||
<>
|
||||
{/* When logged out, surface the language switcher so a visitor
|
||||
who can't read the current UI language can find and pick
|
||||
@@ -208,52 +202,6 @@ export function TopNav() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact USD balance pill in the top-nav right cluster, replacing the
|
||||
* previous search icon. Reads the HD-wallet sats balance via {@link useHdWallet}
|
||||
* and converts to USD via {@link useHdBtcPrice}. Renders nothing when the wallet
|
||||
* isn't available (logged out, extension/bunker login, still loading, or no
|
||||
* price yet) so the chrome stays quiet rather than flashing placeholder text.
|
||||
*/
|
||||
function DeferredWalletBalancePill() {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => setReady(true));
|
||||
}, []);
|
||||
|
||||
return ready ? <WalletBalancePill /> : null;
|
||||
}
|
||||
|
||||
function WalletBalancePill() {
|
||||
const { t } = useTranslation();
|
||||
const { availability, totalBalance, isLoading, error } = useHdWallet();
|
||||
const { data: btcPrice } = useHdBtcPrice();
|
||||
|
||||
if (availability.status !== 'available') return null;
|
||||
if (isLoading || error || !btcPrice) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/wallet"
|
||||
className="shrink-0 inline-flex items-center text-primary rounded-md px-1 hover:bg-primary/10 motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('nav.wallet')}
|
||||
title={t('nav.wallet')}
|
||||
>
|
||||
<span
|
||||
className="latin-display font-display font-normal tracking-wide leading-none uppercase text-xl inline-block tabular-nums"
|
||||
style={{
|
||||
WebkitTextStroke: '0.022em currentColor',
|
||||
transform: 'skewX(-6deg) scaleX(1.1)',
|
||||
transformOrigin: '0 100%',
|
||||
}}
|
||||
>
|
||||
{satsToUSD(totalBalance, btcPrice)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLinkButton({ item }: { item: NavItem }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -318,7 +266,6 @@ function getProfileMenuItems({
|
||||
return [
|
||||
...(showDashboard ? [{ labelKey: 'nav.dashboard', to: '/dashboard', icon: Activity }] : []),
|
||||
{ labelKey: 'nav.myDashboard', to: '/my-dashboard', icon: LayoutDashboard },
|
||||
{ labelKey: 'nav.wallet', to: '/wallet', icon: Wallet },
|
||||
{ labelKey: 'nav.notifications', to: '/notifications', icon: Bell },
|
||||
{ labelKey: 'nav.profile', to: `/${nip19.npubEncode(userPubkey)}`, icon: User },
|
||||
{ labelKey: 'nav.search', to: '/search', icon: Search },
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { HeartHandshake, Share2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
import { VenezuelaReliefGoal } from '@/components/VenezuelaReliefGoal';
|
||||
import { VenezuelaReliefShowcase } from '@/components/VenezuelaReliefShowcase';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
import {
|
||||
VENEZUELA_RELIEF_IMAGES,
|
||||
VENEZUELA_RELIEF_PATH,
|
||||
} from '@/lib/venezuelaRelief';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Ordered set of news photographs from the Venezuela earthquake that
|
||||
* rotate behind the relief banner. Sourced from the shared
|
||||
* {@link VENEZUELA_RELIEF_IMAGES} constant so the hero, popup, and
|
||||
* dedicated page stay in sync. They live in `/public/hero/` and use the
|
||||
* shared {@link HeroBanner} crossfade + slow-pan treatment, the same
|
||||
* animation as the site's other page heroes. Photo attributions are
|
||||
* surfaced generically in the `credit` copy beneath the banner.
|
||||
*/
|
||||
const VENEZUELA_RELIEF_BANNER_IMAGES = VENEZUELA_RELIEF_IMAGES;
|
||||
|
||||
/**
|
||||
* Full-bleed emergency relief banner pinned to the very top of the
|
||||
* home page during the Venezuela earthquake response.
|
||||
*
|
||||
* This is deliberately loud — a disaster appeal, not a subtle promo:
|
||||
*
|
||||
* - A full-bleed crossfading gallery of news photographs from the
|
||||
* quake (via the shared {@link HeroBanner}: slow pan + crossfade,
|
||||
* reduced-motion aware) sits behind the copy. A light dark gradient
|
||||
* keeps the headline and CTAs readable while letting the photos stay
|
||||
* the focus.
|
||||
* - A large display headline ("Venezuela needs you") with the final
|
||||
* word painted inside a solid brand-orange highlighter block — the
|
||||
* same idiom as the home hero's "unstoppable".
|
||||
* - A primary call to action — **Donate to relief** — links to the
|
||||
* dedicated relief page ({@link VENEZUELA_RELIEF_PATH}), which showcases
|
||||
* every Venezuela campaign tagged for relief, plus a **Share** action.
|
||||
*
|
||||
* Beneath the hero sits a live showcase rail
|
||||
* ({@link VenezuelaReliefShowcase}) of those same matching campaigns, so
|
||||
* donors can pick a specific effort without leaving the home page.
|
||||
*
|
||||
* Not dismissible by design — while the appeal is active it stays put
|
||||
* for every visitor (product decision). When the response winds down,
|
||||
* remove `<VenezuelaReliefBanner />` from {@link CampaignsPage}.
|
||||
*
|
||||
* All copy lives under `campaigns.home.venezuelaRelief.*` in the
|
||||
* locale files so every shipped language stays in sync.
|
||||
*/
|
||||
export function VenezuelaReliefBanner({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const shareOrigin = useShareOrigin();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleShare = async () => {
|
||||
const result = await shareOrCopy(
|
||||
`${shareOrigin}${VENEZUELA_RELIEF_PATH}`,
|
||||
t('campaigns.home.venezuelaRelief.shareTitle'),
|
||||
);
|
||||
if (result === 'copied') {
|
||||
toast({ title: t('campaigns.home.venezuelaRelief.linkCopied') });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="venezuela-relief-title"
|
||||
role="region"
|
||||
className={cn(
|
||||
'relative overflow-hidden border-b border-border bg-[hsl(220_25%_6%)] text-white',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Layer 1 — full-bleed crossfading photo gallery. A long
|
||||
interval gives a slow, contemplative pacing; HeroBanner's
|
||||
built-in 1.5s crossfade + slow pan handles the dissolve. */}
|
||||
<HeroBanner images={VENEZUELA_RELIEF_BANNER_IMAGES} intervalMs={9000} />
|
||||
|
||||
{/* Layer 2 — readability gradient. Lighter than a typical hero
|
||||
overlay so the photograph stays the focus: a gentle vertical
|
||||
darken plus a horizontal start-edge darken. The horizontal
|
||||
stops are pinned with explicit percentages (rather than the
|
||||
default even thirds) so the dark backing reliably reaches the
|
||||
end of the centred content column on ultrawide screens and
|
||||
fades to a clean transparent on the trailing half. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/35 to-black/20"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-[linear-gradient(to_right,rgba(0,0,0,0.72)_0%,rgba(0,0,0,0.5)_35%,rgba(0,0,0,0)_70%)] rtl:bg-[linear-gradient(to_left,rgba(0,0,0,0.72)_0%,rgba(0,0,0,0.5)_35%,rgba(0,0,0,0)_70%)]"
|
||||
/>
|
||||
|
||||
{/* Layer 2a — side shadowbox / vignette. Darkens only the outermost
|
||||
edges so the banner frames cleanly on ultrawide displays instead
|
||||
of the photo bleeding flat to the screen edges. The dark stops
|
||||
sit at 0% and 100% and fall off fast (by ~12%), so on normal
|
||||
widths the readable centre is untouched while wide monitors get
|
||||
a soft letterbox-style frame on both sides. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-[linear-gradient(to_right,rgba(0,0,0,0.55)_0%,rgba(0,0,0,0)_12%,rgba(0,0,0,0)_88%,rgba(0,0,0,0.55)_100%)]"
|
||||
/>
|
||||
|
||||
{/* Layer 2b — film-grain noise. A tiny inline SVG fractal-noise
|
||||
texture tiled across the banner at low opacity, with
|
||||
`mix-blend-overlay` so it reads as grain over the photo rather
|
||||
than a flat grey wash. Adds depth and ties the warm photo tones
|
||||
to the dark UI. Pure CSS/SVG, negligible cost, `pointer-events-
|
||||
none` so it never intercepts clicks. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 opacity-[0.12] mix-blend-overlay pointer-events-none"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
|
||||
backgroundSize: '160px 160px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Layer 3 — content. Sized so the headline + CTAs read as the
|
||||
page's hero while still leaving the showcase rail below partly
|
||||
in view above the fold (no full-viewport gap to scroll past).
|
||||
`dvh` so mobile browser chrome (collapsing address bar) doesn't
|
||||
jump the height. */}
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-20 sm:py-28 min-h-[560px] sm:min-h-[680px] flex flex-col justify-center">
|
||||
<div className="max-w-3xl">
|
||||
<h2
|
||||
id="venezuela-relief-title"
|
||||
className="font-display italic font-normal uppercase tracking-wide leading-[0.92] text-5xl sm:text-7xl lg:text-8xl drop-shadow-[0_2px_12px_rgba(0,0,0,0.45)]"
|
||||
style={{ WebkitTextStroke: '0.018em currentColor' }}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="campaigns.home.venezuelaRelief.title"
|
||||
components={[
|
||||
// Index 0: the emphasised word, painted inside a solid
|
||||
// brand-orange highlighter block that hugs the text, the
|
||||
// same idiom as the home hero's "unstoppable". `text-indent`
|
||||
// compensates for Bebas Neue's italic skew so the orange
|
||||
// sits flush against the first letter. `leading-[0.95]`
|
||||
// keeps the block hugging the cap height (matching the
|
||||
// home hero) instead of ballooning to the line box.
|
||||
<span
|
||||
key="hl"
|
||||
className="inline-block w-fit ps-0 pe-3 bg-primary text-white leading-[0.95] align-baseline"
|
||||
style={{ textIndent: '-0.06em' }}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</h2>
|
||||
|
||||
{/* Body sits lower, with generous breathing room above it. A
|
||||
soft shadow keeps it legible now that the overlay is light. */}
|
||||
<p className="mt-7 text-base sm:text-lg lg:text-xl text-white max-w-xl leading-relaxed drop-shadow-[0_1px_8px_rgba(0,0,0,0.6)]">
|
||||
{t('campaigns.home.venezuelaRelief.body')}
|
||||
</p>
|
||||
|
||||
{/* Live fundraising progress for the baked-in relief campaign —
|
||||
the info half of this info + donation hybrid. */}
|
||||
<VenezuelaReliefGoal variant="overlay" className="mt-7" />
|
||||
|
||||
<div className="mt-7 flex flex-col sm:flex-row flex-wrap gap-3">
|
||||
{/* Primary CTA — the dedicated relief page showcasing every
|
||||
Venezuela campaign tagged for relief. */}
|
||||
<Button
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
|
||||
>
|
||||
<Link to={VENEZUELA_RELIEF_PATH}>
|
||||
<HeartHandshake className="mr-2" />
|
||||
{t('campaigns.home.venezuelaRelief.donate')}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* Secondary CTA — share: native share sheet or copy link */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleShare}
|
||||
className="rounded-full h-12 px-6 text-base border-white/40 bg-white/10 text-white hover:bg-white/20 hover:text-white hover:border-white/60 [&_svg]:size-[18px]"
|
||||
>
|
||||
<Share2 className="mr-2" />
|
||||
{t('campaigns.home.venezuelaRelief.share')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Photo credit — accuracy + attribution */}
|
||||
<p className="text-xs text-white/50 pt-1">
|
||||
{t('campaigns.home.venezuelaRelief.credit')}
|
||||
</p>
|
||||
|
||||
{/* Quiet link to the dedicated, shareable relief page */}
|
||||
<Link
|
||||
to={VENEZUELA_RELIEF_PATH}
|
||||
className="mt-2 inline-block text-sm font-medium text-white/80 underline underline-offset-4 hover:text-white motion-safe:transition-colors"
|
||||
>
|
||||
{t('campaigns.home.venezuelaRelief.learnMore')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Showcase rail — every matching relief campaign, pulled in live so
|
||||
donors can pick a specific effort straight from the home page.
|
||||
Sits on a translucent panel anchored to the bottom of the hero so
|
||||
it reads as part of the appeal, over the photo gallery. Renders
|
||||
nothing until campaigns resolve. */}
|
||||
<div className="relative border-t border-white/10 bg-black/40 backdrop-blur-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||
<VenezuelaReliefShowcase variant="overlay" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default VenezuelaReliefBanner;
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
|
||||
import { useVenezuelaReliefCampaigns } from '@/hooks/useVenezuelaReliefCampaigns';
|
||||
|
||||
interface VenezuelaReliefGoalProps {
|
||||
/**
|
||||
* `overlay` — light text, for the dark hero photo backgrounds (banner +
|
||||
* page). `card` — foreground text, for the popup's light card surface.
|
||||
*/
|
||||
variant?: 'overlay' | 'card';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate fundraising readout for the Venezuela relief showcase — the
|
||||
* combined raised total across every matching campaign, plus the
|
||||
* matching-campaign count. Shared by the home hero
|
||||
* ({@link VenezuelaReliefBanner}), the session popup
|
||||
* ({@link VenezuelaReliefPopup}), and the dedicated page
|
||||
* ({@link VenezuelaReliefPage}) so each surface is an info + donation
|
||||
* hybrid backed by the same numbers.
|
||||
*
|
||||
* No goal or progress bar: the appeal spans many independent campaigns
|
||||
* (each with its own goal, shown on its card), so an aggregate "goal" is
|
||||
* meaningless here — we surface the combined raised total only.
|
||||
*
|
||||
* Renders nothing once loaded if no matching campaigns resolve, or they've
|
||||
* raised nothing yet — the surrounding appeal copy and CTAs stand on their
|
||||
* own, so this never leaves an empty box.
|
||||
*/
|
||||
export function VenezuelaReliefGoal({ variant = 'overlay', className }: VenezuelaReliefGoalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isLoading, raisedSats, campaignCount, btcPrice } =
|
||||
useVenezuelaReliefCampaigns();
|
||||
|
||||
const isOverlay = variant === 'overlay';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('w-full max-w-md space-y-2', className)}>
|
||||
<Skeleton className={cn('h-7 w-44', isOverlay && 'bg-white/20')} />
|
||||
<Skeleton className={cn('h-3 w-28', isOverlay && 'bg-white/20')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing meaningful to show — let the appeal copy carry the surface.
|
||||
if (raisedSats <= 0) return null;
|
||||
|
||||
const raisedLabel = formatCampaignAmount(raisedSats, btcPrice);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full max-w-md space-y-1',
|
||||
isOverlay
|
||||
? 'drop-shadow-[0_1px_8px_rgba(0,0,0,0.6)]'
|
||||
: 'rounded-lg border border-border bg-muted/40 p-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-2xl sm:text-3xl font-bold tracking-tight',
|
||||
isOverlay ? 'text-white' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{raisedLabel}
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1.5 text-sm font-normal',
|
||||
isOverlay ? 'text-white/70' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{t('campaignsDetail.raised')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{campaignCount > 0 && (
|
||||
<p className={cn('text-xs', isOverlay ? 'text-white/70' : 'text-muted-foreground')}>
|
||||
{t('campaigns.home.venezuelaRelief.campaignCount', { count: campaignCount })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VenezuelaReliefGoal;
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { HeartHandshake, Share2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { VenezuelaReliefGoal } from '@/components/VenezuelaReliefGoal';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
import {
|
||||
VENEZUELA_RELIEF_IMAGES,
|
||||
VENEZUELA_RELIEF_PATH,
|
||||
VENEZUELA_RELIEF_URL,
|
||||
VENEZUELA_RELIEF_POPUP_SEEN_KEY,
|
||||
} from '@/lib/venezuelaRelief';
|
||||
|
||||
/**
|
||||
* Site-wide Venezuela earthquake relief popup.
|
||||
*
|
||||
* Mounted once in `App.tsx` so it surfaces on a fresh load of any route.
|
||||
* It carries the same appeal as the home-page hero
|
||||
* ({@link VenezuelaReliefBanner}): the same lead photo, headline, and
|
||||
* donate CTA, plus a share action and a link to the dedicated relief
|
||||
* page.
|
||||
*
|
||||
* Frequency: **once per browser session.** A `sessionStorage` flag
|
||||
* (`VENEZUELA_RELIEF_POPUP_SEEN_KEY`) is set the first time it shows, so
|
||||
* it won't reappear on subsequent in-session navigations or reloads, but
|
||||
* returns on the next fresh session (tab/app reopened). We deliberately
|
||||
* avoid `localStorage` so the appeal keeps reaching returning visitors.
|
||||
*
|
||||
* When the relief response winds down, remove `<VenezuelaReliefPopup />`
|
||||
* from `App.tsx`.
|
||||
*/
|
||||
/**
|
||||
* Module-level guard so the popup's "should I open?" decision is made
|
||||
* exactly once per page load, surviving React 19 StrictMode's
|
||||
* double-mount (which unmounts and remounts the component, resetting any
|
||||
* component state / refs). Without this, the first mount would write the
|
||||
* sessionStorage "seen" flag and open, then StrictMode's remount would
|
||||
* read the freshly-written flag, decide "already seen", and leave the
|
||||
* popup closed — so it would flash open and immediately vanish.
|
||||
*/
|
||||
let decidedThisLoad = false;
|
||||
let shouldOpenThisLoad = false;
|
||||
|
||||
export function VenezuelaReliefPopup() {
|
||||
const { t } = useTranslation();
|
||||
const shareOrigin = useShareOrigin();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Decide once per page load (guarded against StrictMode remounts) whether
|
||||
// this is a fresh session that hasn't seen the popup yet. We both read and
|
||||
// write the sessionStorage flag here, inside the one-time guard, so the
|
||||
// decision is stable for the lifetime of the load.
|
||||
if (!decidedThisLoad) {
|
||||
decidedThisLoad = true;
|
||||
let seen = false;
|
||||
try {
|
||||
seen = sessionStorage.getItem(VENEZUELA_RELIEF_POPUP_SEEN_KEY) === '1';
|
||||
if (!seen) sessionStorage.setItem(VENEZUELA_RELIEF_POPUP_SEEN_KEY, '1');
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / sandbox): show once,
|
||||
// best-effort, rather than crash.
|
||||
}
|
||||
shouldOpenThisLoad = !seen;
|
||||
}
|
||||
|
||||
const [open, setOpen] = useState(shouldOpenThisLoad);
|
||||
|
||||
const handleShare = async () => {
|
||||
const result = await shareOrCopy(
|
||||
`${shareOrigin}${VENEZUELA_RELIEF_PATH}`,
|
||||
t('campaigns.home.venezuelaRelief.shareTitle'),
|
||||
);
|
||||
if (result === 'copied') {
|
||||
toast({ title: t('campaigns.home.venezuelaRelief.linkCopied') });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
|
||||
{/* Lead photo from the appeal, with a dark scrim and the headline
|
||||
painted over it, echoing the home hero treatment. */}
|
||||
<div className="relative h-44 w-full bg-[hsl(220_25%_6%)]">
|
||||
<img
|
||||
src={VENEZUELA_RELIEF_IMAGES[0]}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/40 to-black/20"
|
||||
/>
|
||||
<DialogHeader className="absolute inset-x-0 bottom-0 p-5 text-left">
|
||||
<DialogTitle className="font-display italic font-normal uppercase tracking-wide leading-[0.92] text-3xl text-white drop-shadow-[0_2px_12px_rgba(0,0,0,0.5)]">
|
||||
<Trans
|
||||
i18nKey="campaigns.home.venezuelaRelief.title"
|
||||
components={[
|
||||
<span
|
||||
key="hl"
|
||||
className="inline-block w-fit ps-0 pe-2 bg-primary text-white leading-[0.95] align-baseline"
|
||||
style={{ textIndent: '-0.06em' }}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-5 pt-1">
|
||||
<DialogDescription className="text-sm leading-relaxed text-foreground/80">
|
||||
{t('campaigns.home.venezuelaRelief.popupBody')}
|
||||
</DialogDescription>
|
||||
|
||||
{/* Live fundraising progress — the info half of the hybrid. */}
|
||||
<VenezuelaReliefGoal variant="card" className="mt-4" />
|
||||
|
||||
<DialogFooter className="mt-5 sm:flex-row sm:justify-start sm:gap-2">
|
||||
<Button asChild className="rounded-full font-semibold [&_svg]:size-[18px]">
|
||||
<a href={VENEZUELA_RELIEF_URL} onClick={() => setOpen(false)}>
|
||||
<HeartHandshake className="mr-2" />
|
||||
{t('campaigns.home.venezuelaRelief.donate')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleShare}
|
||||
className="rounded-full [&_svg]:size-[18px]"
|
||||
>
|
||||
<Share2 className="mr-2" />
|
||||
{t('campaigns.home.venezuelaRelief.share')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<a
|
||||
href={VENEZUELA_RELIEF_URL}
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-3 inline-block text-sm font-medium text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
>
|
||||
{t('campaigns.home.venezuelaRelief.learnMore')}
|
||||
</a>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default VenezuelaReliefPopup;
|
||||
@@ -1,332 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { useVenezuelaReliefCampaigns } from '@/hooks/useVenezuelaReliefCampaigns';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface VenezuelaReliefShowcaseProps {
|
||||
/**
|
||||
* `overlay` — heading in white + edge fades to the dark hero background,
|
||||
* for the banner / page hero. `default` — foreground heading + edge fades
|
||||
* to `bg-background`, for a light section surface.
|
||||
*/
|
||||
variant?: 'overlay' | 'default';
|
||||
className?: string;
|
||||
/** Optional id for scroll-into-view targeting (page "Donate" CTA). */
|
||||
id?: string;
|
||||
/**
|
||||
* Pixels-per-second pan speed for the auto-scroll marquee. Keep it slow —
|
||||
* the rail should feel ambient, not demanding. Defaults to 24 px/s.
|
||||
*/
|
||||
pxPerSecond?: number;
|
||||
}
|
||||
|
||||
const CARD_WIDTH_CLASS = 'w-[300px]';
|
||||
|
||||
/**
|
||||
* Horizontal auto-scrolling "marquee" of every Venezuela-located campaign
|
||||
* tagged for relief (`humanitarian-aid` / `emergency-relief`) created since
|
||||
* the earthquake, resolved live via {@link useVenezuelaReliefCampaigns}.
|
||||
* Shared by the home hero ({@link VenezuelaReliefBanner}) and the dedicated
|
||||
* page ({@link VenezuelaReliefPage}).
|
||||
*
|
||||
* Interaction model (ported from the surveil deck shelf):
|
||||
*
|
||||
* - Pans on its own at `pxPerSecond`; the campaign list is duplicated in
|
||||
* the DOM so the track wraps at -50% with no visible jump.
|
||||
* - Hover / focus pauses the pan so clicking a card isn't a moving target.
|
||||
* - Click-drag / swipe scrubs the rail, with a short momentum coast on
|
||||
* release; a travel threshold suppresses the click so a drag never
|
||||
* accidentally navigates into a card.
|
||||
* - Honors `prefers-reduced-motion`: the track sits still and becomes a
|
||||
* native horizontal scroll container so content stays reachable.
|
||||
* - Soft gradient fades on both edges so cards dissolve in and out rather
|
||||
* than hitting a hard cutoff.
|
||||
*
|
||||
* Renders nothing once loaded if no campaigns match, so the surrounding
|
||||
* appeal copy and CTAs carry the surface alone.
|
||||
*/
|
||||
export function VenezuelaReliefShowcase({
|
||||
variant = 'default',
|
||||
className,
|
||||
id,
|
||||
pxPerSecond = 24,
|
||||
}: VenezuelaReliefShowcaseProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isLoading, campaigns } = useVenezuelaReliefCampaigns();
|
||||
|
||||
const isOverlay = variant === 'overlay';
|
||||
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Pan offset and pause flag live in refs so hover (or any pause flip)
|
||||
// doesn't restart the rAF effect and reset motion to 0.
|
||||
const offsetRef = useRef(0);
|
||||
const pausedRef = useRef(false);
|
||||
|
||||
// Drag / swipe state — all refs so gestures never trigger re-renders.
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragStartXRef = useRef(0);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const dragTravelRef = useRef(0);
|
||||
const pointerIdRef = useRef<number | null>(null);
|
||||
const lastDragXRef = useRef(0);
|
||||
const lastDragTimeRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
const momentumRafRef = useRef(0);
|
||||
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setReduceMotion(mq.matches);
|
||||
update();
|
||||
mq.addEventListener('change', update);
|
||||
return () => mq.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
// Clamp offset within one full copy of the pool (seamless-loop invariant).
|
||||
const clampOffset = useCallback((raw: number): number => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return raw;
|
||||
const half = el.scrollWidth / 2;
|
||||
if (half <= 0) return raw;
|
||||
let clamped = raw;
|
||||
if (-clamped >= half) clamped += half;
|
||||
if (clamped > 0) clamped -= half;
|
||||
return clamped;
|
||||
}, []);
|
||||
|
||||
// rAF loop: pan leftward, wrap at -50%. Writes transform directly to the
|
||||
// DOM so motion stays smooth across surrounding re-renders.
|
||||
useEffect(() => {
|
||||
if (reduceMotion) return;
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
if (campaigns.length === 0) return;
|
||||
|
||||
let last = performance.now();
|
||||
let raf = 0;
|
||||
const step = (now: number) => {
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
if (!pausedRef.current) {
|
||||
offsetRef.current -= pxPerSecond * dt;
|
||||
const half = el.scrollWidth / 2;
|
||||
if (half > 0 && -offsetRef.current >= half) {
|
||||
offsetRef.current += half;
|
||||
}
|
||||
el.style.transform = `translate3d(${offsetRef.current}px, 0, 0)`;
|
||||
}
|
||||
raf = requestAnimationFrame(step);
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [pxPerSecond, campaigns.length, reduceMotion]);
|
||||
|
||||
// ── Drag / swipe handlers ──────────────────────────────────────────────
|
||||
const onDragStart = useCallback(
|
||||
(clientX: number, pointerId: number | null) => {
|
||||
if (reduceMotion) return;
|
||||
cancelAnimationFrame(momentumRafRef.current);
|
||||
isDraggingRef.current = true;
|
||||
pausedRef.current = true;
|
||||
dragStartXRef.current = clientX;
|
||||
dragStartOffsetRef.current = offsetRef.current;
|
||||
dragTravelRef.current = 0;
|
||||
lastDragXRef.current = clientX;
|
||||
lastDragTimeRef.current = performance.now();
|
||||
velocityRef.current = 0;
|
||||
// Capture lazily in onDragMove once a real drag is confirmed —
|
||||
// capturing on pointerdown re-targets the click and breaks the
|
||||
// child <Link> navigation.
|
||||
pointerIdRef.current = pointerId;
|
||||
},
|
||||
[reduceMotion],
|
||||
);
|
||||
|
||||
const onDragMove = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!isDraggingRef.current) return;
|
||||
if (
|
||||
pointerIdRef.current !== null &&
|
||||
viewportRef.current &&
|
||||
dragTravelRef.current <= 4 &&
|
||||
Math.abs(clientX - dragStartXRef.current) > 4
|
||||
) {
|
||||
viewportRef.current.setPointerCapture(pointerIdRef.current);
|
||||
}
|
||||
const delta = clientX - dragStartXRef.current;
|
||||
offsetRef.current = clampOffset(dragStartOffsetRef.current + delta);
|
||||
if (trackRef.current) {
|
||||
trackRef.current.style.transform = `translate3d(${offsetRef.current}px, 0, 0)`;
|
||||
}
|
||||
dragTravelRef.current += Math.abs(clientX - lastDragXRef.current);
|
||||
const now = performance.now();
|
||||
const dt = now - lastDragTimeRef.current;
|
||||
if (dt > 0) {
|
||||
velocityRef.current = ((clientX - lastDragXRef.current) / dt) * 1000;
|
||||
}
|
||||
lastDragXRef.current = clientX;
|
||||
lastDragTimeRef.current = now;
|
||||
},
|
||||
[clampOffset],
|
||||
);
|
||||
|
||||
const onDragEnd = useCallback(() => {
|
||||
if (!isDraggingRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
|
||||
// Reset travel next frame so the click guard works for this drag but
|
||||
// doesn't bleed into future taps.
|
||||
requestAnimationFrame(() => {
|
||||
dragTravelRef.current = 0;
|
||||
});
|
||||
|
||||
let v = velocityRef.current;
|
||||
const FRICTION = 0.92;
|
||||
const coast = () => {
|
||||
v *= FRICTION;
|
||||
if (Math.abs(v) < 1) {
|
||||
pausedRef.current = false;
|
||||
return;
|
||||
}
|
||||
offsetRef.current = clampOffset(offsetRef.current + v / 60);
|
||||
if (trackRef.current) {
|
||||
trackRef.current.style.transform = `translate3d(${offsetRef.current}px, 0, 0)`;
|
||||
}
|
||||
momentumRafRef.current = requestAnimationFrame(coast);
|
||||
};
|
||||
momentumRafRef.current = requestAnimationFrame(coast);
|
||||
}, [clampOffset]);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
||||
onDragStart(e.clientX, e.pointerId);
|
||||
},
|
||||
[onDragStart],
|
||||
);
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => onDragMove(e.clientX),
|
||||
[onDragMove],
|
||||
);
|
||||
const handlePointerUp = useCallback(() => onDragEnd(), [onDragEnd]);
|
||||
const handlePointerCancel = useCallback(() => {
|
||||
isDraggingRef.current = false;
|
||||
pausedRef.current = false;
|
||||
cancelAnimationFrame(momentumRafRef.current);
|
||||
}, []);
|
||||
|
||||
// Suppress the click that fires at the end of a drag that travelled more
|
||||
// than a few pixels, so swiping never navigates into a card.
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (dragTravelRef.current > 4) e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Once loaded with no matches, render nothing — the appeal copy stands
|
||||
// on its own rather than leaving an empty "Relief campaigns" header.
|
||||
if (!isLoading && campaigns.length === 0) return null;
|
||||
|
||||
const doubledPool: ParsedCampaign[] =
|
||||
campaigns.length > 0 ? [...campaigns, ...campaigns] : campaigns;
|
||||
|
||||
const fadeFrom = isOverlay ? 'from-black/60' : 'from-background';
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
aria-label={t('campaigns.home.venezuelaRelief.showcaseTitle')}
|
||||
className={cn('scroll-mt-20 space-y-4', className)}
|
||||
>
|
||||
<h2
|
||||
className={cn(
|
||||
'text-lg sm:text-xl font-bold tracking-tight',
|
||||
isOverlay
|
||||
? 'text-white drop-shadow-[0_1px_8px_rgba(0,0,0,0.6)]'
|
||||
: 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{t('campaigns.home.venezuelaRelief.showcaseTitle')}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className={cn(
|
||||
'relative -mx-4 sm:mx-0',
|
||||
reduceMotion
|
||||
? 'overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
: 'overflow-hidden',
|
||||
!reduceMotion && 'cursor-grab active:cursor-grabbing',
|
||||
'select-none',
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!isDraggingRef.current) pausedRef.current = true;
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!isDraggingRef.current) pausedRef.current = false;
|
||||
}}
|
||||
onFocusCapture={() => {
|
||||
pausedRef.current = true;
|
||||
}}
|
||||
onBlurCapture={() => {
|
||||
if (!isDraggingRef.current) pausedRef.current = false;
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onClick={handleClick}
|
||||
style={reduceMotion ? undefined : { touchAction: 'pan-y' }}
|
||||
>
|
||||
{/* Edge fades — absolutely-positioned gradient panels (a CSS mask
|
||||
gets bypassed by descendants with their own stacking context,
|
||||
e.g. the cards' hover transform). pointer-events-none so they
|
||||
don't swallow card clicks. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 left-0 w-16 sm:w-20 z-20 bg-gradient-to-r to-transparent',
|
||||
fadeFrom,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 right-0 w-16 sm:w-20 z-20 bg-gradient-to-l to-transparent',
|
||||
fadeFrom,
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={cn(
|
||||
'flex items-stretch gap-4 px-4 sm:px-6 pb-2 w-max',
|
||||
!reduceMotion && 'will-change-transform',
|
||||
)}
|
||||
>
|
||||
{isLoading && campaigns.length === 0
|
||||
? Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className={cn('shrink-0', CARD_WIDTH_CLASS)}>
|
||||
<CampaignCardSkeleton />
|
||||
</div>
|
||||
))
|
||||
: doubledPool.map((campaign, i) => (
|
||||
<div
|
||||
key={i < campaigns.length ? campaign.aTag : `${campaign.aTag}-dup`}
|
||||
aria-hidden={i >= campaigns.length ? true : undefined}
|
||||
className={cn('shrink-0', CARD_WIDTH_CLASS)}
|
||||
>
|
||||
<CampaignCard campaign={campaign} variant="compact" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default VenezuelaReliefShowcase;
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useHdWalletAccess } from '@/hooks/useHdWalletAccess';
|
||||
|
||||
/**
|
||||
* Renders the user's 24-word BIP-39 wallet seed phrase. The seed-phrase box
|
||||
* itself is the reveal affordance — tap once to expose the words, tap again
|
||||
* to hide them. The mnemonic is derived deterministically from the user's
|
||||
* nsec via the v2 derivation pipeline (`src/lib/hdwallet/seed.ts`); this
|
||||
* component does not generate or store anything — re-renders re-derive from
|
||||
* the active login.
|
||||
*
|
||||
* The 24 words can be imported into any BIP-39-compatible wallet (Sparrow,
|
||||
* Electrum, Trezor, Ledger, Phoenix, BlueWallet, …) at the BIP-86 / BIP-352
|
||||
* paths. Agora itself only needs the nsec — the mnemonic exists solely so
|
||||
* users can take their funds elsewhere.
|
||||
*
|
||||
* Renders nothing when the active login type doesn't expose the nsec
|
||||
* (browser extension / NIP-46 bunker). Those signers can't derive the
|
||||
* wallet at all and so have no mnemonic to back up.
|
||||
*/
|
||||
export function WalletBackupMnemonic() {
|
||||
const { t } = useTranslation();
|
||||
const access = useHdWalletAccess();
|
||||
|
||||
const [showWords, setShowWords] = useState(false);
|
||||
|
||||
// Split into a stable list reference so the render is cheap on every
|
||||
// toggle. Always compute (the cost of `useMemo` is trivial), but pass an
|
||||
// empty array when no mnemonic is available so hooks order stays stable.
|
||||
const words = useMemo(() => {
|
||||
if (access.status !== 'available') return [] as string[];
|
||||
return access.mnemonic.split(' ');
|
||||
}, [access]);
|
||||
|
||||
if (access.status !== 'available') return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWords((v) => !v)}
|
||||
aria-pressed={showWords}
|
||||
aria-label={showWords ? t('walletBackup.hideAria') : t('walletBackup.revealAria')}
|
||||
className="w-full text-left rounded-lg border bg-muted/30 p-4 motion-safe:transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
|
||||
>
|
||||
{showWords ? (
|
||||
<ol className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2 text-sm font-mono">
|
||||
{words.map((w, i) => (
|
||||
<li key={`${i}-${w}`} className="flex items-baseline gap-2">
|
||||
<span className="text-muted-foreground tabular-nums w-6 text-right">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<span>{w}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-center text-xs text-muted-foreground py-4">
|
||||
{t('walletBackup.hidden')}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showWords && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-xs text-amber-900 dark:text-amber-300 leading-relaxed">
|
||||
{t('walletBackup.warning')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained dialog wrapper around {@link WalletBackupMnemonic}. Used
|
||||
* by `/wallet` as a single "Back up wallet" affordance the user can open
|
||||
* without leaving the wallet flow.
|
||||
*/
|
||||
export function WalletBackupMnemonicDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('walletBackup.heading')}</DialogTitle>
|
||||
<DialogDescription>{t('walletBackup.dialogDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<WalletBackupMnemonic />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, Zap, Globe, WalletMinimal, CheckCircle, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useNWC } from '@/hooks/useNWCContext';
|
||||
import { useWallet } from '@/hooks/useWallet';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
export function WalletSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [connectionUri, setConnectionUri] = useState('');
|
||||
const [alias, setAlias] = useState('');
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
const {
|
||||
connections,
|
||||
activeConnection,
|
||||
connectionInfo,
|
||||
addConnection,
|
||||
removeConnection,
|
||||
setActiveConnection,
|
||||
} = useNWC();
|
||||
const { webln } = useWallet();
|
||||
const hasNWC = connections.length > 0 && connections.some(c => c.isConnected);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleAddConnection = async () => {
|
||||
if (!connectionUri.trim()) {
|
||||
toast({
|
||||
title: t('walletConnect.toast.uriRequiredTitle'),
|
||||
description: t('walletConnect.toast.uriRequiredDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
|
||||
if (success) {
|
||||
setConnectionUri('');
|
||||
setAlias('');
|
||||
setAddDialogOpen(false);
|
||||
}
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveConnection = (connectionString: string) => {
|
||||
removeConnection(connectionString);
|
||||
};
|
||||
|
||||
const handleSetActive = (connectionString: string) => {
|
||||
setActiveConnection(connectionString);
|
||||
toast({
|
||||
title: t('walletConnect.toast.activeChangedTitle'),
|
||||
description: t('walletConnect.toast.activeChangedDesc'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{/* Connection status cards */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-1">{t('walletConnect.status')}</h2>
|
||||
<div className="grid gap-3">
|
||||
{/* WebLN */}
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-9 rounded-full bg-secondary">
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t('walletConnect.webln.name')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('walletConnect.webln.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{webln && <CheckCircle className="size-4 text-green-500" />}
|
||||
<Badge variant={webln ? 'default' : 'secondary'} className="text-xs">
|
||||
{webln ? t('walletConnect.ready') : t('walletConnect.notFound')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* NWC */}
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-9 rounded-full bg-secondary">
|
||||
<WalletMinimal className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t('walletConnect.nwc.name')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{connections.length > 0
|
||||
? t('walletConnect.nwc.connectedCount', { count: connections.length })
|
||||
: t('walletConnect.nwc.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasNWC && <CheckCircle className="size-4 text-green-500" />}
|
||||
<Badge variant={hasNWC ? 'default' : 'secondary'} className="text-xs">
|
||||
{hasNWC ? t('walletConnect.ready') : t('walletConnect.none')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* NWC Wallets */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{t('walletConnect.nwc.name')}</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)} className="rounded-full">
|
||||
<Plus className="size-4 mr-1" />
|
||||
{t('walletConnect.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-10 text-center">
|
||||
<WalletMinimal className="size-8 mx-auto mb-3 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-1">{t('walletConnect.empty.title')}</p>
|
||||
<p className="text-xs text-muted-foreground/70">{t('walletConnect.empty.description')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{connections.map((connection) => {
|
||||
const info = connectionInfo[connection.connectionString];
|
||||
const isActive = activeConnection === connection.connectionString;
|
||||
return (
|
||||
<Card key={connection.connectionString} className={isActive ? 'ring-2 ring-primary' : ''}>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex items-center justify-center size-9 rounded-full bg-secondary shrink-0">
|
||||
<WalletMinimal className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{connection.alias || info?.alias || t('walletConnect.defaultWalletName')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isActive ? t('walletConnect.active') : t('walletConnect.connectionLabel')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isActive && <CheckCircle className="size-4 text-green-500 mr-1" />}
|
||||
{!isActive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleSetActive(connection.connectionString)}
|
||||
className="rounded-full"
|
||||
title={t('walletConnect.setActiveTitle')}
|
||||
>
|
||||
<Zap className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveConnection(connection.connectionString)}
|
||||
className="rounded-full text-muted-foreground hover:text-destructive"
|
||||
title={t('walletConnect.removeTitle')}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
{!webln && connections.length === 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="text-center py-4 space-y-2 px-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('walletConnect.helpText')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add wallet dialog */}
|
||||
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||
<DialogContent className="max-w-[520px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-12">
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{t('walletConnect.dialog.title')}
|
||||
</DialogTitle>
|
||||
<button
|
||||
onClick={() => setAddDialogOpen(false)}
|
||||
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="px-4 -mt-1 mb-2 text-sm text-muted-foreground">
|
||||
{t('walletConnect.dialog.description')}
|
||||
</p>
|
||||
|
||||
{/* Form fields */}
|
||||
<div className="px-4 space-y-4">
|
||||
<Input
|
||||
placeholder={t('walletConnect.dialog.aliasPlaceholder')}
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
className="bg-transparent"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="nostr+walletconnect://..."
|
||||
value={connectionUri}
|
||||
onChange={(e) => setConnectionUri(e.target.value)}
|
||||
rows={3}
|
||||
className="bg-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end px-4 py-3">
|
||||
<Button
|
||||
onClick={handleAddConnection}
|
||||
disabled={isConnecting || !connectionUri.trim()}
|
||||
className="rounded-full px-5 font-bold"
|
||||
size="sm"
|
||||
>
|
||||
{isConnecting ? t('walletConnect.dialog.connecting') : t('walletConnect.dialog.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, forwardRef } from 'react';
|
||||
import { Zap, Copy, Check, ExternalLink, X, Bitcoin, Loader2 } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { impactMedium } from '@/lib/haptics';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { OnchainZapContent } from '@/components/OnchainZapContent';
|
||||
import { ZapSuccessScreen } from '@/components/ZapSuccessScreen';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useZaps } from '@/hooks/useZaps';
|
||||
import { useWallet } from '@/hooks/useWallet';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import {
|
||||
fetchBtcPrice,
|
||||
isLargeAmount,
|
||||
satsToUSD,
|
||||
} from '@/lib/bitcoin';
|
||||
import type { Event } from 'nostr-tools';
|
||||
import type { WebLNProvider } from '@webbtc/webln-types';
|
||||
|
||||
interface ZapDialogProps {
|
||||
target: Event;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// USD presets for the Lightning tab. Lightning zaps are expected to be
|
||||
// much smaller than on-chain sends (which have a fixed per-tx fee floor),
|
||||
// so the presets stay in tip-jar territory.
|
||||
const LIGHTNING_USD_PRESETS = [0.1, 0.5, 1, 2, 5];
|
||||
|
||||
/** Format a preset button label without trailing zeros ($0.10 → $0.10, $1 → $1). */
|
||||
function formatPresetLabel(usd: number): string {
|
||||
return usd < 1 ? `$${usd.toFixed(2)}` : `$${usd}`;
|
||||
}
|
||||
|
||||
interface LightningZapContentProps {
|
||||
invoice: string | null;
|
||||
usdAmount: number | string;
|
||||
amountSats: number;
|
||||
btcPrice: number | undefined;
|
||||
isZapping: boolean;
|
||||
copied: boolean;
|
||||
webln: WebLNProvider | null;
|
||||
insufficient: boolean;
|
||||
isLarge: boolean;
|
||||
confirmArmed: boolean;
|
||||
error: string;
|
||||
handleZap: () => void;
|
||||
handleCopy: () => void;
|
||||
openInWallet: () => void;
|
||||
setUsdAmount: (amount: number | string) => void;
|
||||
setError: (msg: string) => void;
|
||||
editingAmount: boolean;
|
||||
setEditingAmount: (v: boolean) => void;
|
||||
amountInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
commitAmountEdit: () => void;
|
||||
payWithWebLN: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightning zap flow. Mirrors the onchain tab: one screen, one button, no
|
||||
* comment field. Amount is denominated in USD and converted to sats at
|
||||
* payment time using the same BTC price query the onchain tab uses.
|
||||
*
|
||||
* Defined outside `ZapDialog` as a `forwardRef` to keep the amount input
|
||||
* from losing focus on parent re-renders.
|
||||
*/
|
||||
const LightningZapContent = forwardRef<HTMLDivElement, LightningZapContentProps>(({
|
||||
invoice,
|
||||
usdAmount,
|
||||
amountSats,
|
||||
btcPrice,
|
||||
isZapping,
|
||||
copied,
|
||||
webln,
|
||||
insufficient,
|
||||
isLarge,
|
||||
confirmArmed,
|
||||
error,
|
||||
handleZap,
|
||||
handleCopy,
|
||||
openInWallet,
|
||||
setUsdAmount,
|
||||
setError,
|
||||
editingAmount,
|
||||
setEditingAmount,
|
||||
amountInputRef,
|
||||
commitAmountEdit,
|
||||
payWithWebLN,
|
||||
}, ref) => {
|
||||
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
const hasValidAmount = Number.isFinite(currentUsd) && currentUsd > 0;
|
||||
const usdString = btcPrice && amountSats > 0 ? satsToUSD(amountSats, btcPrice) : '';
|
||||
// When btcPrice hasn't loaded yet, fall back to formatting the raw USD
|
||||
// input so small values like 0.1 still render as "$0.10".
|
||||
const fallbackUsd = hasValidAmount
|
||||
? (currentUsd < 1 ? `$${currentUsd.toFixed(2)}` : `$${currentUsd}`)
|
||||
: '';
|
||||
const usdDisplay = usdString || fallbackUsd;
|
||||
|
||||
if (invoice) {
|
||||
return (
|
||||
<div ref={ref} className="grid gap-3 px-4 py-4 w-full overflow-hidden">
|
||||
{/* Amount header — USD only; sats are an implementation detail. */}
|
||||
<div className="flex flex-col items-center pt-1">
|
||||
<div className="text-3xl font-semibold tabular-nums">
|
||||
{usdDisplay}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR code */}
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white p-3 rounded-xl" aria-label="Lightning invoice QR code">
|
||||
<QRCodeCanvas value={invoice.toUpperCase()} size={220} level="M" className="block" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice copy row */}
|
||||
<div className="flex gap-2 min-w-0">
|
||||
<Input
|
||||
id="invoice"
|
||||
value={invoice}
|
||||
readOnly
|
||||
aria-label="Lightning invoice"
|
||||
className="font-mono text-xs min-w-0 flex-1 overflow-hidden text-ellipsis"
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0"
|
||||
aria-label="Copy invoice"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Payment actions */}
|
||||
<div className="grid gap-2">
|
||||
{webln && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={payWithWebLN}
|
||||
disabled={isZapping}
|
||||
className="w-full"
|
||||
>
|
||||
{isZapping ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-1.5 animate-spin" />
|
||||
Processing…
|
||||
</>
|
||||
) : (
|
||||
'Pay with WebLN'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant={webln ? 'outline' : 'default'}
|
||||
onClick={openInWallet}
|
||||
className="w-full"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Open in Lightning Wallet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground text-center">
|
||||
Scan the QR or copy the invoice to pay with any Lightning wallet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="grid gap-3 px-4 py-4 w-full overflow-hidden">
|
||||
{/* Amount — big number on top, editable by clicking. Matches OnchainZapContent. */}
|
||||
<div className="flex flex-col items-center pt-2">
|
||||
{editingAmount ? (
|
||||
<div className="flex items-baseline justify-center">
|
||||
<span className={`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) => { setUsdAmount(e.target.value); setError(''); }}
|
||||
onBlur={commitAmountEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitAmountEdit();
|
||||
}
|
||||
}}
|
||||
aria-label="Amount in USD"
|
||||
className={`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={() => setEditingAmount(true)}
|
||||
aria-label="Edit amount"
|
||||
className="flex items-baseline justify-center rounded-md px-2 -mx-2 hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
|
||||
>
|
||||
<span className={`text-4xl font-semibold ${insufficient ? 'text-destructive' : 'text-muted-foreground'}`}>$</span>
|
||||
<span className={`text-4xl font-semibold tabular-nums ${insufficient ? 'text-destructive' : ''}`}>
|
||||
{hasValidAmount ? (currentUsd < 1 ? currentUsd.toFixed(2) : currentUsd) : 0}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Presets — compact. Lightning zaps lean small, so the defaults stay
|
||||
in tip-jar territory. */}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={LIGHTNING_USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
|
||||
onValueChange={(v) => { if (v) { setUsdAmount(Number(v)); setError(''); setEditingAmount(false); } }}
|
||||
className="grid grid-cols-5 gap-1 w-full"
|
||||
>
|
||||
{LIGHTNING_USD_PRESETS.map((v) => (
|
||||
<ToggleGroupItem
|
||||
key={v}
|
||||
value={String(v)}
|
||||
className="h-8 min-w-0 text-xs font-semibold px-1"
|
||||
>
|
||||
{formatPresetLabel(v)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleZap}
|
||||
disabled={!btcPrice || amountSats <= 0 || isZapping}
|
||||
variant={isLarge && !isZapping ? 'destructive' : 'default'}
|
||||
className="w-full"
|
||||
>
|
||||
{isZapping ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-1.5 animate-spin" />
|
||||
Creating invoice…
|
||||
</>
|
||||
) : isLarge && confirmArmed ? (
|
||||
<>Tap again to send {usdDisplay}</>
|
||||
) : (
|
||||
<>Send {usdDisplay}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
LightningZapContent.displayName = 'LightningZapContent';
|
||||
|
||||
export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user } = useCurrentUser();
|
||||
const { data: author } = useAuthor(target.pubkey);
|
||||
const { toast } = useToast();
|
||||
const { webln, activeNWC } = useWallet();
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
|
||||
// Success state: populated by either zap rail's onSuccess callback.
|
||||
// When set, we replace the tab UI with <ZapSuccessScreen />.
|
||||
const [success, setSuccess] = useState<
|
||||
| { kind: 'onchain'; amountSats: number; txid: string }
|
||||
| { kind: 'lightning'; amountSats: number }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const handleLightningSuccess = useCallback(
|
||||
({ amountSats }: { amountSats: number }) => {
|
||||
setSuccess({ kind: 'lightning', amountSats });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { zap, isZapping, invoice, setInvoice } = useZaps(
|
||||
target,
|
||||
webln,
|
||||
activeNWC,
|
||||
handleLightningSuccess,
|
||||
);
|
||||
|
||||
// USD-denominated state (matches OnchainZapContent). The sats amount is
|
||||
// derived just before we hit the LNURL endpoint.
|
||||
const [usdAmount, setUsdAmount] = useState<number | string>(0.5);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [editingAmount, setEditingAmount] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [confirmArmed, setConfirmArmed] = useState(false);
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: btcPrice } = useQuery({
|
||||
queryKey: ['btc-price', esploraApis],
|
||||
queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Convert the USD amount to sats for the actual Lightning payment.
|
||||
const amountSats = useMemo(() => {
|
||||
if (!btcPrice) return 0;
|
||||
const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
if (!Number.isFinite(usd) || usd <= 0) return 0;
|
||||
const btc = usd / btcPrice;
|
||||
return Math.round(btc * 100_000_000);
|
||||
}, [usdAmount, btcPrice]);
|
||||
|
||||
const isLarge = isLargeAmount(amountSats, btcPrice);
|
||||
// Lightning has no local balance concept (the wallet / LNURL handles that),
|
||||
// so `insufficient` stays false — kept for symmetry with the onchain props.
|
||||
const insufficient = false;
|
||||
|
||||
// Default tab: Bitcoin. Users can switch to Lightning if available.
|
||||
// If the user's signer can't sign PSBTs AND Lightning is available, we
|
||||
// transparently default to Lightning instead of showing an unusable
|
||||
// Bitcoin tab as the primary option.
|
||||
const { capability: btcCapability } = useBitcoinSigner();
|
||||
const hasLightning = canZap(author?.metadata);
|
||||
const bitcoinUnsupported = btcCapability === 'unsupported';
|
||||
const [activeTab, setActiveTab] = useState<'onchain' | 'lightning'>(
|
||||
bitcoinUnsupported && hasLightning ? 'lightning' : 'onchain',
|
||||
);
|
||||
|
||||
// Re-arm (clear confirmation) whenever the amount moves — editing after
|
||||
// arming forces another deliberate click. Mirrors OnchainZapContent.
|
||||
useEffect(() => {
|
||||
setConfirmArmed(false);
|
||||
}, [amountSats]);
|
||||
|
||||
// Focus + select-all when the amount is clicked into edit mode.
|
||||
useEffect(() => {
|
||||
if (editingAmount) {
|
||||
amountInputRef.current?.focus();
|
||||
amountInputRef.current?.select();
|
||||
}
|
||||
}, [editingAmount]);
|
||||
|
||||
const commitAmountEdit = useCallback(() => {
|
||||
setEditingAmount(false);
|
||||
if (typeof usdAmount === 'string' && usdAmount.trim() === '') {
|
||||
setUsdAmount(0);
|
||||
}
|
||||
}, [usdAmount]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (invoice) {
|
||||
await navigator.clipboard.writeText(invoice);
|
||||
setCopied(true);
|
||||
toast({
|
||||
title: 'Invoice copied',
|
||||
description: 'Lightning invoice copied to clipboard',
|
||||
});
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const openInWallet = () => {
|
||||
if (invoice) {
|
||||
openUrl(`lightning:${invoice}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setUsdAmount(0.5);
|
||||
setInvoice(null);
|
||||
setCopied(false);
|
||||
setEditingAmount(false);
|
||||
setError('');
|
||||
setConfirmArmed(false);
|
||||
setSuccess(null);
|
||||
setActiveTab(bitcoinUnsupported && hasLightning ? 'lightning' : 'onchain');
|
||||
} else {
|
||||
setUsdAmount(0.5);
|
||||
setInvoice(null);
|
||||
setCopied(false);
|
||||
setEditingAmount(false);
|
||||
setError('');
|
||||
setConfirmArmed(false);
|
||||
setSuccess(null);
|
||||
}
|
||||
// `bitcoinUnsupported`/`hasLightning` deliberately excluded — we only
|
||||
// want to reset the active tab on open/close, not on every capability
|
||||
// re-render. The mid-session flip is handled by the effect below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, setInvoice]);
|
||||
|
||||
// Previously, if Bitcoin capability flipped to `unsupported` mid-session we
|
||||
// auto-switched to Lightning because the Bitcoin tab was a dead-end. The
|
||||
// Bitcoin tab now shows a QR fallback for unsupported signers, so users
|
||||
// should be free to click into it. We only bias the *initial* tab choice
|
||||
// toward Lightning (above, in the useState initializer and the open-reset
|
||||
// effect); manual navigation into Bitcoin is respected.
|
||||
|
||||
const handleZap = () => {
|
||||
setError('');
|
||||
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
|
||||
if (amountSats <= 0) { setError('Enter an amount.'); return; }
|
||||
|
||||
// Two-tap safety for large amounts: first click arms, second click sends.
|
||||
if (isLarge && !confirmArmed) {
|
||||
setConfirmArmed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
impactMedium();
|
||||
zap(amountSats, '');
|
||||
};
|
||||
|
||||
const payWithWebLN = () => {
|
||||
if (amountSats > 0) {
|
||||
zap(amountSats, '');
|
||||
}
|
||||
};
|
||||
|
||||
const lightningContentProps: LightningZapContentProps = {
|
||||
invoice,
|
||||
usdAmount,
|
||||
amountSats,
|
||||
btcPrice,
|
||||
isZapping,
|
||||
copied,
|
||||
webln,
|
||||
insufficient,
|
||||
isLarge,
|
||||
confirmArmed,
|
||||
error,
|
||||
handleZap,
|
||||
handleCopy,
|
||||
openInWallet,
|
||||
setUsdAmount,
|
||||
setError,
|
||||
editingAmount,
|
||||
setEditingAmount,
|
||||
amountInputRef,
|
||||
commitAmountEdit,
|
||||
payWithWebLN,
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (user.pubkey === target.pubkey) {
|
||||
return (
|
||||
<div
|
||||
className={`cursor-pointer ${className || ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toast({
|
||||
title: 'Self-zaps are not supported',
|
||||
description: 'You cannot zap your own post.',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<div className={`cursor-pointer ${className || ''}`} onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[425px] rounded-2xl p-0 gap-0 border-border overflow-hidden max-h-[95vh] [&>button]:hidden" data-testid="zap-modal">
|
||||
<div className="flex items-center justify-between px-4 h-12">
|
||||
<DialogTitle className="text-base font-semibold flex items-center gap-1.5">
|
||||
{success
|
||||
? 'Success'
|
||||
: invoice
|
||||
? 'Lightning Payment'
|
||||
: 'Send Bitcoin'}{' '}
|
||||
{!success && (
|
||||
<HelpTip
|
||||
faqId={
|
||||
invoice || activeTab === 'lightning'
|
||||
? 'send-bitcoin-lightning'
|
||||
: 'send-bitcoin-onchain'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto">
|
||||
{success ? (
|
||||
<ZapSuccessScreen
|
||||
recipientPubkey={target.pubkey}
|
||||
amountSats={success.amountSats}
|
||||
btcPrice={btcPrice}
|
||||
txid={success.kind === 'onchain' ? success.txid : undefined}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
) : hasLightning ? (
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'onchain' | 'lightning')} className="w-full">
|
||||
<div className="px-4 pt-2">
|
||||
<TabsList className="grid w-full grid-cols-2 h-9">
|
||||
<TabsTrigger value="onchain" className="gap-1.5 text-xs">
|
||||
<Bitcoin className="size-3.5" /> Bitcoin
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="lightning" className="gap-1.5 text-xs">
|
||||
<Zap className="size-3.5" /> Lightning
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="onchain" className="mt-0">
|
||||
<OnchainZapContent
|
||||
target={target}
|
||||
onSuccess={({ txid, amountSats }) =>
|
||||
setSuccess({ kind: 'onchain', amountSats, txid })
|
||||
}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="lightning" className="mt-0">
|
||||
<LightningZapContent {...lightningContentProps} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<OnchainZapContent
|
||||
target={target}
|
||||
onSuccess={({ txid, amountSats }) =>
|
||||
setSuccess({ kind: 'onchain', amountSats, txid })
|
||||
}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Check, ExternalLink } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { satsToUSD } from '@/lib/bitcoin';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
||||
interface ZapSuccessScreenProps {
|
||||
/** Recipient pubkey (hex). Used to resolve the author avatar + name. */
|
||||
recipientPubkey: string;
|
||||
/** Amount sent in satoshis. */
|
||||
amountSats: number;
|
||||
/** Current BTC/USD price for display; optional, falls back to sats only. */
|
||||
btcPrice: number | undefined;
|
||||
/** Bitcoin txid (onchain only). Enables the "View transaction" link to the in-app tx detail page. */
|
||||
txid?: string;
|
||||
/** Close handler invoked by the "Done" button. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grand confirmation screen shown after a successful Bitcoin send in the
|
||||
* ZapDialog. Replaces the previous toast-and-auto-close behavior with a
|
||||
* dedicated celebration moment: animated checkmark, expanding halo, a
|
||||
* confetti-adjacent sparkle burst, the amount sent, the recipient, and
|
||||
* a "View transaction" shortcut when we have a txid on hand.
|
||||
*
|
||||
* Respects `prefers-reduced-motion`: the entrance animations collapse to a
|
||||
* simple fade and the sparkle burst is suppressed.
|
||||
*/
|
||||
export function ZapSuccessScreen({
|
||||
recipientPubkey,
|
||||
amountSats,
|
||||
btcPrice,
|
||||
txid,
|
||||
onClose,
|
||||
}: ZapSuccessScreenProps) {
|
||||
const { data: author } = useAuthor(recipientPubkey);
|
||||
const metadata = author?.metadata;
|
||||
const displayName = metadata?.name || metadata?.display_name || genUserName(recipientPubkey);
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
|
||||
const usdDisplay = useMemo(
|
||||
() => (btcPrice ? satsToUSD(amountSats, btcPrice) : ''),
|
||||
[amountSats, btcPrice],
|
||||
);
|
||||
|
||||
// Sparkle burst positions: 8 particles radiating outward from the
|
||||
// checkmark, each with a slightly offset delay so the burst reads organic
|
||||
// rather than synchronised.
|
||||
const sparkles = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 8 }, (_, i) => {
|
||||
const angle = (i / 8) * Math.PI * 2;
|
||||
const radius = 58;
|
||||
return {
|
||||
id: i,
|
||||
x: Math.cos(angle) * radius,
|
||||
y: Math.sin(angle) * radius,
|
||||
delay: 0.15 + (i % 4) * 0.05,
|
||||
hue: i % 2 === 0 ? 'bg-amber-400' : 'bg-orange-500',
|
||||
};
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="relative grid gap-5 px-6 py-8 w-full overflow-hidden text-center motion-safe:animate-success-fade-up"
|
||||
>
|
||||
{/* Soft radial glow behind the whole card. Pure decoration. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_35%,hsl(var(--primary)/0.18),transparent_65%)]"
|
||||
/>
|
||||
|
||||
{/* Check + halo + sparkles */}
|
||||
<div className="relative mx-auto flex size-28 items-center justify-center">
|
||||
{/* Expanding halo ring */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400/40 to-orange-500/30 motion-safe:animate-success-halo"
|
||||
/>
|
||||
|
||||
{/* Solid gradient disc */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 shadow-lg shadow-orange-500/30 motion-safe:animate-success-pop"
|
||||
/>
|
||||
|
||||
{/* Checkmark */}
|
||||
<Check
|
||||
className="relative size-14 text-white drop-shadow-sm motion-safe:animate-success-pop"
|
||||
strokeWidth={3}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Sparkle burst */}
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 motion-reduce:hidden">
|
||||
{sparkles.map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className={`absolute left-1/2 top-1/2 size-1.5 rounded-full ${s.hue} motion-safe:animate-success-spark`}
|
||||
style={
|
||||
{
|
||||
'--spark-x': `${s.x}px`,
|
||||
'--spark-y': `${s.y}px`,
|
||||
animationDelay: `${s.delay}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Headline + amount */}
|
||||
<div className="grid gap-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
Bitcoin sent
|
||||
</h2>
|
||||
<div className="text-4xl font-bold tabular-nums bg-gradient-to-br from-amber-500 to-orange-600 bg-clip-text text-transparent">
|
||||
{usdDisplay || `${amountSats.toLocaleString()} sats`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipient card */}
|
||||
<div className="mx-auto flex items-center gap-3 rounded-full border border-border/70 bg-muted/40 pl-2 pr-4 py-2 max-w-full">
|
||||
<Avatar shape={avatarShape} className="size-8 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 text-left">
|
||||
<div className="text-[11px] text-muted-foreground leading-tight">To</div>
|
||||
<div className="text-sm font-medium truncate max-w-[220px]">{displayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="grid gap-2">
|
||||
{txid && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
asChild
|
||||
className="w-full"
|
||||
>
|
||||
<Link to={`/i/bitcoin:tx:${txid}`} onClick={onClose}>
|
||||
<ExternalLink className="size-4 mr-2" />
|
||||
View transaction
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" onClick={onClose} className="w-full">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Activity, Bell, ChevronDown, LayoutDashboard, LogOut, MessageSquare, Search, Settings, UserIcon, UserPlus, Wallet } from 'lucide-react';
|
||||
import { Activity, Bell, ChevronDown, LayoutDashboard, LogOut, MessageSquare, Search, Settings, UserIcon, UserPlus } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -118,12 +118,6 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
||||
<span>{t('nav.search')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
|
||||
<Link to="/wallet">
|
||||
<Wallet className='w-4 h-4' />
|
||||
<span>{t('nav.wallet')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
|
||||
<Link to="/messages">
|
||||
<MessageSquare className='w-4 h-4' />
|
||||
|
||||
@@ -7,7 +7,6 @@ import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
|
||||
import { ModerationOverlay } from '@/components/moderation';
|
||||
import { PledgeCard, PledgeCardSkeleton } from '@/components/PledgeCard';
|
||||
import { parseAction, useActions, type Action } from '@/hooks/useActions';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
|
||||
@@ -62,7 +61,6 @@ export function PledgesDiscoverySection({
|
||||
}: PledgesDiscoverySectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
@@ -207,7 +205,6 @@ export function PledgesDiscoverySection({
|
||||
<PledgeCard
|
||||
key={`${action.pubkey}:${action.id}`}
|
||||
action={action}
|
||||
btcPrice={btcPrice}
|
||||
showAuthor
|
||||
showTranslate
|
||||
topRight={
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PaymentMode } from '@/lib/helpContent';
|
||||
|
||||
interface InlinePaymentBadgeProps {
|
||||
mode: PaymentMode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small inline pill that visually distinguishes the two payment options
|
||||
* a campaign can accept (public Bitcoin or silent payments) wherever
|
||||
* they're mentioned in guide copy or table headers.
|
||||
*
|
||||
* Public uses the project's primary accent (orange). Silent uses an
|
||||
* indigo tint so the two read as visually different at a glance without
|
||||
* either looking like a warning state.
|
||||
*/
|
||||
export function InlinePaymentBadge({ mode, className }: InlinePaymentBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const label = t(`guides.shared.paymentBadge.${mode}`);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold leading-none',
|
||||
mode === 'public'
|
||||
? 'bg-primary/15 text-primary border border-primary/30'
|
||||
: 'bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-500/30',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { Bell, Bug, Eye, EyeOff, Gauge, ShieldCheck, Sparkles, Wallet } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { renderInlineMarkup } from '@/lib/helpMarkup';
|
||||
import { InlinePaymentBadge } from './InlinePaymentBadge';
|
||||
import type { GuidePaymentComparisonBlock } from '@/lib/helpContent';
|
||||
|
||||
interface Row {
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
public: string;
|
||||
silent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row IDs and icons. Strings live in `guides.shared.paymentComparison.*`
|
||||
* in the locale files, keyed by the `id` below. Keeping icons in code
|
||||
* lets translators ignore the visual layer entirely.
|
||||
*/
|
||||
const DONOR_ROW_IDS: { id: string; Icon: LucideIcon }[] = [
|
||||
{ id: 'whatYouSee', Icon: Eye },
|
||||
{ id: 'walletSupport', Icon: Wallet },
|
||||
{ id: 'privacy', Icon: ShieldCheck },
|
||||
{ id: 'settlement', Icon: Gauge },
|
||||
];
|
||||
|
||||
const RECIPIENT_ROW_IDS: { id: string; Icon: LucideIcon }[] = [
|
||||
{ id: 'whatDonorsSee', Icon: Sparkles },
|
||||
{ id: 'receivingSpeed', Icon: Gauge },
|
||||
{ id: 'pushNotifications', Icon: Bell },
|
||||
{ id: 'donorList', Icon: Eye },
|
||||
{ id: 'ecosystem', Icon: Bug },
|
||||
{ id: 'bestFor', Icon: ShieldCheck },
|
||||
{ id: 'watchOutFor', Icon: EyeOff },
|
||||
];
|
||||
|
||||
/**
|
||||
* Side-by-side comparison of Public Payments vs. Silent Payments.
|
||||
*
|
||||
* - Desktop (`sm:` and up): three-column grid with row labels on the
|
||||
* left, Public tinted in primary, Silent tinted in indigo.
|
||||
* - Mobile: collapses to two stacked tinted cards (one per option) with
|
||||
* the same row labels inside each card. No sideways scrolling.
|
||||
*
|
||||
* Row content is driven by the `audience` flag so donors and recipients
|
||||
* get row copy tuned to what they care about. All strings are read from
|
||||
* i18n keyed by audience-specific row IDs in `helpContent.ts`'s
|
||||
* structural template.
|
||||
*/
|
||||
export function PaymentComparisonTable({
|
||||
block,
|
||||
}: {
|
||||
block: GuidePaymentComparisonBlock;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowIds = block.audience === 'donor' ? DONOR_ROW_IDS : RECIPIENT_ROW_IDS;
|
||||
const audienceKey = block.audience === 'donor' ? 'donorRows' : 'recipientRows';
|
||||
|
||||
const rows: Row[] = rowIds.map(({ id, Icon }) => ({
|
||||
label: t(`guides.shared.paymentComparison.${audienceKey}.${id}.label`),
|
||||
Icon,
|
||||
public: t(`guides.shared.paymentComparison.${audienceKey}.${id}.public`),
|
||||
silent: t(`guides.shared.paymentComparison.${audienceKey}.${id}.silent`),
|
||||
}));
|
||||
|
||||
const headerText = t(
|
||||
block.audience === 'donor'
|
||||
? 'guides.shared.paymentComparison.donorHeader'
|
||||
: 'guides.shared.paymentComparison.recipientHeader',
|
||||
);
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* ── Desktop: aligned 3-column grid ──────────────────────────── */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
{/* Header row */}
|
||||
<div className="grid grid-cols-[1.1fr_1fr_1fr] bg-secondary/40 border-b">
|
||||
<div className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{headerText}
|
||||
</div>
|
||||
<div className="px-4 py-3 border-l">
|
||||
<InlinePaymentBadge mode="public" />
|
||||
</div>
|
||||
<div className="px-4 py-3 border-l">
|
||||
<InlinePaymentBadge mode="silent" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Body rows */}
|
||||
{rows.map((row, i) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={cn(
|
||||
'grid grid-cols-[1.1fr_1fr_1fr]',
|
||||
i < rows.length - 1 && 'border-b',
|
||||
)}
|
||||
>
|
||||
<div className="px-4 py-3 flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<row.Icon className="size-4 text-muted-foreground shrink-0" />
|
||||
{row.label}
|
||||
</div>
|
||||
<div className="px-4 py-3 border-l text-sm text-foreground/85 leading-snug">
|
||||
{renderInlineMarkup(row.public)}
|
||||
</div>
|
||||
<div className="px-4 py-3 border-l text-sm text-foreground/85 leading-snug">
|
||||
{renderInlineMarkup(row.silent)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mobile: two stacked tinted cards ───────────────────────── */}
|
||||
<div className="grid gap-3 sm:hidden">
|
||||
<PaymentStack mode="public" rows={rows} />
|
||||
<PaymentStack mode="silent" rows={rows} />
|
||||
</div>
|
||||
|
||||
{block.footnote && (
|
||||
<p className="text-xs text-muted-foreground mt-3 leading-relaxed">
|
||||
{renderInlineMarkup(block.footnote)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentStack({
|
||||
mode,
|
||||
rows,
|
||||
}: {
|
||||
mode: 'public' | 'silent';
|
||||
rows: Row[];
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border overflow-hidden',
|
||||
mode === 'public'
|
||||
? 'border-primary/30 bg-primary/[0.04]'
|
||||
: 'border-indigo-500/30 bg-indigo-500/[0.04]',
|
||||
)}
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-inherit">
|
||||
<InlinePaymentBadge mode={mode} />
|
||||
</div>
|
||||
<dl className="divide-y divide-border/60">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="px-4 py-3">
|
||||
<dt className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<row.Icon className="size-3.5 shrink-0" />
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-foreground/85 leading-snug">
|
||||
{renderInlineMarkup(mode === 'public' ? row.public : row.silent)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,4 +9,3 @@ export { GuideProse } from './GuideProse';
|
||||
export { GuideSteps } from './GuideSteps';
|
||||
export { GuideTLDR } from './GuideTLDR';
|
||||
export { OptionGrid } from './OptionGrid';
|
||||
export { PaymentComparisonTable } from './PaymentComparisonTable';
|
||||
|
||||
@@ -149,11 +149,11 @@ export function VerifyTutorial({
|
||||
* fields directly instead of fetching the event.
|
||||
*/
|
||||
const DEMO_CAMPAIGN = {
|
||||
title: 'Agora App Development Fund',
|
||||
title: 'Eranos App Development Fund',
|
||||
organizer: 'Team Soapbox',
|
||||
organizerPicture:
|
||||
'https://blossom.primal.net/e93f617f8331509acdddde3df0c1cd23cda1803d92c70815fc96e2d5f8d48ac8.png',
|
||||
story: 'Help fund the development of Agora!',
|
||||
story: 'Help fund the development of Eranos!',
|
||||
banner:
|
||||
'https://blossom.primal.net/aade02e86584a7ab269550992d0266bae31059a34e6e08fddba1f6f5acb6e7d6.jpg',
|
||||
goalLabel: '$1,000',
|
||||
|
||||
@@ -15,12 +15,6 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { BioContent } from '@/components/BioContent';
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
@@ -34,7 +28,6 @@ import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useProfileOrganizations, type ProfileOrganization } from '@/hooks/useProfileOrganizations';
|
||||
import type { ProfileCampaignStats } from '@/hooks/useProfileCampaignStats';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -73,8 +66,6 @@ interface ProfileIdentityRailProps {
|
||||
* newest by `createdAt` itself, so callers can pass the unsorted list.
|
||||
*/
|
||||
pledges: Action[];
|
||||
/** Spot BTC price for the Raised stat row. */
|
||||
btcPrice: number | undefined;
|
||||
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
@@ -88,7 +79,6 @@ interface ProfileIdentityRailProps {
|
||||
onFollowQROpen: () => void;
|
||||
onToggleFollow: () => void;
|
||||
onTabChange: (tabId: string) => void;
|
||||
onDonate: (campaign: ParsedCampaign) => void;
|
||||
/** Whether the viewer can take any action (logged in). Disables follow when null. */
|
||||
canFollow: boolean;
|
||||
}
|
||||
@@ -127,7 +117,6 @@ export function ProfileIdentityRail({
|
||||
campaigns,
|
||||
campaignStats,
|
||||
pledges,
|
||||
btcPrice,
|
||||
followersCount,
|
||||
followingCount,
|
||||
isFollowing,
|
||||
@@ -139,7 +128,6 @@ export function ProfileIdentityRail({
|
||||
onFollowQROpen,
|
||||
onToggleFollow,
|
||||
onTabChange,
|
||||
onDonate,
|
||||
canFollow,
|
||||
}: ProfileIdentityRailProps) {
|
||||
if (isAuthorLoading) {
|
||||
@@ -156,8 +144,6 @@ export function ProfileIdentityRail({
|
||||
return sanitizeUrl(candidate);
|
||||
})();
|
||||
|
||||
const onchainCampaigns = campaigns.filter((c) => !!c.wallets?.onchain);
|
||||
|
||||
return (
|
||||
// Two-layer structure so the rail can scroll independently on lg+
|
||||
// without clipping the avatar that pokes above the rail's top edge:
|
||||
@@ -193,16 +179,11 @@ export function ProfileIdentityRail({
|
||||
canFollow={canFollow}
|
||||
followersCount={followersCount}
|
||||
followingCount={followingCount}
|
||||
totalRaisedSats={campaignStats.totalRaisedSats}
|
||||
btcPrice={btcPrice}
|
||||
onchainCampaigns={onchainCampaigns}
|
||||
onToggleFollow={onToggleFollow}
|
||||
onMoreMenuOpen={onMoreMenuOpen}
|
||||
onFollowQROpen={onFollowQROpen}
|
||||
onDonate={onDonate}
|
||||
onFollowersOpen={onFollowersOpen}
|
||||
onFollowingOpen={onFollowingOpen}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
<ProfileOverviewSections
|
||||
pubkey={pubkey}
|
||||
@@ -210,7 +191,6 @@ export function ProfileIdentityRail({
|
||||
campaigns={campaigns}
|
||||
campaignStats={campaignStats}
|
||||
pledges={pledges}
|
||||
btcPrice={btcPrice}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
</div>
|
||||
@@ -235,20 +215,15 @@ interface ProfileIdentityHeaderProps {
|
||||
canFollow: boolean;
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
totalRaisedSats: number;
|
||||
btcPrice: number | undefined;
|
||||
onchainCampaigns: ParsedCampaign[];
|
||||
onToggleFollow: () => void;
|
||||
onMoreMenuOpen: () => void;
|
||||
onFollowQROpen: () => void;
|
||||
onDonate: (campaign: ParsedCampaign) => void;
|
||||
onFollowersOpen: () => void;
|
||||
onFollowingOpen: () => void;
|
||||
onTabChange: (tabId: string) => void;
|
||||
className?: string;
|
||||
/**
|
||||
* Suppress the internal action bar (Edit Profile / QR / more, or
|
||||
* Follow / Donate). The mobile layout sets this and renders its own
|
||||
* Follow). The mobile layout sets this and renders its own
|
||||
* `ActionBar` on the avatar row so the buttons sit top-right beside the
|
||||
* avatar, Twitter/X-style, instead of in a full-width row below the bio.
|
||||
*/
|
||||
@@ -277,16 +252,11 @@ export function ProfileIdentityHeader({
|
||||
canFollow,
|
||||
followersCount,
|
||||
followingCount,
|
||||
totalRaisedSats,
|
||||
btcPrice,
|
||||
onchainCampaigns,
|
||||
onToggleFollow,
|
||||
onMoreMenuOpen,
|
||||
onFollowQROpen,
|
||||
onDonate,
|
||||
onFollowersOpen,
|
||||
onFollowingOpen,
|
||||
onTabChange,
|
||||
className,
|
||||
hideActionBar = false,
|
||||
}: ProfileIdentityHeaderProps) {
|
||||
@@ -319,11 +289,8 @@ export function ProfileIdentityHeader({
|
||||
<StatList
|
||||
followersCount={followersCount}
|
||||
followingCount={followingCount}
|
||||
totalRaisedSats={totalRaisedSats}
|
||||
btcPrice={btcPrice}
|
||||
onFollowersOpen={onFollowersOpen}
|
||||
onFollowingOpen={onFollowingOpen}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
{metadata?.about && (
|
||||
<p className="pt-1 text-sm whitespace-pre-wrap break-words text-foreground/90">
|
||||
@@ -350,8 +317,6 @@ export function ProfileIdentityHeader({
|
||||
onToggleFollow={onToggleFollow}
|
||||
onMoreMenuOpen={onMoreMenuOpen}
|
||||
onFollowQROpen={onFollowQROpen}
|
||||
onchainCampaigns={onchainCampaigns}
|
||||
onDonate={onDonate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -366,7 +331,6 @@ interface ProfileOverviewSectionsProps {
|
||||
campaigns: ParsedCampaign[];
|
||||
campaignStats: ProfileCampaignStats;
|
||||
pledges: Action[];
|
||||
btcPrice: number | undefined;
|
||||
onTabChange: (tabId: string) => void;
|
||||
/** Render the Organizations grid inline (default true). Set false on
|
||||
* mobile when "Community" is a dedicated tab and orgs should not also
|
||||
@@ -390,7 +354,6 @@ export function ProfileOverviewSections({
|
||||
campaigns,
|
||||
campaignStats,
|
||||
pledges,
|
||||
btcPrice,
|
||||
onTabChange,
|
||||
showOrganizations = true,
|
||||
className,
|
||||
@@ -411,7 +374,6 @@ export function ProfileOverviewSections({
|
||||
{campaigns.length === 0 && pledges.length > 0 && (
|
||||
<RailLatestPledgeSection
|
||||
pledges={pledges}
|
||||
btcPrice={btcPrice}
|
||||
showSeeAll={pledges.length > 1}
|
||||
onSeeAll={() => onTabChange('pledges')}
|
||||
/>
|
||||
@@ -520,8 +482,6 @@ export function ActionBar({
|
||||
onToggleFollow,
|
||||
onMoreMenuOpen,
|
||||
onFollowQROpen,
|
||||
onchainCampaigns,
|
||||
onDonate,
|
||||
align = 'start',
|
||||
}: {
|
||||
pubkey: string;
|
||||
@@ -532,8 +492,6 @@ export function ActionBar({
|
||||
onToggleFollow: () => void;
|
||||
onMoreMenuOpen: () => void;
|
||||
onFollowQROpen: () => void;
|
||||
onchainCampaigns: ParsedCampaign[];
|
||||
onDonate: (campaign: ParsedCampaign) => void;
|
||||
/**
|
||||
* `start` (default) — the primary button stretches to fill the row,
|
||||
* matching the narrow desktop rail. `end` — buttons size to their
|
||||
@@ -592,40 +550,6 @@ export function ActionBar({
|
||||
onClick={onToggleFollow}
|
||||
disabled={!canFollow}
|
||||
/>
|
||||
{onchainCampaigns.length === 1 ? (
|
||||
<Button
|
||||
onClick={() => onDonate(onchainCampaigns[0])}
|
||||
className="rounded-full font-bold gap-1.5"
|
||||
>
|
||||
<HandHeart className="size-4" />
|
||||
{t('profile.header.donate')}
|
||||
</Button>
|
||||
) : onchainCampaigns.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="rounded-full font-bold gap-1.5">
|
||||
<HandHeart className="size-4" />
|
||||
{t('profile.header.donate')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
{onchainCampaigns.map((c) => (
|
||||
<DropdownMenuItem
|
||||
key={c.aTag}
|
||||
onClick={() => onDonate(c)}
|
||||
className="flex flex-col items-start gap-0.5"
|
||||
>
|
||||
<span className="font-medium truncate w-full">{c.title}</span>
|
||||
{c.goalUsd ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('profile.header.campaignGoal', { amount: c.goalUsd.toLocaleString() })}
|
||||
</span>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -657,39 +581,21 @@ export function ActionBar({
|
||||
function StatList({
|
||||
followersCount,
|
||||
followingCount,
|
||||
totalRaisedSats,
|
||||
btcPrice,
|
||||
onFollowersOpen,
|
||||
onFollowingOpen,
|
||||
onTabChange,
|
||||
}: {
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
totalRaisedSats: number;
|
||||
btcPrice: number | undefined;
|
||||
onFollowersOpen: () => void;
|
||||
onFollowingOpen: () => void;
|
||||
onTabChange: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hasRaised = totalRaisedSats > 0;
|
||||
const hasStats = hasRaised || followersCount > 0 || followingCount > 0;
|
||||
const hasStats = followersCount > 0 || followingCount > 0;
|
||||
if (!hasStats) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
|
||||
{hasRaised && (
|
||||
<button
|
||||
onClick={() => onTabChange('agora')}
|
||||
className="flex items-baseline gap-1.5 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span className="font-bold tabular-nums text-primary">
|
||||
{formatCampaignAmount(totalRaisedSats, btcPrice)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{t('profile.stats.raised')}</span>
|
||||
</button>
|
||||
)}
|
||||
{followersCount > 0 && (
|
||||
<button
|
||||
onClick={onFollowersOpen}
|
||||
@@ -783,12 +689,10 @@ function RailCampaignsSection({
|
||||
*/
|
||||
function RailLatestPledgeSection({
|
||||
pledges,
|
||||
btcPrice,
|
||||
showSeeAll,
|
||||
onSeeAll,
|
||||
}: {
|
||||
pledges: Action[];
|
||||
btcPrice: number | undefined;
|
||||
showSeeAll: boolean;
|
||||
onSeeAll: () => void;
|
||||
}) {
|
||||
@@ -805,7 +709,7 @@ function RailLatestPledgeSection({
|
||||
icon={<HandHeart className="size-4 text-primary" />}
|
||||
title={t('profile.sections.latestPledge')}
|
||||
/>
|
||||
<PledgeCard action={latest} btcPrice={btcPrice} variant="rail" />
|
||||
<PledgeCard action={latest} variant="rail" />
|
||||
{showSeeAll && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -13,8 +13,6 @@ interface ProfilePledgesTabProps {
|
||||
isOwnProfile: boolean;
|
||||
/** Pledges authored by this pubkey. Already filtered upstream. */
|
||||
pledges: Action[];
|
||||
/** BTC price for sats↔USD conversion in pledge amount labels. */
|
||||
btcPrice: number | undefined;
|
||||
/** True while the underlying useActions() query is still in flight. */
|
||||
isLoading: boolean;
|
||||
}
|
||||
@@ -24,14 +22,13 @@ interface ProfilePledgesTabProps {
|
||||
* mirrors the `/pledges` (`ActionsPage`) directory styling.
|
||||
*
|
||||
* v1 scope per the design plan: pledges *created* by the user.
|
||||
* "Pledges backed" (zapped submissions on others' pledges) is deferred to v2.
|
||||
* "Pledges backed" (backed submissions on others' pledges) is deferred to v2.
|
||||
*/
|
||||
export function ProfilePledgesTab({
|
||||
pubkey,
|
||||
displayName,
|
||||
isOwnProfile,
|
||||
pledges,
|
||||
btcPrice,
|
||||
isLoading,
|
||||
}: ProfilePledgesTabProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -90,7 +87,7 @@ export function ProfilePledgesTab({
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{active.map((pledge) => (
|
||||
<PledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} />
|
||||
<PledgeCard key={pledge.event.id} action={pledge} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -103,7 +100,7 @@ export function ProfilePledgesTab({
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{ended.map((pledge) => (
|
||||
<PledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} isExpired />
|
||||
<PledgeCard key={pledge.event.id} action={pledge} isExpired />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -10,11 +10,11 @@ _آخر تحديث: 19 مارس 2026_
|
||||
|
||||
{{appName}} هو **تطبيق عميل** لبروتوكول Nostr، وهو شبكة اتصالات مفتوحة ولامركزية. فهم البنية المعمارية سياق مهم لهذه السياسة:
|
||||
|
||||
- **بنيتنا التحتية:** نُشغّل **مرحّل Agora** و**خادم Agora Blossom**، اللذين يُستخدمان كمرحّل ومستضيف للملفات الافتراضي في {{appName}}. ولدينا تحكم كامل في الإشراف على المحتوى المُخزَّن على هذه الخدمات.
|
||||
- **بنيتنا التحتية:** نُشغّل **مرحّل Eranos** و**خادم Eranos Blossom**، اللذين يُستخدمان كمرحّل ومستضيف للملفات الافتراضي في {{appName}}. ولدينا تحكم كامل في الإشراف على المحتوى المُخزَّن على هذه الخدمات.
|
||||
- **المرحّلات التابعة لأطراف ثالثة:** قد يتصل المستخدمون أيضًا بمرحّلات Nostr إضافية تُشغّلها أطراف ثالثة مستقلة. ويقوم {{appName}} بجلب المحتوى وعرضه من أي مرحّلات يتصل بها المستخدم. لا نملك تحكمًا إشرافيًا على المرحّلات التابعة لأطراف ثالثة، لكننا نتحكم فيما يعرضه التطبيق.
|
||||
- **خوادم الوسائط التابعة لأطراف ثالثة:** قد يرفع المستخدمون الصور ومقاطع الفيديو إلى خوادم ملفات متوافقة مع Blossom تابعة لأطراف ثالثة. ولا نُشغّل هذه الخدمات الخارجية ولا نُشرف عليها.
|
||||
|
||||
نتحمل المسؤولية الكاملة عن التجربة داخل تطبيقنا. وعلى بنيتنا التحتية الخاصة (مرحّل Agora وخادم Agora Blossom) يمكننا إزالة المحتوى وحظر الحسابات المخالفة مباشرة. أما المحتوى الذي مصدره خدمات تابعة لأطراف ثالثة، فنُكافحه بنشاط ونمنع عرضه داخل {{appName}}.
|
||||
نتحمل المسؤولية الكاملة عن التجربة داخل تطبيقنا. وعلى بنيتنا التحتية الخاصة (مرحّل Eranos وخادم Eranos Blossom) يمكننا إزالة المحتوى وحظر الحسابات المخالفة مباشرة. أما المحتوى الذي مصدره خدمات تابعة لأطراف ثالثة، فنُكافحه بنشاط ونمنع عرضه داخل {{appName}}.
|
||||
|
||||
## المحتوى والسلوكيات المحظورة
|
||||
|
||||
@@ -33,8 +33,8 @@ _آخر تحديث: 19 مارس 2026_
|
||||
|
||||
- **تصفية المحتوى:** نحتفظ بآليات تصفية محتوى داخل التطبيق ونُلزم بها، لمنع عرض مواد CSAE المعروفة بصرف النظر عن المرحّل الذي تأتي منه.
|
||||
- **بلاغات المستخدمين:** نوفر أدوات إبلاغ داخل التطبيق تسمح للمستخدمين بالإشارة إلى المحتوى المشتبه به للمراجعة الفورية.
|
||||
- **الإشراف على مرحّل Agora:** على مرحّلنا الخاص Agora، نُشرف بنشاط على المحتوى وسنزيل فورًا أي مواد CSAE ونحظر الحسابات المرتبطة بها نهائيًا.
|
||||
- **الإشراف على خادم Agora Blossom:** على خادم ملفات Agora Blossom الخاص بنا، سنحذف فورًا أي وسائط CSAE ونحظر الحساب الذي قام بالرفع.
|
||||
- **الإشراف على مرحّل Eranos:** على مرحّلنا الخاص Eranos، نُشرف بنشاط على المحتوى وسنزيل فورًا أي مواد CSAE ونحظر الحسابات المرتبطة بها نهائيًا.
|
||||
- **الإشراف على خادم Eranos Blossom:** على خادم ملفات Eranos Blossom الخاص بنا، سنحذف فورًا أي وسائط CSAE ونحظر الحساب الذي قام بالرفع.
|
||||
- **حجب المرحّلات التابعة لأطراف ثالثة:** يجوز إزالة المرحّلات التابعة لأطراف ثالثة المعروفة باستضافة مواد CSAE أو التساهل معها من قائمة المرحّلات الافتراضية في {{appName}} ومنع المستخدمين من إضافتها.
|
||||
- **أدوات الكتم والحجب:** يمكن للمستخدمين كتم الحسابات أو حجبها على مستوى العميل لمنع ظهور محتوى تلك الحسابات في خلاصاتهم.
|
||||
|
||||
@@ -43,7 +43,7 @@ _آخر تحديث: 19 مارس 2026_
|
||||
عند تحديد محتوى CSAE أو سلوك مرتبط به، سيتخذ {{appName}} الإجراءات التالية حسب الاقتضاء:
|
||||
|
||||
- **حجب المحتوى الفوري:** سيتم حجب محتوى CSAE المعروف من العرض في التطبيق عبر مرشحات المحتوى وقوائم الحظر.
|
||||
- **الإزالة من بنية Agora التحتية:** سيُحذف محتوى CSAE الموجود على مرحّل Agora وخادم Agora Blossom فورًا، وستُحظر الحسابات المرتبطة بشكل دائم.
|
||||
- **الإزالة من بنية Eranos التحتية:** سيُحذف محتوى CSAE الموجود على مرحّل Eranos وخادم Eranos Blossom فورًا، وستُحظر الحسابات المرتبطة بشكل دائم.
|
||||
- **حظر الحسابات:** ستُضاف مفاتيح Nostr العامة المرتبطة بنشاط CSAE إلى قوائم الحظر على مستوى التطبيق، مما يمنع ظهور محتواها في {{appName}} بغض النظر عن المرحّل الذي يجلبها.
|
||||
- **حظر المرحّلات:** قد تتم إزالة المرحّلات التابعة لأطراف ثالثة التي لا تعالج محتوى CSAE من قائمة المرحّلات الافتراضية في {{appName}}، وحظر المستخدمين من إضافتها.
|
||||
- **الإبلاغ للسلطات:** سنُبلّغ عن مواد CSAE التي يتم تحديدها إلى [المركز الوطني للأطفال المفقودين والمستغلين (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) عبر CyberTipline، وإلى أجهزة إنفاذ القانون المختصة.
|
||||
@@ -63,7 +63,7 @@ _آخر تحديث: 19 مارس 2026_
|
||||
|
||||
يلتزم {{appName}} بالتعاون التام مع أجهزة إنفاذ القانون التي تحقق في حالات CSAE. وعلى الرغم من أن {{appName}} لا يُخزّن محتوى المستخدمين على خوادمه الخاصة، فإننا سنقوم بما يلي:
|
||||
|
||||
- تقديم أي معلومات متاحة لدينا — بما في ذلك بيانات من مرحّل Agora وخادم Agora Blossom — قد تساعد في التحقيقات، وفقًا للقانون المعمول به.
|
||||
- تقديم أي معلومات متاحة لدينا — بما في ذلك بيانات من مرحّل Eranos وخادم Eranos Blossom — قد تساعد في التحقيقات، وفقًا للقانون المعمول به.
|
||||
- تحديد ومشاركة عناوين URL المحددة للمرحّلات وخوادم الملفات حيث رُصد المحتوى المخالف، حتى تتمكن جهات إنفاذ القانون من التواصل مع المشغّلين مباشرة.
|
||||
- الحفاظ على أي أدلة أو معلومات متاحة عند استلام طلب قانوني صحيح.
|
||||
- الإبلاغ بشكل استباقي عن مواد CSAE التي يتم تحديدها إلى NCMEC والجهات المعنية الأخرى.
|
||||
@@ -72,7 +72,7 @@ _آخر تحديث: 19 مارس 2026_
|
||||
|
||||
تعني الطبيعة اللامركزية لـ Nostr أنه لا توجد جهة واحدة تمتلك تحكمًا كاملًا في كل المحتوى الموجود على الشبكة. ويُقرّ {{appName}} بالحقائق التالية ونهجنا تجاه كل منها:
|
||||
|
||||
- **تحكم كامل في بنيتنا التحتية الخاصة:** يمكننا — ونقوم بالفعل — إزالة المحتوى من مرحّل Agora وخادم Agora Blossom. وتُحذف مواد CSAE المكتشفة على بنيتنا التحتية فورًا، وتُحظر الحسابات بشكل دائم.
|
||||
- **تحكم كامل في بنيتنا التحتية الخاصة:** يمكننا — ونقوم بالفعل — إزالة المحتوى من مرحّل Eranos وخادم Eranos Blossom. وتُحذف مواد CSAE المكتشفة على بنيتنا التحتية فورًا، وتُحظر الحسابات بشكل دائم.
|
||||
- **تحكم محدود في المرحّلات التابعة لأطراف ثالثة:** لا يمكننا حذف المحتوى من المرحّلات التابعة لأطراف ثالثة. ومع ذلك، نحجب عرض هذا المحتوى داخل تطبيقنا عبر مرشحات وقوائم حظر على مستوى العميل.
|
||||
- **يتحكم المستخدمون باتصالاتهم بالمرحّلات:** بينما يستطيع المستخدمون الاتصال بالمرحّلات التي يختارونها، يحتفظ {{appName}} بالحق في حجب الاتصالات بالمرحّلات المعروفة باستضافة محتوى CSAE.
|
||||
- **المفاتيح العامة مستعارة:** تُحدَّد حسابات Nostr بأزواج مفاتيح تشفيرية بدلاً من هويات موثقة. ومع ذلك، سنواصل حظر المفاتيح المخالفة والإبلاغ عنها والتعاون مع جهات إنفاذ القانون لتحديد الأفراد الذين يقفون وراءها.
|
||||
|
||||
@@ -10,11 +10,11 @@ This policy applies to all content accessible through {{appName}}, including tex
|
||||
|
||||
{{appName}} is a **client application** for the Nostr protocol, an open, decentralized communication network. Understanding the architecture is important context for this policy:
|
||||
|
||||
- **Our infrastructure:** We operate the **Agora relay** and **Agora Blossom server**, which serve as the default relay and file host for {{appName}}. We have full moderation control over content stored on these services.
|
||||
- **Our infrastructure:** We operate the **Eranos relay** and **Eranos Blossom server**, which serve as the default relay and file host for {{appName}}. We have full moderation control over content stored on these services.
|
||||
- **Third-party relays:** Users may also connect to additional Nostr relays operated by independent third parties. {{appName}} fetches and renders content from whatever relays the user is connected to. We do not have moderation control over third-party relays, but we control what the app displays.
|
||||
- **Third-party media servers:** Users may upload images and videos to third-party Blossom-compatible file servers. We do not operate or moderate these external services.
|
||||
|
||||
We take full responsibility for the experience within our app. On our own infrastructure (Agora relay and Agora Blossom server), we can directly remove content and ban offending accounts. For content originating from third-party services, we actively block it from being displayed within {{appName}}.
|
||||
We take full responsibility for the experience within our app. On our own infrastructure (Eranos relay and Eranos Blossom server), we can directly remove content and ban offending accounts. For content originating from third-party services, we actively block it from being displayed within {{appName}}.
|
||||
|
||||
## Prohibited Content and Behavior
|
||||
|
||||
@@ -33,8 +33,8 @@ The following is strictly prohibited on {{appName}}. Users found engaging in any
|
||||
|
||||
- **Content filtering:** We maintain and enforce content filtering mechanisms within the app to block known CSAE material from being displayed, regardless of which relay it originates from.
|
||||
- **User reporting:** We provide in-app reporting tools that allow users to flag suspected CSAE content for immediate review.
|
||||
- **Agora relay moderation:** On our own Agora relay, we actively moderate content and will immediately remove any CSAE material and permanently ban associated accounts.
|
||||
- **Agora Blossom server moderation:** On our own Agora Blossom file server, we will immediately delete any CSAE media and ban the uploading account.
|
||||
- **Eranos relay moderation:** On our own Eranos relay, we actively moderate content and will immediately remove any CSAE material and permanently ban associated accounts.
|
||||
- **Eranos Blossom server moderation:** On our own Eranos Blossom file server, we will immediately delete any CSAE media and ban the uploading account.
|
||||
- **Third-party relay blocking:** Third-party relays known to host or tolerate CSAE material may be removed from {{appName}}'s default relay list and blocked from being added by users.
|
||||
- **Mute and block tools:** Users can mute or block accounts at the client level, preventing content from those accounts from appearing in their feed.
|
||||
|
||||
@@ -43,7 +43,7 @@ The following is strictly prohibited on {{appName}}. Users found engaging in any
|
||||
When CSAE content or behavior is identified, {{appName}} will take the following actions as applicable:
|
||||
|
||||
- **Immediate content blocking:** Known CSAE content will be blocked from rendering in the app through content filters and blocklists.
|
||||
- **Removal from Agora infrastructure:** CSAE content on the Agora relay and Agora Blossom server will be immediately deleted, and the associated accounts permanently banned.
|
||||
- **Removal from Eranos infrastructure:** CSAE content on the Eranos relay and Eranos Blossom server will be immediately deleted, and the associated accounts permanently banned.
|
||||
- **Account blocking:** Nostr public keys associated with CSAE activity will be added to app-level blocklists, preventing their content from appearing in {{appName}} regardless of which relay it is fetched from.
|
||||
- **Relay blocking:** Third-party relays that fail to address CSAE content may be removed from {{appName}}'s default relay list and blocked from being added by users.
|
||||
- **Reporting to authorities:** We will report identified CSAE material to the [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) via the CyberTipline, and to applicable law enforcement agencies.
|
||||
@@ -63,7 +63,7 @@ All reports of CSAE content are treated with the highest priority and will be re
|
||||
|
||||
{{appName}} is committed to cooperating fully with law enforcement agencies investigating CSAE. While {{appName}} does not store user content on its own servers, we will:
|
||||
|
||||
- Provide any information available to us — including data from the Agora relay and Agora Blossom server — that may assist in investigations, in accordance with applicable law.
|
||||
- Provide any information available to us — including data from the Eranos relay and Eranos Blossom server — that may assist in investigations, in accordance with applicable law.
|
||||
- Identify and share the specific relay URLs and file server URLs where offending content was observed, so law enforcement can contact those operators directly.
|
||||
- Preserve any available evidence or information upon receiving a valid legal request.
|
||||
- Report identified CSAE material to NCMEC and other relevant authorities proactively.
|
||||
@@ -72,7 +72,7 @@ All reports of CSAE content are treated with the highest priority and will be re
|
||||
|
||||
Nostr's decentralized nature means that no single entity has complete control over all content on the network. {{appName}} acknowledges the following realities and our approach to each:
|
||||
|
||||
- **Full control over our own infrastructure:** We can and do remove content from the Agora relay and Agora Blossom server. CSAE material found on our infrastructure is deleted immediately and accounts are permanently banned.
|
||||
- **Full control over our own infrastructure:** We can and do remove content from the Eranos relay and Eranos Blossom server. CSAE material found on our infrastructure is deleted immediately and accounts are permanently banned.
|
||||
- **Limited control over third-party relays:** We cannot delete content from third-party relays. However, we block such content from being displayed within our app through client-level filters and blocklists.
|
||||
- **Users control their relay connections:** While users can connect to relays of their choice, {{appName}} reserves the right to block connections to relays known to host CSAE content.
|
||||
- **Public keys are pseudonymous:** Nostr accounts are identified by cryptographic key pairs rather than verified identities. We will still block and report offending keys and cooperate with law enforcement to identify individuals behind them.
|
||||
|
||||
@@ -10,11 +10,11 @@ Esta política se aplica a todo el contenido accesible a través de {{appName}},
|
||||
|
||||
{{appName}} es una **aplicación cliente** para el protocolo Nostr, una red de comunicación abierta y descentralizada. Comprender la arquitectura es un contexto importante para esta política:
|
||||
|
||||
- **Nuestra infraestructura:** Operamos el **relé Agora** y el **servidor Agora Blossom**, que sirven como relé y servidor de archivos predeterminados para {{appName}}. Tenemos control de moderación total sobre el contenido almacenado en estos servicios.
|
||||
- **Nuestra infraestructura:** Operamos el **relé Eranos** y el **servidor Eranos Blossom**, que sirven como relé y servidor de archivos predeterminados para {{appName}}. Tenemos control de moderación total sobre el contenido almacenado en estos servicios.
|
||||
- **Relés de terceros:** Los usuarios también pueden conectarse a otros relés Nostr operados por terceros independientes. {{appName}} obtiene y muestra contenido desde cualquier relé al que el usuario esté conectado. No tenemos control de moderación sobre los relés de terceros, pero sí controlamos lo que la aplicación muestra.
|
||||
- **Servidores de medios de terceros:** Los usuarios pueden subir imágenes y vídeos a servidores de archivos de terceros compatibles con Blossom. No operamos ni moderamos estos servicios externos.
|
||||
|
||||
Asumimos plena responsabilidad por la experiencia dentro de nuestra aplicación. En nuestra propia infraestructura (relé Agora y servidor Agora Blossom), podemos eliminar contenido y vetar las cuentas infractoras directamente. Para el contenido procedente de servicios de terceros, bloqueamos activamente su visualización dentro de {{appName}}.
|
||||
Asumimos plena responsabilidad por la experiencia dentro de nuestra aplicación. En nuestra propia infraestructura (relé Eranos y servidor Eranos Blossom), podemos eliminar contenido y vetar las cuentas infractoras directamente. Para el contenido procedente de servicios de terceros, bloqueamos activamente su visualización dentro de {{appName}}.
|
||||
|
||||
## Contenido y comportamiento prohibidos
|
||||
|
||||
@@ -33,8 +33,8 @@ Lo siguiente está estrictamente prohibido en {{appName}}. Las personas usuarias
|
||||
|
||||
- **Filtrado de contenido:** Mantenemos y aplicamos mecanismos de filtrado dentro de la aplicación para bloquear la visualización de material CSAE conocido, independientemente del relé del que provenga.
|
||||
- **Reporte de usuarios:** Ofrecemos herramientas de reporte dentro de la aplicación que permiten a las personas usuarias señalar contenido sospechoso de CSAE para una revisión inmediata.
|
||||
- **Moderación del relé Agora:** En nuestro propio relé Agora, moderamos activamente el contenido y eliminaremos de inmediato cualquier material CSAE, vetando permanentemente las cuentas asociadas.
|
||||
- **Moderación del servidor Agora Blossom:** En nuestro propio servidor de archivos Agora Blossom, eliminaremos de inmediato cualquier medio CSAE y vetaremos la cuenta que lo haya subido.
|
||||
- **Moderación del relé Eranos:** En nuestro propio relé Eranos, moderamos activamente el contenido y eliminaremos de inmediato cualquier material CSAE, vetando permanentemente las cuentas asociadas.
|
||||
- **Moderación del servidor Eranos Blossom:** En nuestro propio servidor de archivos Eranos Blossom, eliminaremos de inmediato cualquier medio CSAE y vetaremos la cuenta que lo haya subido.
|
||||
- **Bloqueo de relés de terceros:** Los relés de terceros que se sepa que alojan o toleran material CSAE pueden ser eliminados de la lista de relés predeterminada de {{appName}} y bloqueados para que los usuarios no puedan añadirlos.
|
||||
- **Herramientas de silencio y bloqueo:** Las personas usuarias pueden silenciar o bloquear cuentas a nivel de cliente, evitando que el contenido de esas cuentas aparezca en su feed.
|
||||
|
||||
@@ -43,7 +43,7 @@ Lo siguiente está estrictamente prohibido en {{appName}}. Las personas usuarias
|
||||
Cuando se identifica contenido o comportamiento de tipo CSAE, {{appName}} tomará las siguientes acciones según corresponda:
|
||||
|
||||
- **Bloqueo inmediato de contenido:** El contenido CSAE conocido será bloqueado para que no se renderice en la aplicación mediante filtros y listas de bloqueo.
|
||||
- **Eliminación de la infraestructura de Agora:** El contenido CSAE en el relé Agora y en el servidor Agora Blossom será eliminado de inmediato, y las cuentas asociadas, vetadas permanentemente.
|
||||
- **Eliminación de la infraestructura de Eranos:** El contenido CSAE en el relé Eranos y en el servidor Eranos Blossom será eliminado de inmediato, y las cuentas asociadas, vetadas permanentemente.
|
||||
- **Bloqueo de cuentas:** Las claves públicas de Nostr asociadas a actividad CSAE se añadirán a las listas de bloqueo a nivel de aplicación, evitando que su contenido aparezca en {{appName}} independientemente del relé desde el que se obtenga.
|
||||
- **Bloqueo de relés:** Los relés de terceros que no aborden el contenido CSAE pueden ser eliminados de la lista de relés predeterminada de {{appName}} y bloqueados para que los usuarios no puedan añadirlos.
|
||||
- **Reporte a las autoridades:** Reportaremos el material CSAE identificado al [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) a través de CyberTipline, y a las agencias de aplicación de la ley pertinentes.
|
||||
@@ -63,7 +63,7 @@ Todos los reportes de contenido CSAE se tratan con la máxima prioridad y se rev
|
||||
|
||||
{{appName}} se compromete a cooperar plenamente con las autoridades que investiguen casos de CSAE. Aunque {{appName}} no almacena contenido de personas usuarias en sus propios servidores, haremos lo siguiente:
|
||||
|
||||
- Proporcionar cualquier información de la que dispongamos —incluidos datos del relé Agora y del servidor Agora Blossom— que pueda asistir en las investigaciones, de conformidad con la ley aplicable.
|
||||
- Proporcionar cualquier información de la que dispongamos —incluidos datos del relé Eranos y del servidor Eranos Blossom— que pueda asistir en las investigaciones, de conformidad con la ley aplicable.
|
||||
- Identificar y compartir las URL específicas de los relés y de los servidores de archivos donde se observó el contenido infractor, para que las autoridades puedan contactar directamente con esos operadores.
|
||||
- Preservar cualquier evidencia o información disponible al recibir un requerimiento legal válido.
|
||||
- Reportar de manera proactiva el material CSAE identificado a NCMEC y a otras autoridades pertinentes.
|
||||
@@ -72,7 +72,7 @@ Todos los reportes de contenido CSAE se tratan con la máxima prioridad y se rev
|
||||
|
||||
La naturaleza descentralizada de Nostr implica que ninguna entidad tiene control completo sobre todo el contenido de la red. {{appName}} reconoce las siguientes realidades y nuestro enfoque ante cada una:
|
||||
|
||||
- **Control total sobre nuestra propia infraestructura:** Podemos eliminar —y eliminamos— contenido del relé Agora y del servidor Agora Blossom. El material CSAE detectado en nuestra infraestructura se elimina de inmediato y las cuentas son vetadas permanentemente.
|
||||
- **Control total sobre nuestra propia infraestructura:** Podemos eliminar —y eliminamos— contenido del relé Eranos y del servidor Eranos Blossom. El material CSAE detectado en nuestra infraestructura se elimina de inmediato y las cuentas son vetadas permanentemente.
|
||||
- **Control limitado sobre relés de terceros:** No podemos eliminar contenido de relés de terceros. Sin embargo, bloqueamos la visualización de dicho contenido dentro de nuestra aplicación mediante filtros y listas de bloqueo a nivel de cliente.
|
||||
- **Las personas usuarias controlan sus conexiones a relés:** Aunque las personas usuarias pueden conectarse a los relés que elijan, {{appName}} se reserva el derecho de bloquear las conexiones a relés que se sepa que alojan contenido CSAE.
|
||||
- **Las claves públicas son seudónimas:** Las cuentas de Nostr se identifican mediante pares de claves criptográficas en lugar de identidades verificadas. Aun así, bloquearemos y reportaremos las claves infractoras y cooperaremos con las autoridades para identificar a las personas detrás de ellas.
|
||||
|
||||
@@ -10,11 +10,11 @@ _آخرین بهروزرسانی: ۱۹ مارس ۲۰۲۶_
|
||||
|
||||
{{appName}} یک **برنامهٔ کلاینت** برای پروتکل Nostr است، شبکهای ارتباطی باز و غیرمتمرکز. درک معماری آن، زمینهٔ مهمی برای این سیاست است:
|
||||
|
||||
- **زیرساخت ما:** ما **رلهٔ Agora** و **سرور Agora Blossom** را به عنوان رله و میزبان فایل پیشفرض {{appName}} اداره میکنیم. کنترل کاملی بر مدیریت محتوای ذخیرهشده در این سرویسها داریم.
|
||||
- **زیرساخت ما:** ما **رلهٔ Eranos** و **سرور Eranos Blossom** را به عنوان رله و میزبان فایل پیشفرض {{appName}} اداره میکنیم. کنترل کاملی بر مدیریت محتوای ذخیرهشده در این سرویسها داریم.
|
||||
- **رلههای اشخاص ثالث:** کاربران میتوانند به رلههای Nostr دیگری که توسط اشخاص ثالث مستقل اداره میشوند نیز متصل شوند. {{appName}} محتوا را از هر رلهای که کاربر به آن متصل است دریافت و نمایش میدهد. ما کنترل مدیریت بر رلههای اشخاص ثالث نداریم، اما آنچه برنامه نمایش میدهد را کنترل میکنیم.
|
||||
- **سرورهای رسانهای اشخاص ثالث:** کاربران ممکن است تصاویر و ویدیوها را در سرورهای فایل سازگار با Blossom که توسط اشخاص ثالث اداره میشوند بارگذاری کنند. ما این سرویسهای خارجی را اداره یا مدیریت نمیکنیم.
|
||||
|
||||
ما مسئولیت کامل تجربهٔ کاربر در درون برنامهٔ خود را میپذیریم. در زیرساختهای خودمان (رلهٔ Agora و سرور Agora Blossom) میتوانیم محتوا را بهطور مستقیم حذف و حسابهای متخلف را مسدود کنیم. برای محتوایی که از سرویسهای اشخاص ثالث میآید، فعالانه نمایش آن در {{appName}} را مسدود میکنیم.
|
||||
ما مسئولیت کامل تجربهٔ کاربر در درون برنامهٔ خود را میپذیریم. در زیرساختهای خودمان (رلهٔ Eranos و سرور Eranos Blossom) میتوانیم محتوا را بهطور مستقیم حذف و حسابهای متخلف را مسدود کنیم. برای محتوایی که از سرویسهای اشخاص ثالث میآید، فعالانه نمایش آن در {{appName}} را مسدود میکنیم.
|
||||
|
||||
## محتوا و رفتارهای ممنوع
|
||||
|
||||
@@ -33,8 +33,8 @@ _آخرین بهروزرسانی: ۱۹ مارس ۲۰۲۶_
|
||||
|
||||
- **فیلتر محتوا:** ما سازوکارهای فیلتر محتوا را در درون برنامه نگهداری و اعمال میکنیم تا از نمایش مواد CSAE شناختهشده، صرف نظر از اینکه از کدام رله بیایند، جلوگیری شود.
|
||||
- **گزارشدهی کاربران:** ابزارهای گزارشدهی درونبرنامهای ارائه میدهیم که به کاربران امکان میدهد محتوای مشکوک به CSAE را برای بازبینی فوری علامتگذاری کنند.
|
||||
- **مدیریت رلهٔ Agora:** در رلهٔ Agora متعلق به خودمان، محتوا را فعالانه مدیریت میکنیم و هرگونه مواد CSAE را فوراً حذف کرده و حسابهای مرتبط را برای همیشه مسدود خواهیم کرد.
|
||||
- **مدیریت سرور Agora Blossom:** در سرور فایل Agora Blossom متعلق به خودمان، هرگونه رسانهٔ CSAE را فوراً حذف کرده و حساب بارگذاریکننده را مسدود خواهیم کرد.
|
||||
- **مدیریت رلهٔ Eranos:** در رلهٔ Eranos متعلق به خودمان، محتوا را فعالانه مدیریت میکنیم و هرگونه مواد CSAE را فوراً حذف کرده و حسابهای مرتبط را برای همیشه مسدود خواهیم کرد.
|
||||
- **مدیریت سرور Eranos Blossom:** در سرور فایل Eranos Blossom متعلق به خودمان، هرگونه رسانهٔ CSAE را فوراً حذف کرده و حساب بارگذاریکننده را مسدود خواهیم کرد.
|
||||
- **مسدودسازی رلههای اشخاص ثالث:** رلههای اشخاص ثالث که میزبانی مواد CSAE یا تحمل آنها از سویشان شناخته شده باشد، ممکن است از فهرست رلههای پیشفرض {{appName}} حذف شوند و کاربران از افزودن آنها منع شوند.
|
||||
- **ابزارهای بیصدا کردن و مسدودسازی:** کاربران میتوانند حسابها را در سطح کلاینت بیصدا کنند یا مسدود نمایند تا محتوای آن حسابها در خوراک آنها ظاهر نشود.
|
||||
|
||||
@@ -43,7 +43,7 @@ _آخرین بهروزرسانی: ۱۹ مارس ۲۰۲۶_
|
||||
هنگام شناسایی محتوای CSAE یا رفتار مرتبط، {{appName}} حسب مورد اقدامات زیر را انجام خواهد داد:
|
||||
|
||||
- **مسدودسازی فوری محتوا:** محتوای CSAE شناختهشده از طریق فیلترها و فهرستهای مسدودسازی، در برنامه نمایش داده نخواهد شد.
|
||||
- **حذف از زیرساخت Agora:** محتوای CSAE موجود در رلهٔ Agora و سرور Agora Blossom فوراً حذف خواهد شد و حسابهای مرتبط برای همیشه مسدود میشوند.
|
||||
- **حذف از زیرساخت Eranos:** محتوای CSAE موجود در رلهٔ Eranos و سرور Eranos Blossom فوراً حذف خواهد شد و حسابهای مرتبط برای همیشه مسدود میشوند.
|
||||
- **مسدودسازی حساب:** کلیدهای عمومی Nostr مرتبط با فعالیتهای CSAE به فهرستهای مسدودسازی در سطح برنامه افزوده خواهند شد تا محتوای آنها در {{appName}} ظاهر نشود، صرف نظر از اینکه از کدام رله گرفته شود.
|
||||
- **مسدودسازی رله:** رلههای اشخاص ثالثی که در رسیدگی به محتوای CSAE کوتاهی کنند، ممکن است از فهرست رلههای پیشفرض {{appName}} حذف شوند و کاربران از افزودن آنها منع شوند.
|
||||
- **گزارش به مقامات:** مواد CSAE شناساییشده را از طریق CyberTipline به [مرکز ملی کودکان مفقود و مورد بهرهکشی (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) و به نهادهای اجرای قانون مربوطه گزارش خواهیم داد.
|
||||
@@ -63,7 +63,7 @@ _آخرین بهروزرسانی: ۱۹ مارس ۲۰۲۶_
|
||||
|
||||
{{appName}} متعهد است به همکاری کامل با نهادهای اجرای قانون که در حال تحقیق دربارهٔ CSAE هستند. هرچند {{appName}} محتوای کاربران را در سرورهای خود ذخیره نمیکند، اما:
|
||||
|
||||
- اطلاعاتی که در اختیار ما باشد — از جمله دادههای رلهٔ Agora و سرور Agora Blossom — را در چارچوب قانون قابل اجرا، در اختیار خواهیم گذاشت تا به تحقیقات کمک کند.
|
||||
- اطلاعاتی که در اختیار ما باشد — از جمله دادههای رلهٔ Eranos و سرور Eranos Blossom — را در چارچوب قانون قابل اجرا، در اختیار خواهیم گذاشت تا به تحقیقات کمک کند.
|
||||
- آدرسهای مشخص رلهها و سرورهای فایلی را که محتوای متخلف در آنها مشاهده شده، شناسایی و به اشتراک میگذاریم تا نیروی انتظامی بتواند مستقیماً با اپراتورهای آنها تماس بگیرد.
|
||||
- پس از دریافت درخواست قانونی معتبر، هرگونه شواهد یا اطلاعات در دسترس را حفظ میکنیم.
|
||||
- مواد CSAE شناساییشده را بهصورت پیشگیرانه به NCMEC و سایر مقامات مربوطه گزارش میکنیم.
|
||||
@@ -72,7 +72,7 @@ _آخرین بهروزرسانی: ۱۹ مارس ۲۰۲۶_
|
||||
|
||||
ماهیت غیرمتمرکز Nostr به این معناست که هیچ نهاد یگانهای کنترل کامل بر تمام محتوای موجود در شبکه ندارد. {{appName}} واقعیتهای زیر و رویکرد خود نسبت به هر یک را اذعان میکند:
|
||||
|
||||
- **کنترل کامل بر زیرساخت خودمان:** ما میتوانیم و در عمل محتوا را از رلهٔ Agora و سرور Agora Blossom حذف میکنیم. مواد CSAE یافتشده در زیرساخت ما فوراً حذف میشوند و حسابها برای همیشه مسدود میگردند.
|
||||
- **کنترل کامل بر زیرساخت خودمان:** ما میتوانیم و در عمل محتوا را از رلهٔ Eranos و سرور Eranos Blossom حذف میکنیم. مواد CSAE یافتشده در زیرساخت ما فوراً حذف میشوند و حسابها برای همیشه مسدود میگردند.
|
||||
- **کنترل محدود بر رلههای اشخاص ثالث:** ما نمیتوانیم محتوای رلههای اشخاص ثالث را حذف کنیم. اما با فیلترها و فهرستهای مسدودسازی در سطح کلاینت، از نمایش چنین محتوایی در برنامهٔ خود جلوگیری میکنیم.
|
||||
- **کاربران اتصالهای رلهٔ خود را کنترل میکنند:** هرچند کاربران میتوانند به رلههای دلخواه خود متصل شوند، {{appName}} این حق را برای خود محفوظ میدارد که اتصال به رلههایی که شناختهشده است محتوای CSAE میزبانی میکنند را مسدود کند.
|
||||
- **کلیدهای عمومی، نام مستعار هستند:** حسابهای Nostr با جفتکلیدهای رمزنگاری شناسایی میشوند نه با هویتهای احراز شده. با این حال، ما کلیدهای متخلف را مسدود و گزارش خواهیم کرد و با نیروی انتظامی برای شناسایی افراد پشت آنها همکاری میکنیم.
|
||||
|
||||
@@ -10,11 +10,11 @@ Cette politique s'applique à tout contenu accessible via {{appName}}, y compris
|
||||
|
||||
{{appName}} est une **application cliente** pour le protocole Nostr, un réseau de communication ouvert et décentralisé. Comprendre l'architecture est un contexte important pour cette politique :
|
||||
|
||||
- **Notre infrastructure :** Nous exploitons le **relais Agora** et le **serveur Blossom Agora**, qui servent de relais et d'hôte de fichiers par défaut pour {{appName}}. Nous avons un contrôle complet de modération sur le contenu stocké sur ces services.
|
||||
- **Notre infrastructure :** Nous exploitons le **relais Eranos** et le **serveur Blossom Eranos**, qui servent de relais et d'hôte de fichiers par défaut pour {{appName}}. Nous avons un contrôle complet de modération sur le contenu stocké sur ces services.
|
||||
- **Relais tiers :** Les utilisateurs peuvent également se connecter à d'autres relais Nostr exploités par des tiers indépendants. {{appName}} récupère et affiche le contenu de tous les relais auxquels l'utilisateur est connecté. Nous n'avons pas de contrôle de modération sur les relais tiers, mais nous contrôlons ce que l'application affiche.
|
||||
- **Serveurs de médias tiers :** Les utilisateurs peuvent téléverser des images et des vidéos sur des serveurs de fichiers tiers compatibles Blossom. Nous n'exploitons ni ne modérons ces services externes.
|
||||
|
||||
Nous assumons l'entière responsabilité de l'expérience au sein de notre application. Sur notre propre infrastructure (relais Agora et serveur Blossom Agora), nous pouvons directement supprimer le contenu et bannir les comptes contrevenants. Pour le contenu provenant de services tiers, nous le bloquons activement pour qu'il ne s'affiche pas dans {{appName}}.
|
||||
Nous assumons l'entière responsabilité de l'expérience au sein de notre application. Sur notre propre infrastructure (relais Eranos et serveur Blossom Eranos), nous pouvons directement supprimer le contenu et bannir les comptes contrevenants. Pour le contenu provenant de services tiers, nous le bloquons activement pour qu'il ne s'affiche pas dans {{appName}}.
|
||||
|
||||
## Contenu et comportement interdits
|
||||
|
||||
@@ -33,8 +33,8 @@ Ce qui suit est strictement interdit sur {{appName}}. Les utilisateurs trouvés
|
||||
|
||||
- **Filtrage de contenu :** Nous maintenons et appliquons des mécanismes de filtrage de contenu dans l'application pour bloquer l'affichage de matériel CSAE connu, quel que soit le relais d'origine.
|
||||
- **Signalement par les utilisateurs :** Nous fournissons des outils de signalement intégrés à l'application qui permettent aux utilisateurs de signaler tout contenu CSAE suspect pour examen immédiat.
|
||||
- **Modération du relais Agora :** Sur notre propre relais Agora, nous modérons activement le contenu et supprimons immédiatement tout matériel CSAE et bannissons définitivement les comptes associés.
|
||||
- **Modération du serveur Blossom Agora :** Sur notre propre serveur de fichiers Blossom Agora, nous supprimerons immédiatement tout média CSAE et bannirons le compte qui l'a téléversé.
|
||||
- **Modération du relais Eranos :** Sur notre propre relais Eranos, nous modérons activement le contenu et supprimons immédiatement tout matériel CSAE et bannissons définitivement les comptes associés.
|
||||
- **Modération du serveur Blossom Eranos :** Sur notre propre serveur de fichiers Blossom Eranos, nous supprimerons immédiatement tout média CSAE et bannirons le compte qui l'a téléversé.
|
||||
- **Blocage des relais tiers :** Les relais tiers connus pour héberger ou tolérer le matériel CSAE peuvent être retirés de la liste de relais par défaut de {{appName}} et bloqués pour être ajoutés par les utilisateurs.
|
||||
- **Outils de mise en sourdine et de blocage :** Les utilisateurs peuvent mettre en sourdine ou bloquer des comptes au niveau du client, empêchant le contenu de ces comptes d'apparaître dans leur fil.
|
||||
|
||||
@@ -43,7 +43,7 @@ Ce qui suit est strictement interdit sur {{appName}}. Les utilisateurs trouvés
|
||||
Lorsqu'un contenu ou un comportement CSAE est identifié, {{appName}} prendra les mesures suivantes selon le cas :
|
||||
|
||||
- **Blocage immédiat du contenu :** Le contenu CSAE connu sera bloqué à l'affichage dans l'application par des filtres de contenu et des listes de blocage.
|
||||
- **Suppression de l'infrastructure Agora :** Le contenu CSAE sur le relais Agora et le serveur Blossom Agora sera immédiatement supprimé, et les comptes associés bannis définitivement.
|
||||
- **Suppression de l'infrastructure Eranos :** Le contenu CSAE sur le relais Eranos et le serveur Blossom Eranos sera immédiatement supprimé, et les comptes associés bannis définitivement.
|
||||
- **Blocage de comptes :** Les clés publiques Nostr associées à l'activité CSAE seront ajoutées aux listes de blocage au niveau de l'application, empêchant leur contenu d'apparaître dans {{appName}} quel que soit le relais d'où il est récupéré.
|
||||
- **Blocage de relais :** Les relais tiers qui ne traitent pas le contenu CSAE peuvent être retirés de la liste de relais par défaut de {{appName}} et bloqués pour être ajoutés par les utilisateurs.
|
||||
- **Signalement aux autorités :** Nous signalerons le matériel CSAE identifié au [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) via la CyberTipline, et aux agences d'application de la loi applicables.
|
||||
@@ -63,7 +63,7 @@ Tous les signalements de contenu CSAE sont traités avec la plus haute priorité
|
||||
|
||||
{{appName}} s'engage à coopérer pleinement avec les agences d'application de la loi enquêtant sur la CSAE. Bien que {{appName}} ne stocke pas le contenu utilisateur sur ses propres serveurs, nous :
|
||||
|
||||
- Fournirons toute information dont nous disposons — y compris les données du relais Agora et du serveur Blossom Agora — qui peut aider aux enquêtes, conformément à la loi applicable.
|
||||
- Fournirons toute information dont nous disposons — y compris les données du relais Eranos et du serveur Blossom Eranos — qui peut aider aux enquêtes, conformément à la loi applicable.
|
||||
- Identifierons et partagerons les URL spécifiques de relais et de serveurs de fichiers où le contenu contrevenant a été observé, afin que les forces de l'ordre puissent contacter directement ces opérateurs.
|
||||
- Préserverons toute preuve ou information disponible à la réception d'une demande légale valide.
|
||||
- Signalerons proactivement le matériel CSAE identifié au NCMEC et aux autres autorités compétentes.
|
||||
@@ -72,7 +72,7 @@ Tous les signalements de contenu CSAE sont traités avec la plus haute priorité
|
||||
|
||||
La nature décentralisée de Nostr signifie qu'aucune entité unique n'a un contrôle complet sur tout le contenu du réseau. {{appName}} reconnaît les réalités suivantes et notre approche pour chacune :
|
||||
|
||||
- **Contrôle complet sur notre propre infrastructure :** Nous pouvons et supprimons le contenu du relais Agora et du serveur Blossom Agora. Le matériel CSAE trouvé sur notre infrastructure est supprimé immédiatement et les comptes sont bannis définitivement.
|
||||
- **Contrôle complet sur notre propre infrastructure :** Nous pouvons et supprimons le contenu du relais Eranos et du serveur Blossom Eranos. Le matériel CSAE trouvé sur notre infrastructure est supprimé immédiatement et les comptes sont bannis définitivement.
|
||||
- **Contrôle limité sur les relais tiers :** Nous ne pouvons pas supprimer le contenu des relais tiers. Cependant, nous bloquons l'affichage de ce contenu dans notre application via des filtres et des listes de blocage au niveau du client.
|
||||
- **Les utilisateurs contrôlent leurs connexions aux relais :** Bien que les utilisateurs puissent se connecter aux relais de leur choix, {{appName}} se réserve le droit de bloquer les connexions aux relais connus pour héberger du contenu CSAE.
|
||||
- **Les clés publiques sont pseudonymes :** Les comptes Nostr sont identifiés par des paires de clés cryptographiques plutôt que par des identités vérifiées. Nous bloquerons et signalerons toujours les clés contrevenantes et coopérerons avec les forces de l'ordre pour identifier les individus derrière elles.
|
||||
|
||||
@@ -10,11 +10,11 @@ _បានធ្វើបច្ចុប្បន្នភាពចុងក្
|
||||
|
||||
{{appName}} គឺជា **កម្មវិធីភ្ញៀវ** សម្រាប់ប្រូតូកូល Nostr ដែលជាបណ្ដាញទំនាក់ទំនងបើកចំហ និងគ្មានមជ្ឈការ។ ការយល់ដឹងពីស្ថាបត្យកម្មគឺជាបរិបទដ៏សំខាន់សម្រាប់គោលនយោបាយនេះ៖
|
||||
|
||||
- **ហេដ្ឋារចនាសម្ព័ន្ធរបស់យើង៖** យើងគ្រប់គ្រងប្រតិបត្តិការ **Agora relay** និង **Agora Blossom server** ដែលដើរតួជា relay និងម៉ាស៊ីនមេឯកសារលំនាំដើមសម្រាប់ {{appName}}។ យើងមានសិទ្ធិត្រួតពិនិត្យពេញលេញលើមាតិកាដែលរក្សាទុកនៅលើសេវាកម្មទាំងនេះ។
|
||||
- **ហេដ្ឋារចនាសម្ព័ន្ធរបស់យើង៖** យើងគ្រប់គ្រងប្រតិបត្តិការ **Eranos relay** និង **Eranos Blossom server** ដែលដើរតួជា relay និងម៉ាស៊ីនមេឯកសារលំនាំដើមសម្រាប់ {{appName}}។ យើងមានសិទ្ធិត្រួតពិនិត្យពេញលេញលើមាតិកាដែលរក្សាទុកនៅលើសេវាកម្មទាំងនេះ។
|
||||
- **Relay របស់ភាគីទីបី៖** អ្នកប្រើអាចភ្ជាប់ទៅ Nostr relay បន្ថែមដែលដំណើរការដោយភាគីទីបីឯករាជ្យ។ {{appName}} ទាញ និងបង្ហាញមាតិកាពី relay ណាមួយដែលអ្នកប្រើបានភ្ជាប់។ យើងគ្មានសិទ្ធិត្រួតពិនិត្យ relay របស់ភាគីទីបីទេ ប៉ុន្តែយើងគ្រប់គ្រងអ្វីដែលកម្មវិធីបង្ហាញ។
|
||||
- **ម៉ាស៊ីនមេមេឌៀរបស់ភាគីទីបី៖** អ្នកប្រើអាចអាប់ឡូតរូបភាព និងវីដេអូទៅម៉ាស៊ីនមេឯកសារដែលត្រូវនឹង Blossom របស់ភាគីទីបី។ យើងមិនដំណើរការ ឬត្រួតពិនិត្យសេវាកម្មខាងក្រៅទាំងនេះទេ។
|
||||
|
||||
យើងទទួលខុសត្រូវពេញលេញចំពោះបទពិសោធន៍នៅក្នុងកម្មវិធីរបស់យើង។ នៅលើហេដ្ឋារចនាសម្ព័ន្ធរបស់យើងផ្ទាល់ (Agora relay និង Agora Blossom server) យើងអាចលុបមាតិកាដោយផ្ទាល់ និងហាមឃាត់គណនីដែលរំលោភ។ សម្រាប់មាតិកាដែលមានប្រភពពីសេវាកម្មរបស់ភាគីទីបី យើងរារាំងវាយ៉ាងសកម្មពីការបង្ហាញនៅក្នុង {{appName}}។
|
||||
យើងទទួលខុសត្រូវពេញលេញចំពោះបទពិសោធន៍នៅក្នុងកម្មវិធីរបស់យើង។ នៅលើហេដ្ឋារចនាសម្ព័ន្ធរបស់យើងផ្ទាល់ (Eranos relay និង Eranos Blossom server) យើងអាចលុបមាតិកាដោយផ្ទាល់ និងហាមឃាត់គណនីដែលរំលោភ។ សម្រាប់មាតិកាដែលមានប្រភពពីសេវាកម្មរបស់ភាគីទីបី យើងរារាំងវាយ៉ាងសកម្មពីការបង្ហាញនៅក្នុង {{appName}}។
|
||||
|
||||
## មាតិកា និងឥរិយាបថដែលត្រូវហាមឃាត់
|
||||
|
||||
@@ -33,8 +33,8 @@ _បានធ្វើបច្ចុប្បន្នភាពចុងក្
|
||||
|
||||
- **ការត្រងមាតិកា៖** យើងថែទាំ និងអនុវត្តយន្តការត្រងមាតិកាក្នុងកម្មវិធី ដើម្បីរារាំងសម្ភារៈ CSAE ដែលគេស្គាល់ពីការបង្ហាញ ដោយមិនគិតពី relay ណាដែលវាមានប្រភពមកពី។
|
||||
- **ការរាយការណ៍របស់អ្នកប្រើ៖** យើងផ្ដល់ឧបករណ៍រាយការណ៍ក្នុងកម្មវិធីដែលអនុញ្ញាតឱ្យអ្នកប្រើដាក់សញ្ញាសម្ភារៈ CSAE ដែលគួរឱ្យសង្ស័យដើម្បីពិនិត្យឡើងវិញភ្លាមៗ។
|
||||
- **ការត្រួតពិនិត្យ Agora relay៖** នៅលើ Agora relay របស់យើងផ្ទាល់ យើងត្រួតពិនិត្យមាតិកាយ៉ាងសកម្ម ហើយនឹងលុបសម្ភារៈ CSAE ភ្លាមៗ និងហាមឃាត់គណនីដែលពាក់ព័ន្ធជារៀងរហូត។
|
||||
- **ការត្រួតពិនិត្យ Agora Blossom server៖** នៅលើម៉ាស៊ីនមេឯកសារ Agora Blossom របស់យើងផ្ទាល់ យើងនឹងលុបមេឌៀ CSAE ភ្លាមៗ និងហាមឃាត់គណនីដែលអាប់ឡូត។
|
||||
- **ការត្រួតពិនិត្យ Eranos relay៖** នៅលើ Eranos relay របស់យើងផ្ទាល់ យើងត្រួតពិនិត្យមាតិកាយ៉ាងសកម្ម ហើយនឹងលុបសម្ភារៈ CSAE ភ្លាមៗ និងហាមឃាត់គណនីដែលពាក់ព័ន្ធជារៀងរហូត។
|
||||
- **ការត្រួតពិនិត្យ Eranos Blossom server៖** នៅលើម៉ាស៊ីនមេឯកសារ Eranos Blossom របស់យើងផ្ទាល់ យើងនឹងលុបមេឌៀ CSAE ភ្លាមៗ និងហាមឃាត់គណនីដែលអាប់ឡូត។
|
||||
- **ការរារាំង relay របស់ភាគីទីបី៖** Relay របស់ភាគីទីបីដែលគេស្គាល់ថាដាក់សម្ភារៈ CSAE ឬមិនយកចិត្តទុកដាក់នឹងត្រូវបានដកចេញពីបញ្ជី relay លំនាំដើមរបស់ {{appName}} និងត្រូវរារាំងពីអ្នកប្រើ។
|
||||
- **ឧបករណ៍បិទសម្លេង និងទប់ស្កាត់៖** អ្នកប្រើអាចបិទសម្លេង ឬទប់ស្កាត់គណនីនៅកម្រិតភ្ញៀវ ដើម្បីការពារមាតិកាពីគណនីទាំងនោះមិនឱ្យបង្ហាញនៅក្នុង feed របស់ពួកគេ។
|
||||
|
||||
@@ -43,7 +43,7 @@ _បានធ្វើបច្ចុប្បន្នភាពចុងក្
|
||||
នៅពេលដែលមាតិកា ឬឥរិយាបថ CSAE ត្រូវបានកំណត់ {{appName}} នឹងចាត់សកម្មភាពខាងក្រោមតាមការអនុវត្ត៖
|
||||
|
||||
- **ការរារាំងមាតិកាភ្លាមៗ៖** មាតិកា CSAE ដែលគេស្គាល់នឹងត្រូវរារាំងពីការបង្ហាញនៅក្នុងកម្មវិធីតាមរយៈតម្រងមាតិកា និងបញ្ជីហាមឃាត់។
|
||||
- **ការដកចេញពីហេដ្ឋារចនាសម្ព័ន្ធ Agora៖** មាតិកា CSAE នៅលើ Agora relay និង Agora Blossom server នឹងត្រូវលុបភ្លាមៗ ហើយគណនីពាក់ព័ន្ធត្រូវហាមឃាត់ជារៀងរហូត។
|
||||
- **ការដកចេញពីហេដ្ឋារចនាសម្ព័ន្ធ Eranos៖** មាតិកា CSAE នៅលើ Eranos relay និង Eranos Blossom server នឹងត្រូវលុបភ្លាមៗ ហើយគណនីពាក់ព័ន្ធត្រូវហាមឃាត់ជារៀងរហូត។
|
||||
- **ការរារាំងគណនី៖** កូនសោសាធារណៈ Nostr ដែលពាក់ព័ន្ធនឹងសកម្មភាព CSAE នឹងត្រូវបន្ថែមទៅបញ្ជីហាមឃាត់កម្រិតកម្មវិធី ដែលរារាំងមាតិការបស់ពួកគេពីការបង្ហាញនៅក្នុង {{appName}} ដោយមិនគិតពី relay ណាដែលវាត្រូវបានទាញ។
|
||||
- **ការរារាំង relay៖** Relay របស់ភាគីទីបីដែលបរាជ័យក្នុងការដោះស្រាយមាតិកា CSAE អាចត្រូវដកចេញពីបញ្ជី relay លំនាំដើមរបស់ {{appName}} និងត្រូវរារាំងពីការបន្ថែមដោយអ្នកប្រើ។
|
||||
- **ការរាយការណ៍ទៅអាជ្ញាធរ៖** យើងនឹងរាយការណ៍សម្ភារៈ CSAE ដែលបានកំណត់ទៅ [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) តាមរយៈ CyberTipline និងទៅភ្នាក់ងារអនុវត្តច្បាប់ដែលអនុវត្តបាន។
|
||||
@@ -63,7 +63,7 @@ _បានធ្វើបច្ចុប្បន្នភាពចុងក្
|
||||
|
||||
{{appName}} ប្ដេជ្ញាសហការពេញលេញជាមួយភ្នាក់ងារអនុវត្តច្បាប់ដែលកំពុងស៊ើបអង្កេតលើ CSAE។ ទោះបី {{appName}} មិនរក្សាទុកមាតិកាអ្នកប្រើនៅលើម៉ាស៊ីនមេផ្ទាល់ខ្លួនរបស់ខ្លួនក៏ដោយ យើងនឹង៖
|
||||
|
||||
- ផ្ដល់ព័ត៌មានណាមួយដែលមានសម្រាប់យើង — រួមមានទិន្នន័យពី Agora relay និង Agora Blossom server — ដែលអាចជួយដល់ការស៊ើបអង្កេត តាមច្បាប់ដែលអនុវត្តបាន។
|
||||
- ផ្ដល់ព័ត៌មានណាមួយដែលមានសម្រាប់យើង — រួមមានទិន្នន័យពី Eranos relay និង Eranos Blossom server — ដែលអាចជួយដល់ការស៊ើបអង្កេត តាមច្បាប់ដែលអនុវត្តបាន។
|
||||
- កំណត់អត្តសញ្ញាណ និងចែករំលែក URL ជាក់លាក់នៃ relay និង URL ម៉ាស៊ីនមេឯកសារដែលមាតិការំលោភត្រូវបានសង្កេតឃើញ ដើម្បីឱ្យអ្នកអនុវត្តច្បាប់អាចទាក់ទងប្រតិបត្តិករទាំងនោះដោយផ្ទាល់។
|
||||
- រក្សាភ័ស្តុតាង ឬព័ត៌មានដែលមាន នៅពេលទទួលបានសំណើផ្លូវច្បាប់ត្រឹមត្រូវ។
|
||||
- រាយការណ៍សម្ភារៈ CSAE ដែលបានកំណត់ទៅ NCMEC និងអាជ្ញាធរពាក់ព័ន្ធផ្សេងទៀតជាមួយការសកម្ម។
|
||||
@@ -72,7 +72,7 @@ _បានធ្វើបច្ចុប្បន្នភាពចុងក្
|
||||
|
||||
ធម្មជាតិគ្មានមជ្ឈការនៃ Nostr មានន័យថាគ្មានស្ថាប័នតែមួយដែលមានការគ្រប់គ្រងពេញលេញលើមាតិកាទាំងអស់នៅលើបណ្ដាញនោះទេ។ {{appName}} ទទួលស្គាល់ការពិតខាងក្រោម និងវិធីសាស្ត្ររបស់យើងចំពោះនីមួយៗ៖
|
||||
|
||||
- **ការគ្រប់គ្រងពេញលេញលើហេដ្ឋារចនាសម្ព័ន្ធផ្ទាល់ខ្លួនរបស់យើង៖** យើងអាច និងធ្វើការដកមាតិកាពី Agora relay និង Agora Blossom server។ សម្ភារៈ CSAE ដែលរកឃើញនៅលើហេដ្ឋារចនាសម្ព័ន្ធរបស់យើងត្រូវលុបភ្លាមៗ ហើយគណនីត្រូវហាមឃាត់ជារៀងរហូត។
|
||||
- **ការគ្រប់គ្រងពេញលេញលើហេដ្ឋារចនាសម្ព័ន្ធផ្ទាល់ខ្លួនរបស់យើង៖** យើងអាច និងធ្វើការដកមាតិកាពី Eranos relay និង Eranos Blossom server។ សម្ភារៈ CSAE ដែលរកឃើញនៅលើហេដ្ឋារចនាសម្ព័ន្ធរបស់យើងត្រូវលុបភ្លាមៗ ហើយគណនីត្រូវហាមឃាត់ជារៀងរហូត។
|
||||
- **ការគ្រប់គ្រងមានកំណត់លើ relay របស់ភាគីទីបី៖** យើងមិនអាចលុបមាតិកាពី relay របស់ភាគីទីបីបានទេ។ ប៉ុន្តែយើងរារាំងមាតិកាបែបនេះពីការបង្ហាញនៅក្នុងកម្មវិធីរបស់យើងតាមរយៈតម្រង និងបញ្ជីហាមឃាត់នៅកម្រិតភ្ញៀវ។
|
||||
- **អ្នកប្រើគ្រប់គ្រងការតភ្ជាប់ relay របស់ពួកគេ៖** ខណៈពេលដែលអ្នកប្រើអាចភ្ជាប់ទៅ relay ដែលពួកគេជ្រើសរើស {{appName}} រក្សាសិទ្ធិក្នុងការរារាំងការតភ្ជាប់ទៅ relay ដែលគេស្គាល់ថាដាក់មាតិកា CSAE។
|
||||
- **កូនសោសាធារណៈគឺឈ្មោះក្លែងក្លាយ៖** គណនី Nostr ត្រូវបានកំណត់អត្តសញ្ញាណដោយគូកូនសោគ្រីបតូ មិនមែនដោយអត្តសញ្ញាណដែលបានផ្ទៀងផ្ទាត់ទេ។ យើងនឹងនៅតែរារាំង និងរាយការណ៍កូនសោដែលរំលោភ និងសហការជាមួយអ្នកអនុវត្តច្បាប់ដើម្បីកំណត់អត្តសញ្ញាណបុគ្គលនៅពីក្រោយពួកគេ។
|
||||
|
||||
@@ -10,11 +10,11 @@ _وروستی تازه: د ۲۰۲۶ کال د مارچ ۱۹مه_
|
||||
|
||||
{{appName}} د Nostr پروتوکول لپاره یوه **مراجع غوښتنلیک** دی، یوه پرانیستی او نامرکزي اړیکنیز شبکه. د معماري په پوهیدل د دې پالیسي لپاره مهم شالید دی:
|
||||
|
||||
- **زموږ زیربنا:** موږ **د Agora رلی** او **د Agora Blossom سرور** چلوو، چې د {{appName}} لپاره د ډیفالټ رلی او د فایلونو میزبان په توګه کار کوي. موږ د دې خدماتو په زېرمهشویو موادو باندې بشپړ نظارتي کنټرول لرو.
|
||||
- **زموږ زیربنا:** موږ **د Eranos رلی** او **د Eranos Blossom سرور** چلوو، چې د {{appName}} لپاره د ډیفالټ رلی او د فایلونو میزبان په توګه کار کوي. موږ د دې خدماتو په زېرمهشویو موادو باندې بشپړ نظارتي کنټرول لرو.
|
||||
- **د دریمو ډلو رلیګانې:** کاروونکي ممکن د خپلواکو دریمو ډلو لخوا چلیدونکو نورو Nostr رلیګانو سره هم وصل شي. {{appName}} له هر هغه رلی څخه چې کاروونکی ورسره وصل وي، محتوا راوړي او ښیي. موږ د دریمو ډلو په رلیګانو باندې نظارتي کنټرول نه لرو، خو زموږ په کنټرول کې دی چې اپلیکیشن څه ښیي.
|
||||
- **د دریمو ډلو رسنیز سرورونه:** کاروونکي ممکن انځورونه او ویډیوګانې د دریمو ډلو Blossom سره ښکاره فایل سرورونو ته اپلوډ کړي. موږ دا بهرني خدمات نه چلوو او نه پرې نظارت کوو.
|
||||
|
||||
موږ زموږ په اپلیکیشن کې د تجربې بشپړه مسؤلیت اخلو. زموږ په خپلې زیربنا (د Agora رلی او د Agora Blossom سرور) کې موږ کولای شو محتوا په مستقیم ډول لرې کړو او ګناهکار حسابونه بند کړو. د هغو محتویاتو لپاره چې له دریمو ډلو خدماتو څخه راځي، موږ په فعاله توګه د هغو په {{appName}} کې له ښودلو څخه مخنیوی کوو.
|
||||
موږ زموږ په اپلیکیشن کې د تجربې بشپړه مسؤلیت اخلو. زموږ په خپلې زیربنا (د Eranos رلی او د Eranos Blossom سرور) کې موږ کولای شو محتوا په مستقیم ډول لرې کړو او ګناهکار حسابونه بند کړو. د هغو محتویاتو لپاره چې له دریمو ډلو خدماتو څخه راځي، موږ په فعاله توګه د هغو په {{appName}} کې له ښودلو څخه مخنیوی کوو.
|
||||
|
||||
## ممنوع محتوا او چلند
|
||||
|
||||
@@ -33,8 +33,8 @@ _وروستی تازه: د ۲۰۲۶ کال د مارچ ۱۹مه_
|
||||
|
||||
- **د محتوا فلټر:** موږ د اپلیکیشن دننه د محتوا د فلټرولو میکانیزمونه ساتو او پلي کوو ترڅو د پېژندلشویو CSAE موادو د ښودلو مخنیوی وکړو، له هر هغه رلی پرته چې پکې راځي.
|
||||
- **د کاروونکو راپور ورکونه:** موږ د اپلیکیشن دننه د راپور ورکولو وسیلې وړاندې کوو چې کاروونکو ته اجازه ورکوي د سمدستي بیاکتنې لپاره د CSAE شکمنه محتوا نښه کړي.
|
||||
- **د Agora رلی نظارت:** زموږ په خپل Agora رلی کې موږ په فعاله توګه پر محتوا څار کوو او هر ډول CSAE مواد به سمدستي لرې کړو، او ورته حسابونه به دایمي بند کړو.
|
||||
- **د Agora Blossom سرور نظارت:** زموږ په خپل Agora Blossom فایل سرور کې، موږ به سمدستي هر ډول CSAE رسنۍ حذف کړو او هغه حساب چې یې اپلوډ کړی بند کړو.
|
||||
- **د Eranos رلی نظارت:** زموږ په خپل Eranos رلی کې موږ په فعاله توګه پر محتوا څار کوو او هر ډول CSAE مواد به سمدستي لرې کړو، او ورته حسابونه به دایمي بند کړو.
|
||||
- **د Eranos Blossom سرور نظارت:** زموږ په خپل Eranos Blossom فایل سرور کې، موږ به سمدستي هر ډول CSAE رسنۍ حذف کړو او هغه حساب چې یې اپلوډ کړی بند کړو.
|
||||
- **د دریمو ډلو رلیګانو بندول:** هغه د دریمو ډلو رلیګانې چې پېژندل شوې وي چې د CSAE مواد یې میزباني کوي یا یې زغمي، ممکن د {{appName}} د ډیفالټ رلیګانو له لیست څخه لرې شي او کاروونکي د هغوی له اضافه کولو څخه منع شي.
|
||||
- **د چوپتیا او بندولو وسیلې:** کاروونکي کولای شي حسابونه د کلاینټ په کچه چوپ کړي یا بند کړي، چې مخه نیسي د هغو حسابونو محتوا یې په فید کې راشي.
|
||||
|
||||
@@ -43,7 +43,7 @@ _وروستی تازه: د ۲۰۲۶ کال د مارچ ۱۹مه_
|
||||
کله چې د CSAE محتوا یا چلند وپېژندل شي، {{appName}} به مناسب اقدامات تر سره کړي:
|
||||
|
||||
- **سمدستي د محتوا بندول:** پېژندلشوې CSAE محتوا به د محتوا فلټرونو او بلاکلیستونو له لارې په اپلیکیشن کې د ښودلو څخه مخنیوی وشي.
|
||||
- **د Agora زیربنا څخه لرې کول:** د Agora رلی او Agora Blossom سرور باندې د CSAE محتوا به سمدستي حذف شي او ورته حسابونه به دایمي بند شي.
|
||||
- **د Eranos زیربنا څخه لرې کول:** د Eranos رلی او Eranos Blossom سرور باندې د CSAE محتوا به سمدستي حذف شي او ورته حسابونه به دایمي بند شي.
|
||||
- **د حسابونو بندول:** د CSAE فعالیت سره تړلې د Nostr عمومي کليګانې به د اپلیکیشن په کچه بلاکلیستونو ته اضافه شي چې د هغو محتوا په {{appName}} کې له ښودلو څخه مخنیوی وکړي، بې له پامه چې له کوم رلی څخه راوړل کېږي.
|
||||
- **د رلی بندول:** هغه د دریمو ډلو رلیګانې چې د CSAE محتوا ته رسیدنه ونه کړي، ممکن د {{appName}} د ډیفالټ رلیګانو له لیست څخه لرې شي او کاروونکي د هغو له اضافه کولو څخه منع شي.
|
||||
- **چارواکو ته راپور:** موږ به پېژندلشوي CSAE مواد د CyberTipline له لارې [د بېسرنوشت او استثمار شویو ماشومانو ملي مرکز (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) ته او اړوندو قانون پلي کوونکو ادارو ته راپور کړو.
|
||||
@@ -63,7 +63,7 @@ _وروستی تازه: د ۲۰۲۶ کال د مارچ ۱۹مه_
|
||||
|
||||
{{appName}} ژمن دی چې له هغو قانون پلي کوونکو ادارو سره چې د CSAE په اړه څیړنه کوي، بشپړ همکاري وکړي. که څه هم {{appName}} د کاروونکو محتوا په خپلو سرورونو کې نه ساتي، خو موږ به:
|
||||
|
||||
- له موږ سره موجود هرې معلومات — په شمول د Agora رلی او Agora Blossom سرور څخه — چې د څیړنو سره مرسته کولی شي، د قابل پلي قانون مطابق وړاندې کړو.
|
||||
- له موږ سره موجود هرې معلومات — په شمول د Eranos رلی او Eranos Blossom سرور څخه — چې د څیړنو سره مرسته کولی شي، د قابل پلي قانون مطابق وړاندې کړو.
|
||||
- هغه ځانګړي د رلی URL او د فایل سرور URL پېژندو او شریکوو چې ګناهکاره محتوا پکې لیدل شوې وي، ترڅو قانون پلي کوونکي په مستقیم ډول د هغو چلونکو سره اړیکه ونیسي.
|
||||
- د یوې اعتباري قانوني غوښتنې له ترلاسه کولو وروسته به هرې شته شواهد یا معلومات وساتو.
|
||||
- پېژندلشوي CSAE مواد به په فعاله توګه NCMEC او نورو اړوندو چارواکو ته راپور کړو.
|
||||
@@ -72,7 +72,7 @@ _وروستی تازه: د ۲۰۲۶ کال د مارچ ۱۹مه_
|
||||
|
||||
د Nostr نامرکزي طبیعت یې پدې مانا دی چې هیڅ یوازینی موجود د شبکې پر ټولې محتوا بشپړ کنټرول نه لري. {{appName}} لاندې واقعیات او زموږ تګلاره یې د هر یوه په وړاندې منلې:
|
||||
|
||||
- **زموږ پر خپله زیربنا بشپړ کنټرول:** موږ کولای شو او کوو چې محتوا د Agora رلی او Agora Blossom سرور څخه لرې کړو. زموږ په زیربنا کې میندلشوي CSAE مواد سمدستي حذف کېږي او حسابونه دایمي بندېږي.
|
||||
- **زموږ پر خپله زیربنا بشپړ کنټرول:** موږ کولای شو او کوو چې محتوا د Eranos رلی او Eranos Blossom سرور څخه لرې کړو. زموږ په زیربنا کې میندلشوي CSAE مواد سمدستي حذف کېږي او حسابونه دایمي بندېږي.
|
||||
- **پر دریمو ډلو رلیګانو محدود کنټرول:** موږ نشو کولای د دریمو ډلو رلیګانو محتوا حذف کړو. خو موږ زموږ په اپلیکیشن دننه د کلاینټ په کچه فلټرونو او بلاکلیستونو له لارې د دغه محتوا د ښودلو مخه نیسو.
|
||||
- **کاروونکي خپلې رلی اړیکې کنټرولوي:** که څه هم کاروونکي کولای شي خپلې غوره رلیګانې وکاروي، {{appName}} دا حق ساتي چې له هغو رلیګانو سره اړیکه بنده کړي چې پېژندل شوي وي چې CSAE محتوا میزباني کوي.
|
||||
- **عمومي کليګانې نام مستعار دي:** د Nostr حسابونه د کرپټوګرافیک کليګانو جوړو لخوا پېژندل کېږي نه د تائید شویو هویتونو لخوا. خو بیا هم موږ به ګناهکار کليګانې بنده او راپور کړو او د دوی شاته اشخاصو د پېژندنې لپاره به له قانون پلي کوونکو سره همکاري وکړو.
|
||||
|
||||
@@ -10,11 +10,11 @@ Esta política se aplica a todo o conteúdo acessível através do {{appName}},
|
||||
|
||||
O {{appName}} é um **aplicativo cliente** para o protocolo Nostr, uma rede de comunicação aberta e descentralizada. Entender a arquitetura é um contexto importante para esta política:
|
||||
|
||||
- **Nossa infraestrutura:** Operamos o **relay Agora** e o **servidor Blossom Agora**, que servem como o relay e host de arquivos padrão para o {{appName}}. Temos controle total de moderação sobre o conteúdo armazenado nesses serviços.
|
||||
- **Nossa infraestrutura:** Operamos o **relay Eranos** e o **servidor Blossom Eranos**, que servem como o relay e host de arquivos padrão para o {{appName}}. Temos controle total de moderação sobre o conteúdo armazenado nesses serviços.
|
||||
- **Relays de terceiros:** Os usuários também podem se conectar a relays Nostr adicionais operados por terceiros independentes. O {{appName}} busca e exibe conteúdo de quaisquer relays aos quais o usuário esteja conectado. Não temos controle de moderação sobre relays de terceiros, mas controlamos o que o aplicativo exibe.
|
||||
- **Servidores de mídia de terceiros:** Os usuários podem enviar imagens e vídeos para servidores de arquivos compatíveis com Blossom de terceiros. Não operamos ou moderamos esses serviços externos.
|
||||
|
||||
Assumimos total responsabilidade pela experiência dentro do nosso aplicativo. Em nossa própria infraestrutura (relay Agora e servidor Blossom Agora), podemos remover diretamente o conteúdo e banir contas infratoras. Para conteúdo originário de serviços de terceiros, ativamente o bloqueamos de ser exibido dentro do {{appName}}.
|
||||
Assumimos total responsabilidade pela experiência dentro do nosso aplicativo. Em nossa própria infraestrutura (relay Eranos e servidor Blossom Eranos), podemos remover diretamente o conteúdo e banir contas infratoras. Para conteúdo originário de serviços de terceiros, ativamente o bloqueamos de ser exibido dentro do {{appName}}.
|
||||
|
||||
## Conteúdo e comportamento proibidos
|
||||
|
||||
@@ -33,8 +33,8 @@ O {{appName}} implementa múltiplas camadas de proteção para combater CSAE:
|
||||
|
||||
- **Filtragem de conteúdo:** Mantemos e aplicamos mecanismos de filtragem de conteúdo dentro do aplicativo para bloquear material CSAE conhecido de ser exibido, independentemente de qual relay seja originário.
|
||||
- **Denúncia por usuários:** Fornecemos ferramentas de denúncia no aplicativo que permitem aos usuários sinalizar conteúdo CSAE suspeito para revisão imediata.
|
||||
- **Moderação do relay Agora:** Em nosso próprio relay Agora, moderamos ativamente o conteúdo e removeremos imediatamente qualquer material CSAE e baniremos permanentemente as contas associadas.
|
||||
- **Moderação do servidor Blossom Agora:** Em nosso próprio servidor de arquivos Blossom Agora, excluiremos imediatamente qualquer mídia CSAE e baniremos a conta que fez o upload.
|
||||
- **Moderação do relay Eranos:** Em nosso próprio relay Eranos, moderamos ativamente o conteúdo e removeremos imediatamente qualquer material CSAE e baniremos permanentemente as contas associadas.
|
||||
- **Moderação do servidor Blossom Eranos:** Em nosso próprio servidor de arquivos Blossom Eranos, excluiremos imediatamente qualquer mídia CSAE e baniremos a conta que fez o upload.
|
||||
- **Bloqueio de relays de terceiros:** Relays de terceiros conhecidos por hospedar ou tolerar material CSAE podem ser removidos da lista de relays padrão do {{appName}} e bloqueados de serem adicionados por usuários.
|
||||
- **Ferramentas de silenciar e bloquear:** Os usuários podem silenciar ou bloquear contas no nível do cliente, impedindo que o conteúdo dessas contas apareça em seu feed.
|
||||
|
||||
@@ -43,7 +43,7 @@ O {{appName}} implementa múltiplas camadas de proteção para combater CSAE:
|
||||
Quando conteúdo ou comportamento CSAE é identificado, o {{appName}} tomará as seguintes ações conforme aplicável:
|
||||
|
||||
- **Bloqueio imediato de conteúdo:** Conteúdo CSAE conhecido será bloqueado de renderizar no aplicativo através de filtros de conteúdo e listas de bloqueio.
|
||||
- **Remoção da infraestrutura Agora:** Conteúdo CSAE no relay Agora e no servidor Blossom Agora será imediatamente excluído, e as contas associadas permanentemente banidas.
|
||||
- **Remoção da infraestrutura Eranos:** Conteúdo CSAE no relay Eranos e no servidor Blossom Eranos será imediatamente excluído, e as contas associadas permanentemente banidas.
|
||||
- **Bloqueio de contas:** Chaves públicas Nostr associadas à atividade CSAE serão adicionadas a listas de bloqueio em nível de aplicativo, impedindo que seu conteúdo apareça no {{appName}} independentemente de qual relay seja buscado.
|
||||
- **Bloqueio de relays:** Relays de terceiros que falham em abordar conteúdo CSAE podem ser removidos da lista de relays padrão do {{appName}} e bloqueados de serem adicionados por usuários.
|
||||
- **Denúncia às autoridades:** Denunciaremos material CSAE identificado ao [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) através da CyberTipline, e às agências de aplicação da lei aplicáveis.
|
||||
@@ -63,7 +63,7 @@ Todas as denúncias de conteúdo CSAE são tratadas com a maior prioridade e ser
|
||||
|
||||
O {{appName}} está comprometido em cooperar totalmente com agências de aplicação da lei investigando CSAE. Embora o {{appName}} não armazene conteúdo de usuário em seus próprios servidores, nós:
|
||||
|
||||
- Forneceremos qualquer informação disponível para nós — incluindo dados do relay Agora e do servidor Blossom Agora — que possa ajudar em investigações, de acordo com a lei aplicável.
|
||||
- Forneceremos qualquer informação disponível para nós — incluindo dados do relay Eranos e do servidor Blossom Eranos — que possa ajudar em investigações, de acordo com a lei aplicável.
|
||||
- Identificaremos e compartilharemos as URLs específicas de relays e servidores de arquivos onde o conteúdo infrator foi observado, para que as autoridades policiais possam contatar esses operadores diretamente.
|
||||
- Preservaremos qualquer evidência ou informação disponível ao receber uma solicitação legal válida.
|
||||
- Denunciaremos material CSAE identificado ao NCMEC e outras autoridades relevantes proativamente.
|
||||
@@ -72,7 +72,7 @@ O {{appName}} está comprometido em cooperar totalmente com agências de aplica
|
||||
|
||||
A natureza descentralizada do Nostr significa que nenhuma entidade única tem controle completo sobre todo o conteúdo na rede. O {{appName}} reconhece as seguintes realidades e nossa abordagem para cada uma:
|
||||
|
||||
- **Controle total sobre nossa própria infraestrutura:** Podemos e removemos conteúdo do relay Agora e do servidor Blossom Agora. Material CSAE encontrado em nossa infraestrutura é excluído imediatamente e contas são permanentemente banidas.
|
||||
- **Controle total sobre nossa própria infraestrutura:** Podemos e removemos conteúdo do relay Eranos e do servidor Blossom Eranos. Material CSAE encontrado em nossa infraestrutura é excluído imediatamente e contas são permanentemente banidas.
|
||||
- **Controle limitado sobre relays de terceiros:** Não podemos excluir conteúdo de relays de terceiros. No entanto, bloqueamos esse conteúdo de ser exibido dentro do nosso aplicativo através de filtros e listas de bloqueio em nível de cliente.
|
||||
- **Usuários controlam suas conexões de relay:** Embora os usuários possam se conectar a relays de sua escolha, o {{appName}} reserva o direito de bloquear conexões a relays conhecidos por hospedar conteúdo CSAE.
|
||||
- **Chaves públicas são pseudônimas:** Contas Nostr são identificadas por pares de chaves criptográficas em vez de identidades verificadas. Ainda assim, bloquearemos e denunciaremos chaves infratoras e cooperaremos com a polícia para identificar os indivíduos por trás delas.
|
||||
|
||||
@@ -10,11 +10,11 @@ _Последнее обновление: 19 марта 2026 г._
|
||||
|
||||
{{appName}} — это **клиентское приложение** для протокола Nostr, открытой децентрализованной коммуникационной сети. Понимание архитектуры — важный контекст для этой политики:
|
||||
|
||||
- **Наша инфраструктура:** Мы управляем **реле Agora** и **сервером Blossom Agora**, которые служат реле по умолчанию и хостом файлов для {{appName}}. У нас есть полный контроль модерации над контентом, хранящимся на этих сервисах.
|
||||
- **Наша инфраструктура:** Мы управляем **реле Eranos** и **сервером Blossom Eranos**, которые служат реле по умолчанию и хостом файлов для {{appName}}. У нас есть полный контроль модерации над контентом, хранящимся на этих сервисах.
|
||||
- **Реле третьих сторон:** Пользователи также могут подключаться к дополнительным реле Nostr, управляемым независимыми третьими сторонами. {{appName}} получает и отображает контент с любых реле, к которым подключён пользователь. У нас нет контроля модерации над реле третьих сторон, но мы контролируем то, что отображает приложение.
|
||||
- **Медиа-серверы третьих сторон:** Пользователи могут загружать изображения и видео на сторонние файловые серверы, совместимые с Blossom. Мы не управляем и не модерируем эти внешние сервисы.
|
||||
|
||||
Мы берём на себя полную ответственность за опыт внутри нашего приложения. На нашей собственной инфраструктуре (реле Agora и сервер Blossom Agora) мы можем напрямую удалять контент и банить аккаунты-нарушители. Для контента, происходящего из сервисов третьих сторон, мы активно блокируем его отображение в {{appName}}.
|
||||
Мы берём на себя полную ответственность за опыт внутри нашего приложения. На нашей собственной инфраструктуре (реле Eranos и сервер Blossom Eranos) мы можем напрямую удалять контент и банить аккаунты-нарушители. Для контента, происходящего из сервисов третьих сторон, мы активно блокируем его отображение в {{appName}}.
|
||||
|
||||
## Запрещённый контент и поведение
|
||||
|
||||
@@ -33,8 +33,8 @@ _Последнее обновление: 19 марта 2026 г._
|
||||
|
||||
- **Фильтрация контента:** Мы поддерживаем и обеспечиваем работу механизмов фильтрации контента внутри приложения, чтобы блокировать отображение известных материалов CSAE, независимо от того, с какого реле они происходят.
|
||||
- **Сообщения пользователей:** Мы предоставляем встроенные в приложение инструменты сообщений, которые позволяют пользователям отмечать подозрительный контент CSAE для немедленной проверки.
|
||||
- **Модерация реле Agora:** На нашем собственном реле Agora мы активно модерируем контент и немедленно удаляем любые материалы CSAE и навсегда баним связанные аккаунты.
|
||||
- **Модерация сервера Blossom Agora:** На нашем собственном файловом сервере Blossom Agora мы немедленно удалим любые медиа CSAE и забаним загрузивший аккаунт.
|
||||
- **Модерация реле Eranos:** На нашем собственном реле Eranos мы активно модерируем контент и немедленно удаляем любые материалы CSAE и навсегда баним связанные аккаунты.
|
||||
- **Модерация сервера Blossom Eranos:** На нашем собственном файловом сервере Blossom Eranos мы немедленно удалим любые медиа CSAE и забаним загрузивший аккаунт.
|
||||
- **Блокировка реле третьих сторон:** Реле третьих сторон, известные тем, что размещают или терпят материалы CSAE, могут быть удалены из списка реле {{appName}} по умолчанию и заблокированы для добавления пользователями.
|
||||
- **Инструменты заглушения и блокировки:** Пользователи могут заглушать или блокировать аккаунты на уровне клиента, предотвращая появление контента с этих аккаунтов в их ленте.
|
||||
|
||||
@@ -43,7 +43,7 @@ _Последнее обновление: 19 марта 2026 г._
|
||||
При обнаружении контента или поведения CSAE {{appName}} предпримет следующие меры по мере необходимости:
|
||||
|
||||
- **Немедленная блокировка контента:** Известный контент CSAE будет заблокирован от отображения в приложении через фильтры контента и списки блокировки.
|
||||
- **Удаление с инфраструктуры Agora:** Контент CSAE на реле Agora и сервере Blossom Agora будет немедленно удалён, а связанные аккаунты навсегда забанены.
|
||||
- **Удаление с инфраструктуры Eranos:** Контент CSAE на реле Eranos и сервере Blossom Eranos будет немедленно удалён, а связанные аккаунты навсегда забанены.
|
||||
- **Блокировка аккаунтов:** Публичные ключи Nostr, связанные с активностью CSAE, будут добавлены в списки блокировки на уровне приложения, предотвращая появление их контента в {{appName}} независимо от того, с какого реле он получен.
|
||||
- **Блокировка реле:** Реле третьих сторон, которые не справляются с контентом CSAE, могут быть удалены из списка реле {{appName}} по умолчанию и заблокированы для добавления пользователями.
|
||||
- **Сообщение властям:** Мы будем сообщать об идентифицированных материалах CSAE в [Национальный центр для пропавших и эксплуатируемых детей (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) через CyberTipline и в применимые правоохранительные органы.
|
||||
@@ -63,7 +63,7 @@ _Последнее обновление: 19 марта 2026 г._
|
||||
|
||||
{{appName}} обязуется полностью сотрудничать с правоохранительными органами, расследующими CSAE. Хотя {{appName}} не хранит пользовательский контент на собственных серверах, мы:
|
||||
|
||||
- Предоставим любую доступную нам информацию — включая данные с реле Agora и сервера Blossom Agora — которая может помочь в расследованиях, в соответствии с применимым законодательством.
|
||||
- Предоставим любую доступную нам информацию — включая данные с реле Eranos и сервера Blossom Eranos — которая может помочь в расследованиях, в соответствии с применимым законодательством.
|
||||
- Идентифицируем и поделимся конкретными URL реле и серверов файлов, где был обнаружен нарушающий контент, чтобы правоохранительные органы могли напрямую связаться с этими операторами.
|
||||
- Сохраним любые доступные доказательства или информацию по получении действительного юридического запроса.
|
||||
- Будем проактивно сообщать об идентифицированных материалах CSAE в NCMEC и другие соответствующие органы.
|
||||
@@ -72,7 +72,7 @@ _Последнее обновление: 19 марта 2026 г._
|
||||
|
||||
Децентрализованная природа Nostr означает, что ни одно отдельное юридическое лицо не имеет полного контроля над всем контентом в сети. {{appName}} признаёт следующие реалии и наш подход к каждой из них:
|
||||
|
||||
- **Полный контроль над нашей собственной инфраструктурой:** Мы можем и удаляем контент с реле Agora и сервера Blossom Agora. Материалы CSAE, найденные на нашей инфраструктуре, удаляются немедленно, а аккаунты навсегда банятся.
|
||||
- **Полный контроль над нашей собственной инфраструктурой:** Мы можем и удаляем контент с реле Eranos и сервера Blossom Eranos. Материалы CSAE, найденные на нашей инфраструктуре, удаляются немедленно, а аккаунты навсегда банятся.
|
||||
- **Ограниченный контроль над реле третьих сторон:** Мы не можем удалить контент с реле третьих сторон. Однако мы блокируем такой контент от отображения в нашем приложении через фильтры на уровне клиента и списки блокировки.
|
||||
- **Пользователи контролируют свои подключения к реле:** Хотя пользователи могут подключаться к реле по своему выбору, {{appName}} оставляет за собой право блокировать подключения к реле, известным тем, что размещают контент CSAE.
|
||||
- **Публичные ключи являются псевдонимами:** Аккаунты Nostr идентифицируются криптографическими парами ключей, а не проверенными идентичностями. Мы всё равно будем блокировать и сообщать о нарушающих ключах и сотрудничать с правоохранительными органами для идентификации лиц, стоящих за ними.
|
||||
|
||||
@@ -10,11 +10,11 @@ Mutemo uyu unoshanda kune zvese zvinooneka kuburikidza ne{{appName}}, kusanganis
|
||||
|
||||
{{appName}} **chishandiso chemutengi** chepurotokoro yeNostr, donhodzo rakavhurika risina nzvimbo huru rekurukurirano. Kunzwisisa zvakavakwa kwacho izvozvo zvakakosha kumutemo uyu:
|
||||
|
||||
- **Zvivakwa zvedu:** Tinochengeta **rerai yeAgora** ne**Agora Blossom server**, izvo zvinoshanda serai rinozvimirira nemuchengeti wefaira we{{appName}}. Tine simba rakazara rekutarisira zvinhu zvakachengetwa pamabasa aya.
|
||||
- **Zvivakwa zvedu:** Tinochengeta **rerai yeEranos** ne**Eranos Blossom server**, izvo zvinoshanda serai rinozvimirira nemuchengeti wefaira we{{appName}}. Tine simba rakazara rekutarisira zvinhu zvakachengetwa pamabasa aya.
|
||||
- **Marerai evevamwe:** Vashandisi vanogona kubatanidzwawo neimwe Nostr rerai inoshandiswa nevamwe vakazvimirira. {{appName}} inotora uye inoratidza zvinhu zvinobva kumarerai api zvawo aakabatana navo mushandisi. Hatina simba rekutarisira marerai evevamwe, asi tinotonga zvinoratidzwa nechishandiso.
|
||||
- **Maservers emawero evamwe:** Vashandisi vanogona kutumira mifananidzo nemavhidhio kuwakavandudzika neBlossom maservers evevamwe. Hatishandise kana kutarisira aya mabasa ekunze.
|
||||
|
||||
Tinotora mutoro wakakwana wezvinoitika muchishandiso chedu. Pazvivakwa zvedu (Agora rerai uye Agora Blossom server), tinogona kubvisa zvinhu zvakananga nekudzima makaundi anodarika. Kune zvinhu zvinobva kumabasa evevamwe, tinotambira kuvharira kuratidzwa kwawo mukati me{{appName}}.
|
||||
Tinotora mutoro wakakwana wezvinoitika muchishandiso chedu. Pazvivakwa zvedu (Eranos rerai uye Eranos Blossom server), tinogona kubvisa zvinhu zvakananga nekudzima makaundi anodarika. Kune zvinhu zvinobva kumabasa evevamwe, tinotambira kuvharira kuratidzwa kwawo mukati me{{appName}}.
|
||||
|
||||
## Zvinhu Nezvinoitika Zvisingatenderwi
|
||||
|
||||
@@ -33,8 +33,8 @@ Zvinotevera zvakanyatsobviswa pa{{appName}}. Vashandisi vanowanikwa vachiita che
|
||||
|
||||
- **Kusefa kwezvinhu:** Tinochengetedza nekushandisa nzira dzekusefa zvinhu mukati mechishandiso kudzivirira mishonga yeCSAE inozivikanwa pakuratidzwa, pasinei kuti inobva kurerai ipi zvayo.
|
||||
- **Kushuma kwemushandisi:** Tinopa zvishandiso zvekushuma mukati meapp kupa mvumo kuvashandisi kupa chiratidzo kuzvinhu zvinofungidzirwa zveCSAE kuti zvionongorwe pakarepo.
|
||||
- **Kutarisira kweAgora rerai:** Pa-Agora rerai redu pachedu, tinotarisira zvinhu nemwoyo wese uye ticharumura chero mishonga yeCSAE pakarepo nekuvharira makaundi anobatana nokusingaperi.
|
||||
- **Kutarisira kweAgora Blossom server:** Pasever yedu yefaira yeAgora Blossom, tichabvisa chero mediya yeCSAE pakarepo uye kuvharira akaundi rakaiteresera.
|
||||
- **Kutarisira kweEranos rerai:** Pa-Eranos rerai redu pachedu, tinotarisira zvinhu nemwoyo wese uye ticharumura chero mishonga yeCSAE pakarepo nekuvharira makaundi anobatana nokusingaperi.
|
||||
- **Kutarisira kweEranos Blossom server:** Pasever yedu yefaira yeEranos Blossom, tichabvisa chero mediya yeCSAE pakarepo uye kuvharira akaundi rakaiteresera.
|
||||
- **Kuvharira marerai evevamwe:** Marerai evevamwe anozivikanwa kuti ano-host kana ano-tolerate mishonga yeCSAE anogona kubviswa pa-default rerai list ye{{appName}} uye kuvharirwa kubva kuwedzerwa nevashandisi.
|
||||
- **Zvishandiso zvekuvharira nekunyaradza:** Vashandisi vanogona kunyaradza kana kuvharira makaundi pamutsetse wemutengi, vachidzivirira zvinhu kubva kumakaundi iwayo kuti zvisaonekwe mufid yavo.
|
||||
|
||||
@@ -43,7 +43,7 @@ Zvinotevera zvakanyatsobviswa pa{{appName}}. Vashandisi vanowanikwa vachiita che
|
||||
Kana zvinhu kana maitiro eCSAE awanikwa, {{appName}} ichatora zviito zvinotevera sezvinodikanwa:
|
||||
|
||||
- **Kuvharira kwezvinhu pakarepo:** Zvinhu zveCSAE zvinozivikanwa zvichavharirwa kubva pakuratidzwa muchishandiso kuburikidza nezvisefedzo zvezvinhu uye marondedzero ekuvharira.
|
||||
- **Kubvisa kubva muzvivakwa zveAgora:** Zvinhu zveCSAE pa-Agora rerai uye Agora Blossom server zvichabviswa pakarepo, uye makaundi anobatana acharamwadzwa nokusingaperi.
|
||||
- **Kubvisa kubva muzvivakwa zveEranos:** Zvinhu zveCSAE pa-Eranos rerai uye Eranos Blossom server zvichabviswa pakarepo, uye makaundi anobatana acharamwadzwa nokusingaperi.
|
||||
- **Kuvharira makaundi:** Makey everuzhinji eNostr anobatana neuchengetedzi hweCSAE acharangarwa kumarondedzero ekuvharira mutsetse weapp, achidzivirira zvinhu zvavo kubva pakuoneka mu{{appName}} pasinei kuti zvinobva kurerai ipi zvayo.
|
||||
- **Kuvharira rerai:** Marerai evevamwe anokundikana kugadzirisa zvinhu zveCSAE anogona kubviswa kubva pa-default rerai list ye{{appName}} uye kuvharirwa kubva kuwedzerwa nevashandisi.
|
||||
- **Kushuma kuvakuru:** Tichashuma mishonga yeCSAE yakaonekwa ku[National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline) kuburikidza neCyberTipline, uye kumamishonga ekushandisa mutemo ane simba.
|
||||
@@ -63,7 +63,7 @@ Shumo dzese dzezvinhu zveCSAE dzinobatwa nehurongwa hwepamusoro uye dzichaongoro
|
||||
|
||||
{{appName}} yakazvipira kushanda pamwe zvakakwana nevemishonga yekushandisa mutemo iri kuferefeta CSAE. Kunyange {{appName}} isingachengeti zvinhu zvemushandisi pamaserver ayo pachayo, tichaita zvinotevera:
|
||||
|
||||
- Kupa chero ruzivo runowanikwa kwatiri — kusanganisira data inobva kuAgora rerai uye Agora Blossom server — inogona kubatsira kuferefeta, maererano nemutemo unoshandiswa.
|
||||
- Kupa chero ruzivo runowanikwa kwatiri — kusanganisira data inobva kuEranos rerai uye Eranos Blossom server — inogona kubatsira kuferefeta, maererano nemutemo unoshandiswa.
|
||||
- Kuziva nekugovera maURL chaiwo erelay nemaURL emaservers efaira kwakaoneswa zvinhu zvinodarika, kuitira kuti vekushandisa mutemo vagone kubata zvakananga nevashandisi ivavo.
|
||||
- Kuchengetedza chero humbowo kana ruzivo runowanikwa pakugamuchira chikumbiro chemutemo chinokwanisika.
|
||||
- Kushumira mishonga yeCSAE yakaonekwa kuNCMEC nevamwe vakuru vakakodzera nemwoyo wese.
|
||||
@@ -72,7 +72,7 @@ Shumo dzese dzezvinhu zveCSAE dzinobatwa nehurongwa hwepamusoro uye dzichaongoro
|
||||
|
||||
Hunhu hwekusava nenzvimbo huru hweNostr kunoreva kuti hapana boka rimwe rine simba rakakwana pamusoro pezvinhu zvese pasaiti. {{appName}} inogamuchira chokwadi chinotevera nenzira yedu kune chimwe nechimwe:
|
||||
|
||||
- **Simba rakakwana pamusoro pezvivakwa zvedu pachedu:** Tinogona uye tinobvisa zvinhu kubva paAgora rerai uye Agora Blossom server. Mishonga yeCSAE yakawanikwa pazvivakwa zvedu inobviswa pakarepo uye makaundi anorambwa nokusingaperi.
|
||||
- **Simba rakakwana pamusoro pezvivakwa zvedu pachedu:** Tinogona uye tinobvisa zvinhu kubva paEranos rerai uye Eranos Blossom server. Mishonga yeCSAE yakawanikwa pazvivakwa zvedu inobviswa pakarepo uye makaundi anorambwa nokusingaperi.
|
||||
- **Simba rakangotemerwa pamarerai evevamwe:** Hatigone kubvisa zvinhu kubva kumarerai evevamwe. Asi, tinovharira zvinhu zvakadaro kubva pakuratidzwa mukati meapp yedu kuburikidza nezvisefedzo nezvirondedzero zvekuvharira pamutsetse wemutengi.
|
||||
- **Vashandisi vanodzora kubatana kwavo kurerai:** Kunyange vashandisi vachigona kubatana kumarerai avanenge vasarudza, {{appName}} inochengetedza kodzero yekuvharira kubatana kumarerai anozivikanwa kuti ano-host zvinhu zveCSAE.
|
||||
- **Makey everuzhinji mazita ekunyepedzera:** Makaundi eNostr anoonekwa nemapeya emakey ekriptiografi, pane hunhu hwakasimbiswa. Asi bedzi tichavharira nekushumira makey anodarika uye kushanda pamwe nevekushandisa mutemo kuti tizive vanhu vari kumashure kwawo.
|
||||
|
||||
@@ -10,11 +10,11 @@ _最后更新:2026 年 3 月 19 日_
|
||||
|
||||
{{appName}} 是 Nostr 协议的**客户端应用**,Nostr 是一个开放、去中心化的通信网络。理解其架构对于本政策的语境十分重要:
|
||||
|
||||
- **我们的基础设施:** 我们运营 **Agora 中继**和 **Agora Blossom 服务器**,作为 {{appName}} 的默认中继和文件托管服务。我们对存储在这些服务上的内容拥有完整的审核控制权。
|
||||
- **我们的基础设施:** 我们运营 **Eranos 中继**和 **Eranos Blossom 服务器**,作为 {{appName}} 的默认中继和文件托管服务。我们对存储在这些服务上的内容拥有完整的审核控制权。
|
||||
- **第三方中继:** 用户也可以连接由独立第三方运营的其他 Nostr 中继。{{appName}} 会从用户所连接的任意中继获取并渲染内容。我们对第三方中继没有审核控制权,但可以控制应用所显示的内容。
|
||||
- **第三方媒体服务器:** 用户可以将图片和视频上传至与 Blossom 兼容的第三方文件服务器。我们既不运营也不审核这些外部服务。
|
||||
|
||||
我们对应用内的体验承担全部责任。在我们自有的基础设施(Agora 中继和 Agora Blossom 服务器)上,我们可以直接删除内容并封禁违规账户。对于源自第三方服务的内容,我们会主动阻止其在 {{appName}} 中显示。
|
||||
我们对应用内的体验承担全部责任。在我们自有的基础设施(Eranos 中继和 Eranos Blossom 服务器)上,我们可以直接删除内容并封禁违规账户。对于源自第三方服务的内容,我们会主动阻止其在 {{appName}} 中显示。
|
||||
|
||||
## 禁止的内容与行为
|
||||
|
||||
@@ -33,8 +33,8 @@ _最后更新:2026 年 3 月 19 日_
|
||||
|
||||
- **内容过滤:** 我们在应用内维护并执行内容过滤机制,无论内容来源于哪个中继,都会阻止已知 CSAE 材料被显示。
|
||||
- **用户举报:** 我们在应用内提供举报工具,允许用户标记疑似 CSAE 内容以便立即审核。
|
||||
- **Agora 中继审核:** 在我们自有的 Agora 中继上,我们会主动审核内容,并立即移除任何 CSAE 材料、永久封禁相关账户。
|
||||
- **Agora Blossom 服务器审核:** 在我们自有的 Agora Blossom 文件服务器上,我们会立即删除任何 CSAE 媒体并封禁上传账户。
|
||||
- **Eranos 中继审核:** 在我们自有的 Eranos 中继上,我们会主动审核内容,并立即移除任何 CSAE 材料、永久封禁相关账户。
|
||||
- **Eranos Blossom 服务器审核:** 在我们自有的 Eranos Blossom 文件服务器上,我们会立即删除任何 CSAE 媒体并封禁上传账户。
|
||||
- **第三方中继封锁:** 已知托管或容忍 CSAE 材料的第三方中继可能会被从 {{appName}} 的默认中继列表中移除,并被阻止用户添加。
|
||||
- **静音与屏蔽工具:** 用户可以在客户端层面静音或屏蔽账户,防止这些账户的内容出现在其信息流中。
|
||||
|
||||
@@ -43,7 +43,7 @@ _最后更新:2026 年 3 月 19 日_
|
||||
当识别到 CSAE 内容或行为时,{{appName}} 将视情况采取以下行动:
|
||||
|
||||
- **立即阻止内容:** 已知的 CSAE 内容将通过内容过滤器和封锁列表被阻止在应用中渲染。
|
||||
- **从 Agora 基础设施中移除:** Agora 中继和 Agora Blossom 服务器上的 CSAE 内容将被立即删除,相关账户被永久封禁。
|
||||
- **从 Eranos 基础设施中移除:** Eranos 中继和 Eranos Blossom 服务器上的 CSAE 内容将被立即删除,相关账户被永久封禁。
|
||||
- **封禁账户:** 与 CSAE 活动相关联的 Nostr 公钥将被加入应用级封锁列表,无论其内容来自哪个中继,都将无法在 {{appName}} 中显示。
|
||||
- **封锁中继:** 未能处理 CSAE 内容的第三方中继可能会被从 {{appName}} 的默认中继列表中移除,并被阻止用户添加。
|
||||
- **向当局举报:** 我们会通过 CyberTipline 将已识别的 CSAE 材料举报给 [国家失踪与受剥削儿童中心(NCMEC)](https://www.missingkids.org/gethelpnow/cybertipline),并通报相关执法机构。
|
||||
@@ -63,7 +63,7 @@ _最后更新:2026 年 3 月 19 日_
|
||||
|
||||
{{appName}} 承诺与调查 CSAE 的执法机构充分合作。尽管 {{appName}} 并不在自有服务器上存储用户内容,我们仍将:
|
||||
|
||||
- 在适用法律允许的范围内,提供我们所掌握的任何信息——包括来自 Agora 中继和 Agora Blossom 服务器的数据——以协助调查。
|
||||
- 在适用法律允许的范围内,提供我们所掌握的任何信息——包括来自 Eranos 中继和 Eranos Blossom 服务器的数据——以协助调查。
|
||||
- 识别并分享观察到违规内容的具体中继 URL 与文件服务器 URL,使执法机构可以直接联系这些运营方。
|
||||
- 在收到合法的法律请求后,保存任何可获得的证据或信息。
|
||||
- 主动向 NCMEC 及其他相关当局举报已识别的 CSAE 材料。
|
||||
@@ -72,7 +72,7 @@ _最后更新:2026 年 3 月 19 日_
|
||||
|
||||
Nostr 的去中心化特性意味着没有任何单一实体能够完全控制网络上的所有内容。{{appName}} 承认以下现实,以及我们对每一项现实的应对方式:
|
||||
|
||||
- **对自有基础设施的完整控制:** 我们能够并且会从 Agora 中继和 Agora Blossom 服务器上移除内容。在我们的基础设施上发现的 CSAE 材料将被立即删除,账户被永久封禁。
|
||||
- **对自有基础设施的完整控制:** 我们能够并且会从 Eranos 中继和 Eranos Blossom 服务器上移除内容。在我们的基础设施上发现的 CSAE 材料将被立即删除,账户被永久封禁。
|
||||
- **对第三方中继的有限控制:** 我们无法删除第三方中继上的内容。但是,我们会通过客户端层面的过滤器和封锁列表,阻止此类内容在应用中显示。
|
||||
- **用户控制其中继连接:** 虽然用户可以连接到自行选择的中继,但 {{appName}} 保留对已知托管 CSAE 内容的中继切断连接的权利。
|
||||
- **公钥是假名:** Nostr 账户由密码学密钥对而非已验证身份进行标识。即便如此,我们仍会封禁并举报违规公钥,并与执法部门合作以识别其背后的个人。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user