Compare commits
254 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00509f979a | |||
| a7550f3e49 | |||
| 65e9bd72a1 | |||
| a0c3e34e14 | |||
| f3b95157dc | |||
| bd8e0b5c5c | |||
| 4d827e01f4 | |||
| 4c32b93f5e | |||
| 45dae078ac | |||
| 25ef304e42 | |||
| 590e592cf0 | |||
| 7dc1afc5a1 | |||
| a42522dda2 | |||
| 9a5d3e56fe | |||
| fe43906cf1 | |||
| 0d1d782437 | |||
| 7f93dcb3af | |||
| 3b9eef908f | |||
| 948e6b70b6 | |||
| afe2bf1c28 | |||
| ae3daef072 | |||
| da6cab8784 | |||
| 2722ee1dcd | |||
| 475843cd27 | |||
| fd97b76fbb | |||
| 587d7eb5ba | |||
| 5c6b9b3baf | |||
| f6947aca9b | |||
| 4df6197a9a | |||
| 559a52f46f | |||
| c4778471bb | |||
| a3964662fa | |||
| 97cf2763a5 | |||
| 0bb55ebb97 | |||
| 5983583388 | |||
| b1d4237bee | |||
| a20a91de0d | |||
| be262fe0d6 | |||
| 8f065379a0 | |||
| 99e4fd0406 | |||
| e7f7d9419d | |||
| 907fdc1b70 | |||
| d0e8c5b64b | |||
| 904df8a776 | |||
| c085c2017f | |||
| c322f2796a | |||
| 2a66968198 | |||
| 5831013baf | |||
| 5d7547f70b | |||
| b9e82da61e | |||
| 90ebc19e79 | |||
| 9f9271cd64 | |||
| c494750efd | |||
| 1cf3646b2a | |||
| ce95c6c12c | |||
| c35a5b942c | |||
| 5f3af5e206 | |||
| 39949bb439 | |||
| 146d569b88 | |||
| 68e62ae705 | |||
| b3b9e73c9f | |||
| ac3cdf34b2 | |||
| a68cad44c3 | |||
| 743390edf7 | |||
| 558e5affea | |||
| ec7d7f4326 | |||
| 3520c0ad6b | |||
| c4f52b8aa7 | |||
| df44b1b2c6 | |||
| 8c6721a3fc | |||
| fb7676b760 | |||
| f97792b81e | |||
| f3c9a74b0f | |||
| 248b07f45c | |||
| a759e653de | |||
| 14939ff534 | |||
| c9b48aeaae | |||
| 24aa3a32d9 | |||
| 3dd229edfb | |||
| 0e13455c7a | |||
| 75a3453daa | |||
| 7d0f565101 | |||
| 3d744518b2 | |||
| d4590a9340 | |||
| 3c330efaa9 | |||
| 89c392fa63 | |||
| a76a971321 | |||
| d2eca1811d | |||
| fdb849aa1d | |||
| d3e0d177a5 | |||
| b7c88ecca8 | |||
| 09bd4096e2 | |||
| ed923bcde6 | |||
| d065580e47 | |||
| cb32405e55 | |||
| b9fee19510 | |||
| f6b209949a | |||
| 949bd5fde4 | |||
| 27d65bc389 | |||
| f2805ed9d8 | |||
| 0745d99e85 | |||
| 4bba4159f1 | |||
| 23977a64ca | |||
| c73c15de22 | |||
| a5159e040b | |||
| c2bf0bd88e | |||
| 9fec863f18 | |||
| 6aeed26642 | |||
| 3530754518 | |||
| 5049116a6f | |||
| 3b641a8d7c | |||
| 3e31d26660 | |||
| 65633bcac9 | |||
| 3cf8b20e97 | |||
| aa5f5d7640 | |||
| 0b1caeffa7 | |||
| dfeeb81ab8 | |||
| 2bce20ba03 | |||
| 208296f841 | |||
| 6488a0ed63 | |||
| 69929fc00d | |||
| 83b4290e62 | |||
| e8bf01b149 | |||
| fc950865c4 | |||
| 043d70fbe0 | |||
| 77db5965a9 | |||
| 2f8569c302 | |||
| 7465ad01d4 | |||
| ab9e8bfcd6 | |||
| d71d6de05f | |||
| f9eec18adb | |||
| ba08d749ac | |||
| 6eccacc06a | |||
| 8feaccf5dd | |||
| 1ac62aac06 | |||
| 041979de07 | |||
| 59556406a8 | |||
| 58bb3046e7 | |||
| 45242292d6 | |||
| ef9adb29e8 | |||
| 1d5f0541d7 | |||
| 0671910e67 | |||
| 7bb960b6b3 | |||
| e71d95fcc6 | |||
| 84496d30a1 | |||
| a0ca42af26 | |||
| 9905d39e19 | |||
| e91f4a2c63 | |||
| 98976c9ce9 | |||
| b1e0bcda63 | |||
| 634e161085 | |||
| ae41290b68 | |||
| 77b35995eb | |||
| 94bcf23b68 | |||
| 847b2f2f00 | |||
| 1eace996f5 | |||
| 9d4116b478 | |||
| c281764bd9 | |||
| a3e3202f21 | |||
| 09dac639c9 | |||
| b3bdf69d61 | |||
| 59fd1b2d14 | |||
| de26235621 | |||
| f4f07ce91f | |||
| 93d00ea4c0 | |||
| 2571f9d216 | |||
| f665ffa0c0 | |||
| 58ca29fb62 | |||
| a4e785e574 | |||
| f3b277bc23 | |||
| 42b901d769 | |||
| ba2c541c31 | |||
| 735de6ece9 | |||
| e5f7ece942 | |||
| 5ebc988190 | |||
| 53cc92d9d0 | |||
| 5c3dc851bc | |||
| 4e8cf62418 | |||
| 883b5b5760 | |||
| ff671bda39 | |||
| 9190f62b9e | |||
| 707b24f41e | |||
| 7e93dcba6c | |||
| 42abac7527 | |||
| 937da49cd7 | |||
| a26269ebbe | |||
| 0fcce88409 | |||
| 0ea1e55ee4 | |||
| 2c8cd11153 | |||
| 2a69747744 | |||
| 48881677b5 | |||
| c9f3a304e6 | |||
| ad364e4b19 | |||
| f413d29fa1 | |||
| 323c613222 | |||
| babfbc5b10 | |||
| 640a8328cf | |||
| e56523b819 | |||
| 177caded5c | |||
| d9d99d6b0b | |||
| 7dbfc31f04 | |||
| fb6f157c42 | |||
| 3504a24be5 | |||
| f49c20787e | |||
| 39fed90296 | |||
| 760e11138d | |||
| 534b8f0102 | |||
| 5cb4c9f950 | |||
| e37552c8ce | |||
| 3af32e167c | |||
| 3927a50633 | |||
| 0712034720 | |||
| c0a23061ee | |||
| 44be9e6e35 | |||
| fa813ed084 | |||
| 2c58a7b0fd | |||
| 532ff57c29 | |||
| f99c1d0b17 | |||
| 703bb6d3ab | |||
| 162d4eee43 | |||
| 810cbfba00 | |||
| 3e5af1922d | |||
| 3a540ffaa1 | |||
| 2b9ea24238 | |||
| 308f3098f3 | |||
| 77eee4f872 | |||
| 1b4399df68 | |||
| aacfb66e2c | |||
| a1be35f1f2 | |||
| fe5a622998 | |||
| 0f1103a607 | |||
| 8975d762ef | |||
| 704cb42e99 | |||
| 1db8b4d5b0 | |||
| b62da321f7 | |||
| c5b929187a | |||
| 119307d13b | |||
| 5b8d2d5c06 | |||
| e0024567ff | |||
| 0316331fd2 | |||
| 7807c994ff | |||
| cd90cbce0e | |||
| 650ba868b3 | |||
| 256e22f0bd | |||
| 3926a1c886 | |||
| b87b70fa72 | |||
| ac48231e82 | |||
| 5f891bbce4 | |||
| e17dbdc9c2 | |||
| a6ed6cd4da | |||
| 84bd0c9e17 | |||
| 11999c0e8b | |||
| 6c2cedf8ec | |||
| 0e117fa417 |
+1
-1
@@ -5,7 +5,7 @@ VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
|
||||
VITE_NOSTR_PUSH_PUBKEY=""
|
||||
# Canonical origin used when generating shareable URLs (QR codes, copy-link, remote-login callbacks).
|
||||
# Primarily useful for native (Capacitor) builds, where window.location.origin is capacitor://localhost.
|
||||
# Example: VITE_SHARE_ORIGIN="https://ditto.pub"
|
||||
# Example: VITE_SHARE_ORIGIN="https://agora.spot"
|
||||
VITE_SHARE_ORIGIN=""
|
||||
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
|
||||
# ALLOWED_HOSTS="*"
|
||||
+43
-27
@@ -26,6 +26,38 @@ test:
|
||||
script:
|
||||
- npm run test
|
||||
|
||||
# Deploy the built web app to agora.spot on venus.vps via rsync over SSH.
|
||||
# Uses the per-site jailed deploy key documented in GITLAB_DEPLOY.md.
|
||||
# DEPLOY_SSH_KEY and DEPLOY_TARGET are protected CI/CD variables; they're
|
||||
# only exposed to jobs on the protected default branch.
|
||||
deploy-web:
|
||||
stage: deploy
|
||||
timeout: 10 minutes
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: never
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH && $DEPLOY_SSH_KEY && $DEPLOY_TARGET
|
||||
script:
|
||||
# Build the web app
|
||||
- npm ci
|
||||
- npm run build
|
||||
- cp dist/index.html dist/404.html
|
||||
|
||||
# Install rsync + ssh client and load the deploy key
|
||||
- apt-get update -qq && apt-get install -y --no-install-recommends rsync openssh-client >/dev/null
|
||||
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
- echo "$DEPLOY_SSH_KEY" | tr -d '\r' > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
|
||||
- ssh-keyscan -H "${DEPLOY_TARGET##*@}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Two-phase rsync: upload hashed assets first, then index.html and sw.js,
|
||||
# so the site never serves an index.html that points at assets that
|
||||
# haven't finished uploading. sw.js is in the second pass for the same
|
||||
# reason — it's a stable filename that all browsers re-fetch to check
|
||||
# for updates, so we want it to land last. The destination ":/" is the
|
||||
# rrsync jail root on venus, which maps to /var/www/agora.spot/.
|
||||
- rsync -av --exclude=/sw.js --exclude=/index.html -e "ssh -i ~/.ssh/id_ed25519" dist/ "${DEPLOY_TARGET}:/"
|
||||
- rsync -av -e "ssh -i ~/.ssh/id_ed25519" dist/index.html dist/sw.js "${DEPLOY_TARGET}:/"
|
||||
|
||||
# Disabled: nsite deploy not needed right now; re-enable by restoring the
|
||||
# rules below to run on default branch (and ensure NSITE_NBUNKSEC is set).
|
||||
deploy-nsite:
|
||||
@@ -61,22 +93,6 @@ deploy-nsite:
|
||||
--use-fallback-relays
|
||||
--use-fallback-servers
|
||||
|
||||
build-web:
|
||||
stage: build
|
||||
timeout: 10 minutes
|
||||
needs: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: never
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
script:
|
||||
- npm ci
|
||||
- npm run build
|
||||
- cp dist/index.html dist/404.html
|
||||
artifacts:
|
||||
paths:
|
||||
- dist/
|
||||
|
||||
release-notes:
|
||||
stage: build
|
||||
timeout: 2 minutes
|
||||
@@ -88,7 +104,7 @@ release-notes:
|
||||
# release-notes.md is the full section (summary + bulleted lists), used as
|
||||
# the GitLab Release description. release-notes-summary.txt is the leading
|
||||
# plaintext paragraph only, used as the App Store / Play Store release
|
||||
# blurb. Falls back to "Ditto vX.Y.Z" when the section has no summary.
|
||||
# blurb. Falls back to "Agora vX.Y.Z" when the section has no summary.
|
||||
- mkdir -p artifacts
|
||||
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" > artifacts/release-notes.md
|
||||
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" --summary > artifacts/release-notes-summary.txt
|
||||
@@ -264,27 +280,27 @@ build-ipa:
|
||||
ios/App/App.xcodeproj/project.pbxproj
|
||||
|
||||
# Run match (cert verify + decrypt) and build_app to produce the IPA.
|
||||
# build_app writes ./artifacts/Ditto.ipa relative to the project root.
|
||||
# build_app writes ./artifacts/Agora.ipa relative to the project root.
|
||||
- cd ios
|
||||
- fastlane build_ipa
|
||||
- cd ..
|
||||
|
||||
# Move the IPA to a stable name in the artifact directory.
|
||||
- ls -lh artifacts/
|
||||
- test -f artifacts/Ditto.ipa
|
||||
- test -f artifacts/Agora.ipa
|
||||
|
||||
# Upload to the Generic Packages registry for a stable public download URL,
|
||||
# mirroring how build-apk publishes the APK and AAB.
|
||||
- |
|
||||
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
|
||||
--upload-file "artifacts/Ditto.ipa" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa"
|
||||
--upload-file "artifacts/Agora.ipa" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.ipa"
|
||||
after_script:
|
||||
# Wipe the API key so nothing sensitive sticks around between jobs.
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
artifacts:
|
||||
paths:
|
||||
- artifacts/Ditto.ipa
|
||||
- artifacts/Agora.ipa
|
||||
expire_in: 90 days
|
||||
|
||||
release:
|
||||
@@ -317,8 +333,8 @@ release:
|
||||
- name: Agora-${CI_COMMIT_TAG}.aab
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.aab
|
||||
link_type: package
|
||||
- name: Ditto-${CI_COMMIT_TAG}.ipa
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa
|
||||
- name: Agora-${CI_COMMIT_TAG}.ipa
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.ipa
|
||||
link_type: package
|
||||
|
||||
publish-zapstore:
|
||||
@@ -379,7 +395,7 @@ publish-google-play:
|
||||
- >-
|
||||
fastlane supply
|
||||
--aab artifacts/Agora.aab
|
||||
--package_name pub.agora.app
|
||||
--package_name spot.agora.app
|
||||
--track production
|
||||
--json_key /tmp/play-service-account.json
|
||||
--metadata_path android/fastlane/metadata/android
|
||||
@@ -430,7 +446,7 @@ publish-app-store:
|
||||
# a JSON descriptor; we pass the API key inline via the Fastfile.
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
script:
|
||||
- test -f artifacts/Ditto.ipa
|
||||
- test -f artifacts/Agora.ipa
|
||||
- test -f artifacts/release-notes-summary.txt
|
||||
|
||||
# Use the release summary paragraph as the App Store "What's New" text.
|
||||
@@ -442,7 +458,7 @@ publish-app-store:
|
||||
- echo "-------------------------"
|
||||
|
||||
# Submit the prebuilt IPA from build-ipa to App Store Connect for review.
|
||||
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Ditto.ipa"
|
||||
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Agora.ipa"
|
||||
- cd ios
|
||||
- fastlane submit_release
|
||||
after_script:
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
|
||||
| Kind | Name | Description |
|
||||
|-------|----------------------------|----------------------------------------------------------------|
|
||||
| 20000 | Ephemeral Geo Chat (public) | Geo-anchored ephemeral chat message (kind 20000, public) |
|
||||
| 20001 | Ephemeral Geo Heartbeat | Geo-anchored ephemeral presence heartbeat (kind 20001) |
|
||||
| 30223 | Campaign | Fundraising campaign with a list of on-chain Bitcoin recipients |
|
||||
| 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards |
|
||||
| 36639 | Activist Action | Country-scoped activist challenge with a sats bounty |
|
||||
| 36639 | Pledge | Donor pledge for concrete submissions, stored as sats |
|
||||
|
||||
### Agora Protocols
|
||||
|
||||
@@ -23,6 +22,7 @@
|
||||
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
|
||||
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
|
||||
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
|
||||
| Campaign Moderation | 30223, 1985, 39089 | Homepage curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster |
|
||||
|
||||
### Community Chat
|
||||
|
||||
@@ -72,6 +72,8 @@ Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) a
|
||||
|
||||
### Event Structure
|
||||
|
||||
Single-recipient zap (the common case — tipping a post or profile):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 8333,
|
||||
@@ -87,6 +89,26 @@ Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) a
|
||||
}
|
||||
```
|
||||
|
||||
Multi-recipient zap (one transaction paying multiple recipients — campaign donations, community splits):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 8333,
|
||||
"pubkey": "<sender-pubkey>",
|
||||
"content": "Great campaign!",
|
||||
"tags": [
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["p", "<recipient-1-pubkey>"],
|
||||
["p", "<recipient-2-pubkey>"],
|
||||
["p", "<recipient-3-pubkey>"],
|
||||
["amount", "<total-sats-paid-to-all-listed-recipients>"],
|
||||
["a", "30223:<campaign-author>:<campaign-d-tag>"],
|
||||
["K", "30223"],
|
||||
["alt", "Donation: 75000 sats across 3 recipients"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field is a human-readable comment from the sender (may be empty). It is NOT a zap request JSON (unlike NIP-57 kind 9735).
|
||||
@@ -96,21 +118,41 @@ The `content` field is a human-readable comment from the sender (may be empty).
|
||||
| Tag | Required | Description |
|
||||
|----------|----------|----------------------------------------------------------------------------------------------|
|
||||
| `i` | Yes | NIP-73 external content identifier. MUST be `bitcoin:tx:<txid>` where `<txid>` is a 64-char lowercase hex Bitcoin transaction ID. |
|
||||
| `p` | Yes | 32-byte hex pubkey of the zap **recipient** (the author being paid). |
|
||||
| `amount` | Yes | Amount paid to the recipient in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the recipient's derived Taproot address — *not* the total tx value. |
|
||||
| `p` | Yes (≥1) | 32-byte hex pubkey of a zap **recipient** (an author being paid). A single event MAY include multiple `p` tags when the transaction has one output per recipient — each `p` tag MUST correspond to at least one tx output paying that recipient's derived Taproot address. |
|
||||
| `amount` | Yes | **Total** amount paid in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the derived Taproot addresses of **all** listed `p` recipients combined — *not* the total tx value (it excludes the sender's change output). For single-recipient events this is the amount paid to that one recipient. |
|
||||
| `e` | If zapping an event | 32-byte hex ID of the event being zapped. Include a relay hint as the 3rd element where possible. |
|
||||
| `a` | If zapping an addressable event | Addressable event coordinate `<kind>:<pubkey>:<d-tag>`. Used instead of (or alongside) `e` for kinds 30000–39999. |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback. |
|
||||
|
||||
If neither `e` nor `a` is present, the zap targets the recipient's **profile** (i.e. a tip to the pubkey, not to a specific event).
|
||||
If neither `e` nor `a` is present, the zap targets the recipients' **profiles** (i.e. a tip to the pubkey(s), not to a specific event).
|
||||
|
||||
### Publishing Flow
|
||||
|
||||
1. Sender builds a Bitcoin transaction paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
|
||||
1. Sender builds a Bitcoin transaction with one output per intended recipient, each paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
|
||||
2. Sender broadcasts the transaction to the Bitcoin network and obtains the `txid`.
|
||||
3. Sender signs and publishes a kind 8333 event referencing that `txid` with the appropriate `e`/`a`/`p` tags.
|
||||
3. Sender signs and publishes a **single** kind 8333 event referencing that `txid` with one `p` tag per recipient and an `amount` tag carrying the total paid across all of them.
|
||||
4. The event is published **after** broadcast; the txid is already final at that point.
|
||||
|
||||
### Batch / Community Zaps
|
||||
|
||||
A single Bitcoin transaction MAY pay multiple recipients by including one output per recipient. Clients SHOULD publish **one kind 8333 event per transaction**, listing every recipient under its own `p` tag and putting the combined total in the single `amount` tag. Per-recipient amounts are not encoded in the event — clients that need them recompute them from the on-chain transaction during verification (each `p` tag's derived Taproot address is matched against tx outputs).
|
||||
|
||||
For community-level zaps, clients MAY include the community addressable coordinate in an `a` tag and the community kind in a `K` tag:
|
||||
|
||||
```json
|
||||
[
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["p", "<recipient-1-pubkey>"],
|
||||
["p", "<recipient-2-pubkey>"],
|
||||
["amount", "5000"],
|
||||
["a", "34550:<community-author>:<community-d-tag>"],
|
||||
["K", "34550"],
|
||||
["alt", "Bitcoin zap: 5000 sats across 2 recipients"]
|
||||
]
|
||||
```
|
||||
|
||||
The `amount` tag MUST be the sum of outputs paying the listed recipients; it MUST NOT include the sender's change output.
|
||||
|
||||
### Client Behavior
|
||||
|
||||
**Querying onchain zaps for an event:**
|
||||
@@ -119,7 +161,7 @@ If neither `e` nor `a` is present, the zap targets the recipient's **profile** (
|
||||
{ "kinds": [8333], "#e": ["<target-event-id>"], "limit": 100 }
|
||||
```
|
||||
|
||||
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps, use `"#p": ["<pubkey>"]`.
|
||||
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps targeting a specific user, use `"#p": ["<pubkey>"]` — this matches both single-recipient events tagging that user and multi-recipient events where the user is one of several recipients.
|
||||
|
||||
**Verification (REQUIRED before trusting amounts):**
|
||||
|
||||
@@ -127,15 +169,17 @@ Clients MUST verify a kind 8333 event on-chain before counting it toward a zap t
|
||||
|
||||
1. Extract the txid from the `i` tag.
|
||||
2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API).
|
||||
3. Derive the recipient's expected Taproot address from the `p` tag pubkey.
|
||||
4. Sum the values of all outputs in the transaction that pay that address. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to the recipient.
|
||||
5. If the verified amount is 0, the event SHOULD be discarded.
|
||||
3. For each `p` tag, derive the recipient's expected Taproot address.
|
||||
4. Sum the values of all outputs in the transaction that pay any of the derived recipient addresses. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to listed recipients.
|
||||
5. If the verified amount is 0 (no listed recipient received anything in the tx), the event SHOULD be discarded.
|
||||
6. If the sender's `amount` tag exceeds the verified amount, clients MAY discard the event or MAY display the smaller verified amount (capping). Clients MUST NOT display or count the claimed amount when it exceeds the verified amount.
|
||||
7. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals. Because unconfirmed transactions can be evicted (RBF, double-spend), clients SHOULD either exclude them from aggregate zap totals or clearly label them as pending.
|
||||
|
||||
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) equals the recipient pubkey from the `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals.
|
||||
When a client needs to attribute a multi-recipient event's amount to one specific recipient (e.g. rendering a profile zap-history entry), it MAY sum only the tx outputs paying that one recipient's derived Taproot address. Per-recipient amounts are not stored in the event — they are recomputed from the transaction at display time.
|
||||
|
||||
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per (txid, target) pair is canonical — when multiple events reference the same `txid` for the same target, the earliest is preferred.
|
||||
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) appears in any `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals. Outputs in the underlying transaction that pay the sender's own derived Taproot address are change outputs and MUST NOT be counted toward the verified amount regardless of the tag set.
|
||||
|
||||
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per `txid` is canonical — when multiple events reference the same `txid`, the earliest is preferred.
|
||||
|
||||
**Network scope:** This specification applies to Bitcoin **mainnet** only. Testnet, signet, and other networks are out of scope; addresses and txids on those networks MUST NOT be used in kind 8333 events.
|
||||
|
||||
@@ -155,42 +199,240 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
|
||||
|
||||
---
|
||||
|
||||
## Standard NIPs: Direct Messaging
|
||||
## Kind 30223: Campaign
|
||||
|
||||
This application implements encrypted direct messaging using two standard Nostr protocols:
|
||||
### Summary
|
||||
|
||||
### NIP-04 (Legacy Encrypted DMs)
|
||||
Addressable event representing a **fundraising campaign**. A campaign carries the marketing-style metadata you would expect on GoFundMe, Kickstarter, or GiveSendGo (title, summary, cover image, story, category, goal, optional deadline, and recommended country), and — most importantly — a list of recipient pubkeys (`p` tags) that share the proceeds of any donation.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Kind | 4 |
|
||||
| Spec | [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) |
|
||||
Donations are sent as a **single Bitcoin on-chain transaction** with one output per recipient. The donor's wallet derives each recipient's Taproot address from their pubkey via BIP-340/BIP-341 (the same scheme used by kind 8333 onchain zaps), so the campaign event itself does not need to carry Bitcoin addresses. After broadcasting the funding tx, the donor's client publishes one kind 8333 event referencing the `txid`, listing every campaign recipient under its own `p` tag, and tagging the campaign via `a` / `K`. The donation then shows up in the campaign's totals and in each recipient's profile zap history (the `#p` filter matches every listed recipient).
|
||||
|
||||
Legacy encrypted direct messages. Content is encrypted with AES-256-CBC using a shared secret derived from the sender's private key and recipient's public key. The recipient is identified by a `p` tag.
|
||||
The kind is addressable so the creator can edit the story, image, goal, deadline, and recipient list over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
|
||||
|
||||
Used for backward compatibility with older Nostr clients that do not support NIP-17.
|
||||
### Event Structure
|
||||
|
||||
### NIP-17 (Private Direct Messages)
|
||||
```json
|
||||
{
|
||||
"kind": 30223,
|
||||
"pubkey": "<creator-pubkey>",
|
||||
"content": "<markdown story>",
|
||||
"tags": [
|
||||
["d", "save-the-bookstore"],
|
||||
["title", "Save the Last Bookstore"],
|
||||
["summary", "Help our 40-year-old neighborhood bookstore make rent through winter."],
|
||||
["image", "https://example.com/cover.jpg"],
|
||||
["t", "human-rights"],
|
||||
["t", "legal-defense"],
|
||||
["goal", "10000000"],
|
||||
["deadline", "1735689600"],
|
||||
["i", "iso3166:VE"],
|
||||
["k", "iso3166"],
|
||||
["p", "<recipient-1-hex-pubkey>", "wss://relay.example", "2"],
|
||||
["p", "<recipient-2-hex-pubkey>", "wss://relay.example", "1"],
|
||||
["p", "<recipient-3-hex-pubkey>"],
|
||||
["alt", "Fundraising campaign: Save the Last Bookstore"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Kinds | 1059 (Gift Wrap), 1060 (Seal) |
|
||||
| Spec | [NIP-17](https://github.com/nostr-protocol/nips/blob/master/17.md) |
|
||||
### Content
|
||||
|
||||
Modern private direct messages using the Gift Wrap protocol. Messages are triple-layered:
|
||||
The `content` field is the **campaign story**, formatted as Markdown. Clients SHOULD render it with the same Markdown renderer they use for NIP-23 long-form content. Empty content is permitted (e.g. for a campaign that lives entirely in its summary).
|
||||
|
||||
1. **Rumor** (kind 14) — unsigned plaintext message
|
||||
2. **Seal** (kind 13) — rumor encrypted to the recipient, signed by the sender
|
||||
3. **Gift Wrap** (kind 1059) — seal encrypted to the recipient, signed by a random ephemeral key
|
||||
### Tags
|
||||
|
||||
This provides metadata protection: relays and observers cannot determine the sender, recipient, or content. The application uses NIP-17 as the default send protocol, with optional NIP-04 compatibility for older clients.
|
||||
| Tag | Required | Description |
|
||||
|------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `d` | Yes | Campaign slug, unique per author. Forms the addressable coordinate `30223:<pubkey>:<d>`. |
|
||||
| `title` | Yes | Display title of the campaign (plain text, max ~200 chars). |
|
||||
| `summary` | Recommended | Short one-paragraph tagline shown in feed cards and previews. |
|
||||
| `image` | Recommended | HTTPS URL of the cover image (jpg/png/webp). Clients MUST sanitize and verify the URL before rendering. |
|
||||
| `t` | Recommended | Topic tag for discovery and filtering (e.g. `human-rights`, `legal-defense`, `independent-media`). Multiple `t` tags MAY be used. Clients SHOULD normalize user-entered tag labels by removing a leading `#`, lowercasing, and replacing whitespace with hyphens. |
|
||||
| `goal` | Recommended | Fundraising goal in **satoshis** (decimal integer). Omit if the campaign has no fixed goal. |
|
||||
| `deadline` | Optional | Unix timestamp (seconds) at which the campaign closes. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
|
||||
| `i` | Recommended | NIP-73 country identifier for sorting and discovery. SHOULD be `iso3166:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). |
|
||||
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166`. |
|
||||
| `location` | Legacy | Human-readable location string used by older campaign events. New events SHOULD prefer `i` + `k` country tags. Clients MAY display this as a fallback only. |
|
||||
| `status` | Optional | Lifecycle status. The only defined value is `archived`, which marks the campaign closed without deleting it. Other values SHOULD be ignored. See *Closing & archiving* below. |
|
||||
| `p` | Yes (≥1) | Recipient pubkey. The 2nd element is the hex pubkey; the 3rd (optional) is a relay hint; the 4th (optional) is a positive decimal **weight** for split allocation. |
|
||||
| `alt` | Recommended | NIP-31 human-readable fallback. |
|
||||
|
||||
### Protocol Configuration
|
||||
### Recipient Split Rules
|
||||
|
||||
Users can configure their preferred send protocol via Settings > Messages:
|
||||
When a donor sends an amount `T` in satoshis to a campaign:
|
||||
|
||||
- **NIP-17 only** (default) — maximum privacy, only modern clients can read
|
||||
- **NIP-04 + NIP-17** — sends via both protocols for compatibility with legacy clients
|
||||
1. Read all `p` tags from the campaign event.
|
||||
2. Parse the weight of each `p` tag from the 4th element. If absent, malformed, or non-positive, the weight defaults to **1**.
|
||||
3. Compute each recipient's share as `floor(T * weight_i / sum_of_weights)` satoshis.
|
||||
4. Any remainder from rounding (at most N−1 sats) MAY be appended to the largest share or kept by the donor as change — clients SHOULD prefer appending the remainder to the largest share so the full amount reaches the campaign.
|
||||
5. If any computed share is below the Bitcoin dust limit (546 sats for P2TR), the donor's client MUST refuse the donation and surface a minimum-amount error.
|
||||
|
||||
Equal splits are the default: omit the weight on every `p` tag, and all recipients receive `floor(T / N)` sats each.
|
||||
|
||||
### Donation Flow
|
||||
|
||||
1. Donor opens the campaign and chooses an amount in sats (preset or custom).
|
||||
2. Donor's client computes per-recipient amounts using the split rules above.
|
||||
3. Donor's client builds a **single PSBT** with one output per recipient (paying each recipient's derived Taproot address) plus a change output back to the donor.
|
||||
4. Donor signs the PSBT with their Nostr key (Taproot key-path spend) and broadcasts the resulting transaction.
|
||||
5. Donor's client publishes **one kind 8333 event for the whole transaction**, listing every recipient under its own `p` tag. The event MUST include:
|
||||
|
||||
```json
|
||||
[
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["p", "<recipient-1-pubkey>"],
|
||||
["p", "<recipient-2-pubkey>"],
|
||||
["p", "<recipient-3-pubkey>"],
|
||||
["amount", "<total-sats-paid-to-all-recipients>"],
|
||||
["a", "30223:<campaign-author-pubkey>:<campaign-d-tag>"],
|
||||
["K", "30223"],
|
||||
["alt", "Donation to <campaign-title>: <total-amount> sats"]
|
||||
]
|
||||
```
|
||||
|
||||
The `amount` tag is the sum of the outputs paying the listed recipients (i.e. the full donation, excluding the donor's change). Per-recipient amounts are not encoded in the event; clients that need them recompute them from the on-chain transaction by matching each recipient's derived Taproot address against the tx outputs.
|
||||
|
||||
This mirrors the community batch-zap pattern documented in the kind 8333 section above, with the campaign's addressable coordinate replacing the community coordinate.
|
||||
|
||||
### Querying
|
||||
|
||||
**List campaigns (newest first):**
|
||||
|
||||
```json
|
||||
{ "kinds": [30223], "limit": 50 }
|
||||
```
|
||||
|
||||
**Filter by category:**
|
||||
|
||||
```json
|
||||
{ "kinds": [30223], "#t": ["medical"], "limit": 50 }
|
||||
```
|
||||
|
||||
**Fetch a specific campaign:**
|
||||
|
||||
```json
|
||||
{ "kinds": [30223], "authors": ["<creator-pubkey>"], "#d": ["<slug>"], "limit": 1 }
|
||||
```
|
||||
|
||||
**Aggregate donations for a campaign:**
|
||||
|
||||
```json
|
||||
{ "kinds": [8333], "#a": ["30223:<creator-pubkey>:<slug>"], "limit": 500 }
|
||||
```
|
||||
|
||||
Clients MUST verify each kind 8333 event on-chain before counting it toward the campaign total, per the verification rules in the kind 8333 section.
|
||||
|
||||
### Client Behavior
|
||||
|
||||
- **Recipient validity:** clients SHOULD reject `p` tag entries whose pubkey is not 64 hex characters and SHOULD ignore weights that are not positive finite decimals.
|
||||
- **Dust protection:** when a donor enters an amount that would assign any recipient less than the dust limit, the client MUST block the donation and either suggest the minimum viable total or prompt the donor to remove recipients.
|
||||
- **Editability:** the creator MAY republish the same `(kind, pubkey, d)` triple to update the campaign. Clients SHOULD keep `published_at` from the first publish on subsequent edits (NIP-23 convention).
|
||||
- **Closing & archiving:** the creator MAY soft-close a campaign by republishing it with a `["status", "archived"]` tag. Clients SHOULD hide archived campaigns from discovery feeds and disable the donate flow, but MUST keep them reachable by direct link so existing donors can still find them and donation history is preserved. The creator can reopen the campaign by republishing without the status tag (or with any other status value). For a hard delete, the creator MAY publish a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate; clients SHOULD continue to render past donations against the campaign even after deletion.
|
||||
|
||||
### Campaign Moderation Labels
|
||||
|
||||
Agora curates which kind 30223 campaigns appear on the homepage (`/`) and on Discover (`/discover`) via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The campaign event itself is never modified — surfacing is purely a client-side rollup of label events.
|
||||
|
||||
#### Namespace
|
||||
|
||||
```
|
||||
agora.moderation
|
||||
```
|
||||
|
||||
Each label event carries the namespace twice, per NIP-32:
|
||||
|
||||
- A capital-`L` "namespace" tag (relay-indexed for queries).
|
||||
- A lowercase `l` tag where the 2nd element is the label value and the 3rd is the namespace.
|
||||
|
||||
#### Label values
|
||||
|
||||
Three independent axes; the newest moderator-signed label per axis per campaign wins.
|
||||
|
||||
| Axis | Values | Meaning |
|
||||
|----------|---------------------------|-------------------------------------------------------------------------|
|
||||
| approval | `approved`, `unapproved` | `approved` allows the campaign on `/` and Discover. `unapproved` retracts a previous approval. |
|
||||
| hide | `hidden`, `unhidden` | `hidden` suppresses the campaign everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
|
||||
| featured | `featured`, `unfeatured` | `featured` places the campaign in the hand-picked Featured row on `/`. `unfeatured` retracts. |
|
||||
|
||||
Surfacing rules (hide always wins):
|
||||
|
||||
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first. Featured is independent of Approved at the protocol level; a campaign may be featured without being approved (the home page treats Featured and Approved as deduplicated bins, with Featured taking precedence).
|
||||
- **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above).
|
||||
- **Discover shelf** — iff approved AND not hidden.
|
||||
- **Moderator-only "Pending"** — iff neither approved nor hidden.
|
||||
- **Moderator-only "Hidden"** — iff hidden.
|
||||
|
||||
#### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "approved", "agora.moderation"],
|
||||
["a", "30223:<author-pubkey>:<campaign-d-tag>"],
|
||||
["alt", "Campaign moderation: approved"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A `featured` label has the same shape with `["l", "featured", "agora.moderation"]` and `["alt", "Campaign moderation: featured"]`.
|
||||
|
||||
Required tags:
|
||||
|
||||
- `L` set to `agora.moderation`.
|
||||
- `l` with the label value as the 2nd element and `agora.moderation` as the 3rd.
|
||||
- `a` referencing the campaign coordinate `30223:<pubkey>:<d>`.
|
||||
- `alt` (NIP-31) — clients without label support will display this string.
|
||||
|
||||
#### Trust Model
|
||||
|
||||
Only label events authored by current members of the **Team Soapbox** follow pack are honored. The pack is a kind 39089 (NIP-51 follow pack) addressable event:
|
||||
|
||||
```
|
||||
kind: 39089
|
||||
pubkey: 932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d
|
||||
d-tag: k4p5w0n22suf
|
||||
```
|
||||
|
||||
The pack `p` tags are the authoritative moderator list. Anyone may publish a kind 1985 event in the `agora.moderation` namespace, but events from non-pack authors are silently ignored at the relay-filter layer (`authors:` is pinned to the pack `p` tags). This means:
|
||||
|
||||
- Self-approval is impossible unless the pack author has added you.
|
||||
- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive only by their labels return to "pending" until another moderator approves them.
|
||||
- The pack author (single signer) can reset the entire moderator roster by republishing the pack.
|
||||
|
||||
#### Querying
|
||||
|
||||
Step 1 — fetch the pack:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [39089],
|
||||
"authors": ["932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d"],
|
||||
"#d": ["k4p5w0n22suf"],
|
||||
"limit": 1
|
||||
}
|
||||
```
|
||||
|
||||
Step 2 — fetch label events from pack members in the namespace:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [1985],
|
||||
"authors": ["<pack p-tag 1>", "<pack p-tag 2>", "..."],
|
||||
"#L": ["agora.moderation"],
|
||||
"limit": 2000
|
||||
}
|
||||
```
|
||||
|
||||
Step 3 — fold by `(campaign-coord, axis)`, latest-`created_at`-wins. Then fetch only the approved-and-not-hidden campaign coordinates with one filter per author (bundled in a single REQ).
|
||||
|
||||
#### Client Behavior
|
||||
|
||||
- Clients SHOULD render approve/hide controls only for users whose pubkey appears in the pack.
|
||||
- Clients MAY display "Hidden" badges on hidden campaigns when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
|
||||
- Non-moderator authors viewing the homepage SHOULD see their own pending campaigns in a separate explained section so they understand why their campaign isn't yet on the homepage. The campaign URL remains live and donatable regardless of moderation state.
|
||||
|
||||
---
|
||||
|
||||
@@ -290,19 +532,19 @@ After resolution (assuming `$follows` = `["pk1", "pk2"]`):
|
||||
|
||||
---
|
||||
|
||||
## Kind 36639: Activist Action
|
||||
## Kind 36639: Pledge
|
||||
|
||||
### Summary
|
||||
|
||||
Addressable event kind for publishing **activist actions**. An action is a task — take a photo, make art, gather information, or take direct action — with an optional country scope, optional community scope, and an optional sats bounty paid out via NIP-57 zaps to the best **submissions**.
|
||||
Addressable event kind for publishing **pledges**. A pledge is donor intent to fund a concrete action, evidence request, or outcome — take a photo, make art, gather information, clean a beach, or take direct action — with an optional country scope, optional community scope, and a sats-denominated pledge amount paid out via zaps or donation receipts to the best **submissions**.
|
||||
|
||||
Submissions are **NIP-22 comments** (kind 1111) authored under the action's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse.
|
||||
Submissions are **NIP-22 comments** (kind 1111) authored under the pledge's coordinate, ranked by zap totals. There is no separate submission kind; an earlier draft (kind 36640) was deprecated in favor of NIP-22 reuse.
|
||||
|
||||
### Trust model
|
||||
|
||||
Actions are user-generated. Anyone can publish a kind 36639 event, and Agora displays valid actions without platform-admin or country-organizer author filtering.
|
||||
Pledges are user-generated. Anyone can publish a kind 36639 event, and Agora displays valid pledges without platform-admin or country-organizer author filtering.
|
||||
|
||||
Community-scoped actions inherit the community's moderation context. Clients rendering a specific community SHOULD query by the community `A` tag and apply that community's moderation and membership filters.
|
||||
Community-scoped pledges inherit the community's moderation context. Clients rendering a specific community SHOULD query by the community `A` tag and apply that community's moderation and membership filters.
|
||||
|
||||
### Event Structure
|
||||
|
||||
@@ -313,17 +555,17 @@ Community-scoped actions inherit the community's moderation context. Clients ren
|
||||
"tags": [
|
||||
["d", "plant-a-tree-1729000000000"],
|
||||
["title", "Plant a tree in your neighborhood"],
|
||||
["challenge-type", "photo"],
|
||||
["bounty", "10000"],
|
||||
["i", "iso3166:US"],
|
||||
["A", "34550:<community-pubkey>:<community-d-tag>"],
|
||||
["K", "34550"],
|
||||
["P", "<community-pubkey>"],
|
||||
["t", "agora-action"],
|
||||
["t", "tree-planting"],
|
||||
["t", "local-action"],
|
||||
["image", "https://example.com/cover.jpg"],
|
||||
["start", "1729000000"],
|
||||
["deadline", "1729604800"],
|
||||
["alt", "Agora activist action: Plant a tree in your neighborhood"]
|
||||
["alt", "Agora pledge: Plant a tree in your neighborhood"]
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -334,33 +576,36 @@ Community-scoped actions inherit the community's moderation context. Clients ren
|
||||
|------------------|----------|----------------------------------------------------------------------------------------------------------|
|
||||
| `d` | Yes | Unique identifier (typically slug + timestamp). Forms the addressable coordinate `36639:<pubkey>:<d>`. |
|
||||
| `title` | Yes | Short title shown on cards. |
|
||||
| `challenge-type` | Yes | One of `photo`, `art`, `info`, `action`. Drives the display icon and submission expectations. |
|
||||
| `bounty` | Yes | Bounty in **sats**, as an unsigned integer string. Paid out via zaps to the chosen submission(s). |
|
||||
| `bounty` | Yes | Pledge amount in **sats**, as an unsigned integer string. Paid out via zaps or donation receipts to chosen submission(s). |
|
||||
| `i` | No | NIP-73 country identifier: `iso3166:XX` (preferred). Legacy `geo:XX` (length 6, country code only) is accepted as a read alias. Optionally combined with a `location` tag fallback. |
|
||||
| `A` | No | Community root coordinate for community-scoped actions, e.g. `34550:<pubkey>:<d-tag>`. |
|
||||
| `K` | No | Root kind hint for community-scoped actions. Use `34550` when `A` points to a NIP-72 community. |
|
||||
| `P` | No | Root author hint for community-scoped actions. Use the community definition author pubkey. |
|
||||
| `t` | Yes | Discovery tag. Canonical write value is `agora-action`. Read aliases: `pathos-challenge`, `agora-challenge`. |
|
||||
| `A` | No | Community root coordinate for community-scoped pledges, e.g. `34550:<pubkey>:<d-tag>`. |
|
||||
| `K` | No | Root kind hint for community-scoped pledges. Use `34550` when `A` points to a NIP-72 community. |
|
||||
| `P` | No | Root author hint for community-scoped pledges. Use the community definition author pubkey. |
|
||||
| `t` | Yes | Discovery and category tags. Canonical write value includes `agora-action`; additional `t` tags are optional hashtags/categories. Read aliases: `pathos-challenge`, `agora-challenge`. |
|
||||
| `image` | No | Cover image URL. |
|
||||
| `start` | No | Unix timestamp when the action becomes active. Defaults to `created_at`. |
|
||||
| `deadline` | No | Unix timestamp when the action expires. Defaults to `start + 48h`. |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora activist action: <title>"`. |
|
||||
| `start` | No | Legacy. Unix timestamp when the pledge becomes active. Defaults to `created_at`. New pledges omit it; the `created_at` is the start. |
|
||||
| `deadline` | No | Optional Unix timestamp when the pledge expires. Omit for open-ended pledges. |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback. Convention: `"Agora pledge: <title>"`. |
|
||||
|
||||
### Content
|
||||
|
||||
Long-form description of the action. Plain text or light markdown. Clients render this as the action's body on the detail page.
|
||||
Long-form description of the pledge. Plain text or light markdown. Clients render this as the pledge's body on the detail page.
|
||||
|
||||
### Categories
|
||||
|
||||
Clients SHOULD use optional `t` tags for filtering and discovery instead of the deprecated `challenge-type` tag. Suggested user-entered tags include values like `beach-cleanup`, `protest-documentation`, `internet-blackout`, `legal-defense`, or `mutual-aid`.
|
||||
|
||||
### Submissions
|
||||
|
||||
Submissions are kind 1111 NIP-22 comments addressed to the action's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD:
|
||||
Submissions are kind 1111 NIP-22 comments addressed to the pledge's coordinate (`["A", "36639:<pubkey>:<d>"]` and `["P", "<pubkey>"]`). Clients SHOULD:
|
||||
|
||||
- Sort top-level submissions by **total zap amount** (sum of NIP-57 zap receipts on each submission), descending.
|
||||
- Show the bounty as the prize pool that organizers can distribute to top submissions via zaps.
|
||||
- Hide submissions with `created_at` after the action's `deadline` for "past" leaderboards (or surface them separately as "late submissions").
|
||||
- Sort top-level submissions by **total funded amount** (sum of kind 9735 zap receipts and kind 8333 donation receipts on each submission), descending.
|
||||
- Show the pledge amount, total funded, and remaining amount as a trust-based progress indicator. There is no escrow guarantee.
|
||||
- Hide submissions with `created_at` after the pledge's `deadline` for "past" leaderboards (or surface them separately as "late submissions"). Open-ended pledges have no deadline cutoff.
|
||||
|
||||
### Discovery
|
||||
|
||||
Clients querying actions globally:
|
||||
Clients querying pledges globally:
|
||||
|
||||
```json
|
||||
{ "kinds": [36639], "#t": ["agora-action", "pathos-challenge", "agora-challenge"], "limit": 50 }
|
||||
@@ -505,66 +750,6 @@ After fetching, take the event with the highest `created_at` and parse it. Cache
|
||||
|
||||
---
|
||||
|
||||
## Kinds 20000 / 20001: Ephemeral Geo Chat
|
||||
|
||||
### Summary
|
||||
|
||||
Ephemeral events used to power realtime location-anchored chat on the world map. Both kinds live in NIP-01's ephemeral range (`20000 ≤ kind < 30000`), so relays MUST NOT persist them — they are short-lived signals only.
|
||||
|
||||
- **Kind 20000** — public chat message. The `content` field carries the message text.
|
||||
- **Kind 20001** — presence "heartbeat". Same tag schema, but `content` MAY be empty (the event simply broadcasts that someone is listening at the geohash).
|
||||
|
||||
This kind range is shared with the wider Bitchat / geo-chat ecosystem; Agora interoperates with Pathos and other clients producing the same shape.
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Purpose |
|
||||
|-----|----------|-------------------------------------------------------------------------|
|
||||
| `g` | Yes | Geohash anchoring the message. Any precision is allowed; the dialog filters by exact-match `g` value, while the map clusters by full geohash. |
|
||||
| `n` | No | Display nickname (≤ 16 chars after client-side truncation). Anonymous senders pick a random "ghost" handle; logged-in senders may use their account display name. |
|
||||
|
||||
Events without a `g` tag MUST be ignored — they cannot be plotted.
|
||||
|
||||
### Identity
|
||||
|
||||
There are two valid signing paths:
|
||||
|
||||
1. **Real identity** — a logged-in user signs with their existing Nostr key (typically via NIP-07 / NIP-46). Other clients can correlate the chat message with the author's public profile.
|
||||
2. **Ephemeral "ghost" identity** — the client generates a fresh in-memory keypair (never persisted) and signs locally. Only the chosen `n` nickname is persisted (in `localStorage`) so the user keeps a stable handle even though the pubkey rotates per session.
|
||||
|
||||
Clients SHOULD let logged-in users toggle between modes per-session and SHOULD default to the ghost mode when no account is available.
|
||||
|
||||
### Relay Routing
|
||||
|
||||
Because ephemeral events are not stored, latency dominates the experience. Clients SHOULD:
|
||||
|
||||
1. Always include a baseline of widely-reachable relays (`wss://nos.lol`, `wss://relay.damus.io`, `wss://relay.primal.net`).
|
||||
2. Augment with geo-located relays drawn from the [permissionlesstech/georelays](https://github.com/permissionlesstech/georelays) CSV catalogue (`relayUrl,latitude,longitude` per line).
|
||||
3. For a specific geohash conversation, prefer the relays nearest the decoded coordinates (Haversine distance, top-N).
|
||||
4. For the global map heatmap, take a rotating window (e.g. 8 relays, rotated every 5 minutes) so coverage spreads without saturating any single relay.
|
||||
|
||||
### Time Window
|
||||
|
||||
Clients SHOULD only surface events from the last hour (`since = now - 3600`). Older ephemeral events are uninteresting for "what's happening right now" and most relays will have dropped them anyway.
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 20000,
|
||||
"created_at": 1734567890,
|
||||
"pubkey": "...",
|
||||
"tags": [
|
||||
["g", "u4pruydqqvj"],
|
||||
["n", "stealthranger4242"]
|
||||
],
|
||||
"content": "anyone in berlin tonight?",
|
||||
"sig": "..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flat Communities
|
||||
|
||||
Flat communities on Nostr, composed from existing event kinds. Communities have one membership badge, explicit moderators, and no recursive badge-chain authority.
|
||||
|
||||
@@ -7,10 +7,10 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "pub.agora.app"
|
||||
namespace = "spot.agora.app"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "pub.agora.app"
|
||||
applicationId "spot.agora.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
|
||||
@@ -10,6 +10,7 @@ android {
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-barcode-scanner')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-keyboard')
|
||||
|
||||
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
||||
|
||||
# Keep Capacitor classes (WebView JS bridge)
|
||||
-keep class com.getcapacitor.** { *; }
|
||||
-keep class pub.ditto.app.** { *; }
|
||||
-keep class spot.agora.app.** { *; }
|
||||
|
||||
# Keep WebView JS interfaces
|
||||
-keepclassmembers class * {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pub.ditto.app;
|
||||
package spot.agora.app;
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package pub.ditto.app;
|
||||
package spot.agora.app;
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
@@ -63,7 +63,7 @@ public class MainActivity extends BridgeActivity {
|
||||
private void handleNotificationIntent(Intent intent) {
|
||||
if (intent == null) return;
|
||||
Uri data = intent.getData();
|
||||
if (data != null && "ditto.pub".equals(data.getHost())) {
|
||||
if (data != null && "agora.spot".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 pub.ditto.app;
|
||||
package spot.agora.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://ditto.pub/notifications"));
|
||||
intent.setData(Uri.parse("https://agora.spot/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 pub.ditto.app;
|
||||
package spot.agora.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 = "pub.ditto.app.ACTION_FETCH";
|
||||
private static final String ACTION_FETCH = "spot.agora.app.ACTION_FETCH";
|
||||
|
||||
// Backoff bounds for relay connect failures (separate from alarm interval).
|
||||
private static final long INITIAL_BACKOFF_MS = 1_000;
|
||||
@@ -2,6 +2,6 @@
|
||||
<resources>
|
||||
<string name="app_name">Agora</string>
|
||||
<string name="title_activity_main">Agora</string>
|
||||
<string name="package_name">pub.agora.app</string>
|
||||
<string name="custom_url_scheme">pub.agora.app</string>
|
||||
<string name="package_name">spot.agora.app</string>
|
||||
<string name="custom_url_scheme">spot.agora.app</string>
|
||||
</resources>
|
||||
|
||||
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-barcode-scanner'
|
||||
project(':capacitor-barcode-scanner').projectDir = new File('../node_modules/@capacitor/barcode-scanner/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'pub.agora.app',
|
||||
appId: 'spot.agora.app',
|
||||
appName: 'Agora',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
|
||||
@@ -325,7 +325,7 @@
|
||||
);
|
||||
MARKETING_VERSION = 2.14.4;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -348,7 +348,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.8.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -33,7 +33,7 @@ public class DittoNotificationPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
static let bgTaskIdentifier = "pub.ditto.app.notification-refresh"
|
||||
static let bgTaskIdentifier = "spot.agora.app.notification-refresh"
|
||||
private static let prefsKey = "ditto_notification_config"
|
||||
|
||||
// MARK: - Plugin Methods
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>pub.agora.app.notification-refresh</string>
|
||||
<string>spot.agora.app.notification-refresh</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -13,6 +13,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorBarcodeScanner", path: "../../../node_modules/@capacitor/barcode-scanner"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
|
||||
@@ -28,6 +29,7 @@ let package = Package(
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorBarcodeScanner", package: "CapacitorBarcodeScanner"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
|
||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
app_identifier("pub.ditto.app")
|
||||
app_identifier("spot.agora.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/Ditto.ipa."
|
||||
desc "Build and sign the App Store IPA. Output at ../artifacts/Agora.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 Ditto to the App Store for review (single-step convenience)."
|
||||
desc "Build, sign, and submit Agora 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: "Ditto.ipa",
|
||||
output_name: "Agora.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 pub.ditto.app'",
|
||||
"PROVISIONING_PROFILE_SPECIFIER='match AppStore spot.agora.app'",
|
||||
"DEVELOPMENT_TEAM=GZLTTH5DLM",
|
||||
].join(" "),
|
||||
export_options: {
|
||||
@@ -101,7 +101,7 @@ platform :ios do
|
||||
signingStyle: "manual",
|
||||
teamID: "GZLTTH5DLM",
|
||||
provisioningProfiles: {
|
||||
"pub.ditto.app" => "match AppStore pub.ditto.app",
|
||||
"spot.agora.app" => "match AppStore spot.agora.app",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git_url("https://gitlab.com/soapbox-pub/certificates.git")
|
||||
storage_mode("git")
|
||||
type("appstore")
|
||||
app_identifier(["pub.ditto.app"])
|
||||
app_identifier(["spot.agora.app"])
|
||||
team_id("GZLTTH5DLM")
|
||||
|
||||
Generated
+162
-57
@@ -8,6 +8,7 @@
|
||||
"name": "agora",
|
||||
"version": "2.8.0",
|
||||
"dependencies": {
|
||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
@@ -95,12 +96,12 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@samthomson/nostr-messaging": "^0.17.1",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"bitcoinjs-lib": "^7.0.1",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
@@ -111,6 +112,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"ecpair": "^3.0.1",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -123,7 +125,6 @@
|
||||
"iso-3166": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.8.0",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
@@ -354,6 +355,30 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/secp256k1": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz",
|
||||
"integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/curves": {
|
||||
"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": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -6170,39 +6195,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@samthomson/nostr-messaging": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@samthomson/nostr-messaging/-/nostr-messaging-0.17.1.tgz",
|
||||
"integrity": "sha512-TfgC3L/7sKnkLSqod1UyF9Bt/F36kH02nRffWjm5YEMfLvHLEYlT5ECgzyrnt9QVpYXG25rVAhEpXF9wxmPX0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"fuse.js": "^7.1.0",
|
||||
"idb": "^8.0.3",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"react-blurhash": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nostrify/nostrify": ">=0.47.0",
|
||||
"@nostrify/react": ">=0.2.0",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.0.0",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
|
||||
@@ -7715,6 +7707,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base-x": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -7735,6 +7733,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bech32": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
|
||||
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.9.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
|
||||
@@ -7783,6 +7787,37 @@
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bip174": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0.tgz",
|
||||
"integrity": "sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.9",
|
||||
"varuint-bitcoin": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bitcoinjs-lib": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.1.tgz",
|
||||
"integrity": "sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"bech32": "^2.0.0",
|
||||
"bip174": "^3.0.0",
|
||||
"bs58check": "^4.0.0",
|
||||
"uint8array-tools": "^0.0.9",
|
||||
"valibot": "^1.2.0",
|
||||
"varuint-bitcoin": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
@@ -7896,6 +7931,25 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base-x": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58check": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz",
|
||||
"integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"bs58": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
@@ -8731,6 +8785,29 @@
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/ecpair": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.1.tgz",
|
||||
"integrity": "sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.8",
|
||||
"valibot": "^1.2.0",
|
||||
"wif": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ecpair/node_modules/uint8array-tools": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
|
||||
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.149",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz",
|
||||
@@ -9333,19 +9410,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz",
|
||||
"integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/krisk"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@@ -11659,15 +11723,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ngeohash": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz",
|
||||
"integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.89.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
|
||||
@@ -14577,6 +14632,15 @@
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uint8array-tools": {
|
||||
"version": "0.0.9",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz",
|
||||
"integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -14915,6 +14979,38 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/valibot": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.0.tgz",
|
||||
"integrity": "sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/varuint-bitcoin": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
|
||||
"integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/varuint-bitcoin/node_modules/uint8array-tools": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
|
||||
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
@@ -16517,6 +16613,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wif": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz",
|
||||
"integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bs58check": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
+3
-2
@@ -15,6 +15,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
@@ -102,12 +103,12 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@samthomson/nostr-messaging": "^0.17.1",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"bitcoinjs-lib": "^7.0.1",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
@@ -118,6 +119,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"ecpair": "^3.0.1",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -130,7 +132,6 @@
|
||||
"iso-3166": "^4.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.8.0",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"GZLTTH5DLM.pub.agora.app"
|
||||
"GZLTTH5DLM.spot.agora.app"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "pub.agora.app",
|
||||
"package_name": "spot.agora.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71",
|
||||
"E5:B1:A9:13:C9:37:35:3C:A5:E7:27:89:C0:9D:3D:0D:A5:4F:F5:26:88:06:BD:24:46:21:AB:61:6B:CC:C5:E5"
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1152" height="720">
|
||||
|
||||
<defs>
|
||||
|
||||
<linearGradient id="grad">
|
||||
<stop stop-color="#84be86" offset="0"/>
|
||||
|
||||
<stop stop-color="#328c4e" offset="1"/>
|
||||
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path fill="#f4e109" d="m0,0,0,720,1152,0,0-720z"/>
|
||||
|
||||
<path fill="#da251c" d="m596.99,620,555.01,56.187,0-634.28-1110,0,0,634.28"/>
|
||||
|
||||
<g fill="#29166f">
|
||||
<path d="m597,359.06,0-317.16-277.69,0z"/>
|
||||
|
||||
<path d="m597,359.06,555-317.16-278.03,0z"/>
|
||||
|
||||
<path d="m1152,200.47,0,158.59-1110,0,0,158.56z"/>
|
||||
|
||||
<path d="m1152,676.14,0-158.51-1110-317.16,0-158.56z"/>
|
||||
|
||||
</g>
|
||||
<path fill="#f4e109" d="m392.87,329.97,102.05,17.726c-0.408,3.732-0.632,7.516-0.632,11.355,0,3.868,0.225,7.685,0.64,11.441l-102.04,17.813,102.92-11.563c1.158,6.703,2.963,13.182,5.344,19.374l-94.205,43.224,96.647-37.403c1.266,2.774,2.643,5.487,4.14,8.123l178.54,0.001c1.495-2.636,2.874-5.349,4.14-8.123l96.646,37.403-94.206-43.224c2.382-6.192,4.187-12.671,5.345-19.374l102.92,11.563-102.04-17.813c0.414-3.757,0.641-7.573,0.641-11.441,0-3.839-0.227-7.623-0.632-11.355l102.05-17.726-102.94,11.476c-1.148-6.719-2.944-13.211-5.321-19.419l94.22-43.177-96.657,37.357c-3.226-7.082-7.215-13.735-11.879-19.849l78.264-68.147-82.245,63.261c-10.31-11.931-23.281-21.47-38.01-27.65l33.979-98.248-39.859,96.01c-10.556-3.681-21.882-5.707-33.686-5.707-11.803,0-23.128,2.026-33.684,5.707l-39.86-96.01,33.98,98.248c-14.729,6.181-27.701,15.719-38.01,27.65l-82.246-63.261,78.266,68.147c-4.665,6.114-8.654,12.768-11.878,19.849l-96.659-37.357,94.22,43.177c-2.378,6.208-4.171,12.7-5.321,19.419z"/>
|
||||
|
||||
<path fill="#fff" d="M596.99,359.05,1152,676.19h-1110l554.99-317.14z"/>
|
||||
|
||||
<g fill="#da251c" stroke="#000" stroke-width="0.476">
|
||||
|
||||
<path fill="#f1c700" d="m629.96,593.89c0,18.271-14.811,33.082-33.081,33.082-18.268,0-33.082-14.812-33.082-33.082,0-18.269,14.814-33.079,33.082-33.079,18.27,0,33.081,14.81,33.081,33.079z"/>
|
||||
|
||||
<path d="m624.99,593.89c0,15.526-12.586,28.112-28.112,28.112s-28.112-12.586-28.112-28.112,12.586-28.112,28.112-28.112,28.112,12.586,28.112,28.112z"/>
|
||||
|
||||
<path fill="#e87817" d="m620.54,593.89c0,13.069-10.594,23.663-23.663,23.663s-23.663-10.594-23.663-23.663,10.594-23.663,23.663-23.663,23.663,10.594,23.663,23.663z"/>
|
||||
|
||||
<path fill="#29166f" d="m620.54,593.9c0,13.065-10.594,23.661-23.663,23.661s-23.702-10.596-23.702-23.661c0-6.541,5.301-11.858,11.848-11.858,6.552,0,11.836,5.317,11.836,11.858,0,6.542,5.296,11.849,11.85,11.849,6.542-0.001,11.831-5.307,11.831-11.849z"/>
|
||||
|
||||
<path fill="#e87817" d="m588.41,593.89c0,1.868-1.509,3.382-3.38,3.382s-3.382-1.514-3.382-3.382c0-1.864,1.511-3.384,3.382-3.384s3.38,1.519,3.38,3.384z"/>
|
||||
|
||||
<path fill="#29166f" d="m612.11,593.89c0,1.868-1.508,3.382-3.38,3.382-1.871,0-3.385-1.514-3.385-3.382,0-1.864,1.514-3.384,3.385-3.384,1.873,0,3.38,1.519,3.38,3.384z"/>
|
||||
|
||||
<path d="m596.87,556.27c-1.38,0-2.501-1.117-2.501-2.5,0-1.385,1.121-2.507,2.501-2.507,1.384,0,2.502,1.122,2.502,2.507,0,1.382-1.118,2.5-2.502,2.5z"/>
|
||||
|
||||
<path d="m596.81,558.2c0,1.385-1.117,2.506-2.502,2.506s-2.501-1.121-2.501-2.506c0-1.378,1.116-2.502,2.501-2.502s2.502,1.124,2.502,2.502z"/>
|
||||
|
||||
<path d="m601.94,558.2c0,1.385-1.116,2.506-2.5,2.506-1.386,0-2.506-1.121-2.506-2.506,0-1.378,1.12-2.502,2.506-2.502,1.384,0,2.5,1.124,2.5,2.502z"/>
|
||||
|
||||
<path d="m596.87,631.52c1.379,0,2.506,1.12,2.506,2.5,0,1.384-1.127,2.502-2.506,2.502-1.384,0-2.5-1.118-2.5-2.502,0-1.38,1.116-2.5,2.5-2.5z"/>
|
||||
|
||||
<path d="m596.94,629.58c0-1.38,1.118-2.5,2.502-2.5,1.382,0,2.504,1.12,2.504,2.5,0,1.386-1.122,2.506-2.504,2.506-1.384,0-2.502-1.12-2.502-2.506z"/>
|
||||
|
||||
<path d="m591.81,629.58c0-1.38,1.116-2.5,2.5-2.5s2.505,1.12,2.505,2.5c0,1.386-1.121,2.506-2.505,2.506s-2.5-1.12-2.5-2.506z"/>
|
||||
|
||||
<path d="m639.5,593.89c0,1.3813-1.1197,2.501-2.501,2.501s-2.501-1.1197-2.501-2.501,1.1197-2.501,2.501-2.501,2.501,1.1197,2.501,2.501z"/>
|
||||
|
||||
<path d="m635.07,596.46c0,1.3824-1.1206,2.503-2.503,2.503s-2.503-1.1206-2.503-2.503,1.1206-2.503,2.503-2.503,2.503,1.1206,2.503,2.503z"/>
|
||||
|
||||
<path d="m632.57,588.83c-1.381,0-2.502,1.115-2.502,2.5s1.121,2.504,2.502,2.504c1.382,0,2.502-1.119,2.502-2.504s-1.12-2.5-2.502-2.5z"/>
|
||||
|
||||
<path d="m559.25,593.89c0-1.378-1.115-2.504-2.5-2.504-1.384,0-2.501,1.126-2.501,2.504,0,1.383,1.117,2.5,2.501,2.5,1.385,0,2.5-1.118,2.5-2.5z"/>
|
||||
|
||||
<path d="m563.68,591.33c0,1.3824-1.1206,2.503-2.503,2.503s-2.503-1.1206-2.503-2.503,1.1206-2.503,2.503-2.503,2.503,1.1206,2.503,2.503z"/>
|
||||
|
||||
<path d="m563.68,596.45c0,1.3818-1.1202,2.502-2.502,2.502s-2.502-1.1202-2.502-2.502,1.1202-2.502,2.502-2.502,2.502,1.1202,2.502,2.502z"/>
|
||||
|
||||
</g>
|
||||
<g stroke-width="0.476">
|
||||
|
||||
<g fill="#e0609b" stroke="#000">
|
||||
|
||||
<path fill="#e87817" d="m600.33,516.36c0,1.84-1.487,3.326-3.325,3.326-1.84,0-3.326-1.486-3.326-3.326,0-1.838,1.486-3.324,3.326-3.324,1.838,0,3.325,1.486,3.325,3.324z"/>
|
||||
|
||||
<path fill="#e12211" d="m622.04,486.76c-1.398,5.898-12.077,10.491-25.054,10.491-13.008,0-23.717-4.616-25.07-10.542-4.437,2.376-7.1,5.4-7.1,8.691,0,7.66,14.382,13.867,32.124,13.867,17.74,0,32.118-6.207,32.118-13.867,0-3.268-2.629-6.274-7.018-8.64z"/>
|
||||
|
||||
<path d="m597.44,497.25c-2.56,0-5.03-0.179-7.36-0.514-0.354,0.997-2.361,8.101,7.313,12.53,7.429-2.847,7.275-10.72,6.511-12.41-2.063,0.254-4.228,0.394-6.464,0.394z"/>
|
||||
|
||||
<path d="m571.8,485.93c-1.184,0.502-7.366,2.488-5.974,13.064,9.224,2.535,10.25-5.694,10.28-6.938-2.553-1.761-4.104-3.864-4.306-6.126z"/>
|
||||
|
||||
<path d="m588.87,496.54c0.079,1.021,0.6,9.042-9.55,10.257-7.443-6.215-2.961-13.113-1.307-13.851,2.84,1.615,6.58,2.866,10.857,3.594z"/>
|
||||
|
||||
<path d="m617.82,492.06c0.027,1.243,1.056,9.473,10.279,6.938,1.392-10.576-4.792-12.563-5.976-13.064-0.198,2.261-1.752,4.364-4.303,6.126z"/>
|
||||
|
||||
<path d="m615.91,492.95c1.654,0.737,6.138,7.636-1.308,13.851-10.149-1.215-9.625-9.235-9.55-10.257,4.278-0.728,8.019-1.979,10.858-3.594z"/>
|
||||
|
||||
<path fill="#f1c700" d="m627.78,491.52c0.831,1.236,1.284,2.536,1.284,3.881,0,7.66-14.378,13.867-32.118,13.867-17.741,0-32.124-6.207-32.124-13.867,0-1.237,0.381-2.438,1.089-3.585-3.936,2.409-6.231,5.296-6.231,8.405,0,8.402,16.749,15.207,37.411,15.207,20.669,0,37.421-6.805,37.421-15.207,0.001-3.238-2.495-6.233-6.732-8.701z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g fill="#fff">
|
||||
|
||||
<path d="m597.12,504.23c-1.458-2.815-0.844-5.285-0.803-6.996,0.16,0.004,0.317,0.005,0.478,0.005-0.06,0.894-0.052,3.593,0.325,6.991z"/>
|
||||
|
||||
<path d="m572.84,488.82c-0.661,0.648-2.937,3.078-4.184,6.476,2.1-2.619,4.031-5.024,4.688-5.757-0.181-0.236-0.351-0.477-0.504-0.719z"/>
|
||||
|
||||
<path d="m582.4,494.9c-0.296,0.59-1.664,3.014-1.664,6.553,1.033-3.603,2.047-5.626,2.423-6.3-0.261-0.084-0.511-0.164-0.759-0.253z"/>
|
||||
|
||||
<path d="m620.59,489.54c0.657,0.732,2.588,3.138,4.688,5.757-1.247-3.397-3.522-5.827-4.184-6.476-0.154,0.242-0.321,0.483-0.504,0.719z"/>
|
||||
|
||||
<path d="m610.77,495.16c0.373,0.674,1.387,2.697,2.419,6.3,0-3.539-1.367-5.963-1.664-6.553-0.247,0.089-0.496,0.169-0.755,0.253z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g stroke="#000">
|
||||
|
||||
<path fill="#e12211" stroke-linejoin="round" stroke-linecap="round" d="m576.26,461.85s-2.332-2.805-5.756-4.99c-3.421-2.194-5.785-0.894-9.277-1.612-3.316-0.688-10.426-2.966-8.386-15.539,2.053,11.032,7.61,7.926,8.077,6.467,0.581-1.814-0.708-4.537-2.603-5.753-2.393-1.538-6.708-5.063-6.708-10.131,0-1.56,0.377-3.218,0.901-4.756,0.566-1.675,3.003-3.86,2.557-6.782,1.879,4.552-2.285,4.675,2.022,11.125,1.16,1.736,2.86,0.363,3.148-0.958,0.442-2.052-0.822-4.79-1.779-6.984-0.962-2.188-1.82-5.789-0.962-8.896,1.235-4.453,3.868-5.447,4.794-8.768,0.561-2.009-2.656-6.852,2.054-10.679-2.584,5.996,0.959,7.939,1.779,9.723,0.822,1.78-0.417,5.209-0.547,6.435-0.171,1.612,2.178,2.282,3.695,1.265,1.955-1.3,1.678-5.234,1.819-7.422,0.136-2.197,1.985-3.532,4.311-4.627,2.329-1.094,6.149-5.379,3.219-11.946,7.264,5.734,5.718,11.09,5.029,13.282-0.683,2.191-2.052,6.301-1.771,9.035,0.27,2.742,2.184,2.602,2.868,1.508,0.688-1.095-0.373-4.674-0.138-6.573,0.4-3.227,5.537-3.862,5.804-6.267,0.222-2.001-4.978-6.153-4.694-9.609,0.445-5.436,4.978-3.62,3.275-9.52,3.697,3.697,1.232,6.499,0.959,7.867-0.273,1.371,0.055,3.479,1.78,3.291,1.233-0.141,2.381-1.729,3.149-4.795,2.085-8.319-4.873-6.527-4.146-12.84,2.566,4.148,5.99,1.523,9.867,7.603,1.874,2.938,0.988,5.373,0.579,7.425-0.411,2.058,1.371,4.114,2.603,2.195,2.794-4.354-1.274-5.243,1.916-10.888-1.149,8.436,3.851,5.978,3.147,14.858-0.291,3.675-4.79,6.982-5.206,10-0.347,2.572,0.449,5.575,1.406,6.533,1.584,1.586,3.188,1.326,3.699-0.549,0.407-1.503-0.413-3.114-0.962-4.759-0.621-1.868,0.601-3.651,2.979-4.243,1.821-0.453,3.977-3.488,3.56-6.16-0.426-2.792-4.774-4.581-0.408-11.643-2.45,7.937,4.134,6.937,4.962,12.396,0.353,2.322-0.43,3.844-0.648,5.063-0.282,1.556,4.211,3.806,2.811-2.258,4.718,3.862,5.063,8.629,4.517,10.407-0.551,1.78-2.056,3.968-1.098,5.886s3.249,2.671,4.52,0.822c1.059-1.541,1.366-3.745,0.582-6.299-0.844-2.749-4.469-3.581-0.513-9.793,0,6.212,4.919,8.503,5.714,11.504,0.393,1.481-0.442,3.219,0.242,4.176,0.685,0.962,4.938,0.58,1.645-6.843,5.86,4.789,2.737,10.437,0.82,11.948-1.914,1.506-5.339,5.441-5.339,8.867,0,3.422,3.704,6.521,4.929,3.559,1.353-3.271-1.383-6.007,1.507-10.547-0.765,4.582,0.693,9.567,2.872,9.859,1.819,0.244,2.356-2.655,2.982-4.622,1.839,5.22-2.16,7.878-2.982,9.109-0.822,1.229-1.197,4.484-2.738,5.342-1.29,0.713-4.38,1.406-6.296,2.769-1.916,1.368-4.174,4.275-0.683,4.799,3.212,0.479,5.377-0.508,7.391-5.48,2.98,9.223-5.41,12.905-9.586,13.423-3.398,0.415-9.174,0.544-10.818,1.776-1.643,1.231-1.643,3.214-1.643,3.214s-2.602,4.729-7.805,5.824c-5.201,1.097-19.718,1.917-23.962,0.273s-7.4-3.84-9.036-6.097zm66.648-45.118c-5.9,5.665-0.803,12.679,0.446,8.628,1.39-4.507-2.096-3.241-0.446-8.628zm-16.259,21.773c-1.02,1.02-0.98,2.195-0.445,2.808,0.445,0.512,1.472,0.037,2.156-0.475,0.687-0.517,1.664-1.645,1.715-3.082,0.09-2.557-2.178-2.633-2.433-5.856-1.2,2.4,2.089,3.523-0.993,6.605zm-5.342-23.107c-1.089,3.51,1.18,4.697,1.493,6.006,0.308,1.287-0.256,1.855,0.151,2.93,0.411,1.075,1.594,2.105,2.26,0.769,0.75-1.494,0.432-2.606-0.099-3.952-0.686-1.743-3.805-1.993-3.805-5.753zm-6.11-12.98c1.332,2.896,0.14,3.552,0.716,6.829,0.206,1.17,1.912,3.409,2.519,1.591,0.512-1.535,0.41-3.078-0.617-4.105-2.288-2.287-0.356-2.552-2.618-4.315zm-6.574,11.655c4.591,5.847-0.048,11.04-1.026,11.864-0.977,0.821-1.542,1.692-1.797,2.513-0.255,0.826-0.052,2.363,1.384,2.004,1.439-0.361,2.673-1.848,3.339-3.081,0.668-1.229,1.598-4.002,1.696-6.265,0.15-3.439-1.505-6.773-3.596-7.035zm-6.832-1.077c-1.868,2.38,0.052,3.88-0.203,5.494-0.149,0.946-0.924,1.438-0.768,2.206,0.15,0.773,1.229,1.593,1.949,0.976,0.716-0.614,1.334-2.668,0.82-3.539-0.511-0.875-2.979-2.11-1.798-5.137zm-10.214-6.161c-4.343,5.054-1.492,10.216-0.927,10.889,0.566,0.664,2.617,0.929,2.72-0.98,0.208-3.886-3.511-3.886-1.793-9.909zm-5.958,9.653c-2.987,8.139,0.14,8.804,0.716,8.42,0.772-0.511,1.334-1.54,0.979-2.258-0.361-0.72-2.432-0.107-1.695-6.162zm-6.676-3.597c0.979,4.318-1.437,4.527-2.058,9.865-0.236,2.036,0.67,4.361,1.079,5.182,0.409,0.826,1.596,1.42,2.314,0.46,0.77-1.025,0.973-2.206,0.408-3.337-0.463-0.928-2.241-1.436-2.211-3.542,0.033-2.143,2.449-6.185,0.468-8.628zm-10.94,0.363c-2.247,4.872,2.253,5.163,2.93,6.979,0.414,1.11,1.229,2.056,1.897,0.98,0.67-1.079,0.413-3.081-0.465-3.906-0.871-0.819-4.362-1.265-4.362-4.053zm-4.108,8.473c-1.598,2.482,1.319,3.732,0.512,6.723-0.445,1.646-0.615,2.771-0.308,3.393,0.308,0.615,1.695,0.924,2.313-0.258,0.615-1.178,0.702-2.16,0.511-3.135-0.357-1.824-3.028-3.174-3.028-6.723zm11.4-17.44c-0.453,0.623-0.874,2.411-0.566,3.54,0.311,1.131,1.802,0.835,2.107,0.257,0.463-0.871-0.183-1.63-0.053-2.613,0.253-1.906,2.894-1.781,2.208-5.139-0.553,3.096-3.03,3.029-3.696,3.955zm18.637-2.621c-0.215,2.016,0.618,5.6,1.899,6.776,1.285,1.182,2.946-0.305,3.028-1.744,0.104-1.831-1.932-2.553-2.253-4.616-0.617-3.965,4.399-5.675,0.409-10.788,1.866,5.655-2.465,4.573-3.083,10.372z"/>
|
||||
|
||||
<path fill="#f1c700" d="m615.28,477.31c1.009,1.263,1.566,2.65,1.566,4.11,0,5.835-8.932,10.569-19.953,10.569-11.018,0-19.952-4.734-19.952-10.569,0-1.4,0.521-2.734,1.455-3.958-4.11,2.104-6.614,4.901-6.614,7.977,0,6.521,11.282,11.808,25.208,11.808,13.924,0,25.213-5.286,25.213-11.808,0-3.154-2.634-6.012-6.923-8.129z"/>
|
||||
|
||||
<path fill="#e12211" d="m615.59,477.73c-0.229,0.014-0.333,0.168-0.563-0.064-0.336-0.343-1.774-5.841-2.258-7.72-3.385,1.708-9.229,2.841-15.875,2.841-6.458,0-12.236-1.075-15.655-2.727-0.51,1.98-1.901,7.273-2.233,7.605-0.296,0.305-0.373-0.063-0.834,0.111-0.788,1.137-1.228,2.361-1.228,3.644,0,5.835,8.935,10.569,19.952,10.569,11.021,0,19.953-4.734,19.953-10.569,0-1.299-0.451-2.542-1.259-3.69z"/>
|
||||
|
||||
<path fill="#e87817" d="m579.67,467.35c1.768,4.648-2.587,10.577-1.52,13.252,1.063,2.667,3.491,4.079,5.861,4.878,5.991,2.019,7.075-3.606,8.075-14.169-3.69,0-9.599-1.144-12.416-3.961z"/>
|
||||
|
||||
<path fill="#29166f" d="m614.32,467.35c-1.768,4.648,2.587,10.577,1.52,13.252-1.063,2.667-3.491,4.079-5.861,4.878-5.991,2.019-7.075-3.606-8.075-14.169,3.691,0,9.599-1.144,12.416-3.961z"/>
|
||||
|
||||
<path fill="#fff" d="m602.32,471.78c0.75,2.538,2.585,11.549,1.657,13.4-1.125,2.239-4.041,3.375-6.981,3.406s-5.856-1.167-6.981-3.406c-0.928-1.852,0.907-10.862,1.657-13.4h10.648z"/>
|
||||
|
||||
<path fill="#e87817" d="m579.97,432c-5.032-3.902-7.604-3.569-7.604-3.569s-2.019,1.635-2.66,7.97c-0.405,4.006-0.633,10.451,2.174,16.628,2.774,6.112,7.654,12.183,9.485,14.039,1.834,1.854,3.345,3.692,3.345,3.692l10.187-4.366s-0.293-2.36-0.374-4.969c-0.077-2.607-1.494-10.163-4.002-16.389-2.543-6.29-7.373-10.568-10.551-13.036z"/>
|
||||
|
||||
<path fill="#29166f" d="m613.86,432c5.032-3.902,7.604-3.569,7.604-3.569s2.02,1.635,2.66,7.97c0.406,4.006,0.633,10.451-2.173,16.628-2.774,6.112-7.654,12.183-9.485,14.039-1.834,1.854-3.345,3.692-3.345,3.692l-10.187-4.366s0.293-2.36,0.374-4.969c0.077-2.607,1.494-10.163,4.002-16.389,2.542-6.29,7.372-10.568,10.55-13.036z"/>
|
||||
|
||||
<path fill="#fff" d="m602.57,430.27c-3.088-5.569-5.582-6.276-5.582-6.276s-2.5,0.707-5.586,6.276c-1.951,3.521-4.7,9.355-4.556,16.139,0.142,6.711,2.234,14.213,3.185,16.641,0.956,2.428,1.62,4.713,1.62,4.713h11.083s0.66-2.285,1.614-4.713c0.956-2.428,2.632-9.93,2.779-16.641,0.143-6.783-2.609-12.617-4.557-16.139z"/>
|
||||
|
||||
<path fill="#f4e109" d="m596.59,470.93c-9.241,0-17.013-2.043-19.26-4.805-0.03,0.133-0.047,0.266-0.047,0.404,0,3.451,8.643,6.256,19.307,6.256,10.662,0,19.308-2.805,19.308-6.256,0-0.139-0.019-0.271-0.048-0.404-2.251,2.762-10.023,4.805-19.26,4.805z"/>
|
||||
|
||||
<path fill="#e87817" d="m596.59,468.84c-9.572,0-17.604-2.131-19.827-5.008-0.063,0.21-0.099,0.432-0.099,0.646,0,3.564,8.917,6.457,19.926,6.457,11.001,0,19.924-2.893,19.924-6.457,0-0.215-0.036-0.437-0.099-0.646-2.226,2.877-10.255,5.008-19.825,5.008z"/>
|
||||
|
||||
<path fill="#f4e109" d="m596.59,466.98c-9.833,0-18.088-2.182-20.402-5.132-0.021,0.121-0.037,0.248-0.037,0.369,0,3.654,9.148,6.62,20.439,6.62,11.283,0,20.435-2.966,20.435-6.62,0-0.121-0.013-0.248-0.033-0.369-2.314,2.95-10.573,5.132-20.402,5.132z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g fill="#000">
|
||||
|
||||
<path d="m577.27,430.13c0.364,0.851-0.835,2.477-2.762,3.303s-3.931,0.573-4.296-0.277l-0.109,0.557c0.364,0.851,2.463,1.506,4.75,0.524,2.288-0.98,3.261-2.951,2.896-3.803l-0.479-0.304z"/>
|
||||
|
||||
<path d="m581.32,433.08c0.569,1.327-1.313,4.192-4.564,5.585-3.25,1.395-6.623,0.784-7.192-0.545l-0.042,0.734c0.568,1.328,4.137,2.432,7.703,0.902,3.565-1.528,5.226-4.873,4.656-6.202l-0.561-0.474z"/>
|
||||
|
||||
<path d="m584.63,436.13c0.718,1.674-1.6,5.621-5.78,7.415-4.183,1.793-8.64,0.749-9.357-0.924l0.065,0.862c0.718,1.674,5.294,3.135,9.784,1.21,4.489-1.925,6.586-6.248,5.867-7.921l-0.579-0.642z"/>
|
||||
|
||||
<path d="M586.84,446.47c-1.25,1.38-3.02,2.7-5.18,3.62-5.01,2.15-10.19,1.29-11.04-0.68l0.16,0.68c0.85,1.98,6.07,3.49,11.38,1.22,1.98-0.85,3.6-2.09,4.78-3.37-0.03-0.5-0.09-0.98-0.1-1.47z"/>
|
||||
|
||||
<path d="m616.56,430.13c-0.364,0.851,0.834,2.477,2.762,3.303,1.927,0.826,3.931,0.573,4.295-0.277l0.11,0.557c-0.364,0.851-2.463,1.506-4.751,0.524-2.287-0.98-3.26-2.951-2.895-3.803l0.479-0.304z"/>
|
||||
|
||||
<path d="m612.52,433.08c-0.569,1.327,1.313,4.192,4.563,5.585,3.251,1.395,6.623,0.784,7.193-0.545l0.041,0.734c-0.568,1.328-4.137,2.432-7.702,0.902-3.565-1.528-5.226-4.873-4.656-6.202l0.561-0.474z"/>
|
||||
|
||||
<path d="m609.21,436.13c-0.718,1.674,1.6,5.621,5.78,7.415,4.182,1.793,8.64,0.749,9.357-0.924l-0.066,0.862c-0.717,1.674-5.293,3.135-9.783,1.21-4.489-1.925-6.586-6.248-5.867-7.921l0.579-0.642z"/>
|
||||
|
||||
<path d="M607.12,446.59c-0.01,0.5-0.06,1-0.09,1.5,1.17,1.24,2.71,2.4,4.63,3.22,5.3,2.27,10.56,0.76,11.4-1.22l0.16-0.68c-0.85,1.97-6.02,2.83-11.03,0.68-2.09-0.89-3.82-2.17-5.07-3.5z"/>
|
||||
|
||||
<path d="m600.83,427.49c0,0.926-1.742,1.948-3.839,1.948s-3.839-1.022-3.839-1.948l-0.32,0.469c0,0.926,1.67,2.354,4.159,2.354s4.159-1.429,4.159-2.354l-0.32-0.469z"/>
|
||||
|
||||
<path d="m603.39,431.79c0,1.445-2.858,3.336-6.396,3.336s-6.396-1.891-6.396-3.336l-0.328,0.658c0,1.445,2.844,3.865,6.724,3.865s6.724-2.42,6.724-3.865l-0.328-0.658z"/>
|
||||
|
||||
<path d="m605.23,435.9c0,1.821-3.686,4.536-8.235,4.536s-8.235-2.715-8.235-4.536l-0.279,0.818c0,1.821,3.63,4.968,8.515,4.968s8.515-3.146,8.515-4.968l-0.29-0.82z"/>
|
||||
|
||||
<path d="m607,443.28c0,2.152-4.238,5.593-10.007,5.593s-10.007-3.44-10.007-5.593l0.143-0.714c0,2.152,4.412,4.994,9.864,4.994s9.864-2.842,9.864-4.994l0.16,0.71z"/>
|
||||
|
||||
</g>
|
||||
|
||||
</g>
|
||||
<g id="lion" stroke="#000" stroke-width="0.476" stroke-linejoin="round" stroke-linecap="round">
|
||||
|
||||
<path fill="#f4e109" d="m578.1,509.26c6.627,6.627-0.549,11.553-4.858,8.988,0.618-1.141,1.062-3.734,1.062-3.734s4.872,0.986,3.796-5.254z"/>
|
||||
|
||||
<g fill="#fff" stroke-width="1.429">
|
||||
|
||||
<path d="m445.45,607.5s-4.234,8.459-7.507,10.099c-0.995,0.498-1.821,0.36-1.821,0.36s4.128,3.283,6.071,4.015c1.943,0.726,5.344,2.427,6.436,2.061,1.095-0.362,4.86-2.188,7.652-3.037,2.795-0.849,4.738-0.728,5.952,0.122,1.216,0.854,3.28,3.768,2.552,4.982-0.729,1.213-2.309,2.064-2.309,2.064s0.242,1.094-0.608,2.063c-0.849,0.972-2.914,1.334-2.914,1.334l-0.972,2.554c-0.484,1.335-2.064,1.455-2.064,1.455s-0.122,3.16-1.215,4.735c-1.093,1.583-2.793,1.945-4.857,1.703-2.065-0.242-5.345-1.818-6.316-1.942-0.971-0.121-2.672-1.094-3.157-2.309-0.488-1.214-2.065-2.915-3.766-4.009-1.701-1.092-11.173-5.1-14.453-5.584-3.279-0.489-13.117-3.158-13.117-3.158s8.381-6.803,9.109-12.756c0.729-5.948,2.309-9.105,9.717-7.404,7.406,1.7,17.587,2.652,17.587,2.652z"/>
|
||||
|
||||
<path d="m513.73,538.17s2.722-2.971,6.152-4.2c6.747-2.421,9.257-0.081,9.257-0.081s0.136-2.521,5.48-4.223c5.344-1.703,8.259-0.727,8.259-0.727s0.972-3.403,3.888-4.617c2.914-1.215,5.829-1.945,6.315-3.885,0.484-1.943-0.73-5.83,0.241-8.018,0.973-2.188,3.771-4.163,6.406-2.825,2.277,1.155,2.127-0.121,3.826,0.362,1.699,0.49,2.458,1.367,2.458,1.367s1.398-1.182,3.828-0.451c2.427,0.725,3.399,1.547,3.399,1.547s2.429,1.457,2.188,3.156c-0.246,1.704-0.974,4.132-3.887,4.617-2.915,0.485-5.584-0.243-7.53,0.727-1.942,0.976-4.372,4.616-5.102,6.56-0.728,1.945-5.828,15.304-13.115,20.647-7.288,5.342-18.219,16.518-21.133,17.245-2.916,0.731-7.287,1.7-7.287,1.7s1.214-16.518-0.729-21.375c-1.944-4.855-2.914-7.526-2.914-7.526z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g fill="url(#grad)" stroke-width="0.667">
|
||||
|
||||
<path d="m375.46,542.44s6.964-6.071,13.295,1.129c6.426,7.311,2.914,15.123-0.366,18.218-3.278,3.098-7.604,6.334-13.298,5.285,1.856-2.311,1.032-3.699,0.785-4.513-0.547-1.786,1.408-1.575,3.362-4.122,1.434-1.861,2.518-5.51,1.625-8.188-1.856-5.57-5.403-7.809-5.403-7.809z"/>
|
||||
|
||||
<path d="m302.22,567.26c5.333-4.255,8.208,3.433,15.121,0.731,3.417-1.335,6.922-8.2,11.295-8.932,4.373-0.727,9.525,2.734,12.752,0.551,5.647-3.826,0.911-13.302,13.117-18.765,8.628-3.862,18.4-3.101,22.956,1.639,4.554,4.733,5.647,11.476,4.19,14.211-1.458,2.731-5.466,6.011-5.466,6.011l-6.377,1.092s-2.186-4.738-6.739-5.465c-4.554-0.729-6.922,2.369-9.109,5.83-2.62,4.148-9.11,9.475-19.312,7.469-3.77-0.739-7.834,4.918-17.672,2.187-4.889-1.357-7.986-6.626-14.756-6.559z"/>
|
||||
|
||||
<path d="m307.31,581.61c4.309-3.92,8.75,0.238,10.765,0.221,3.659-0.029,6.921-2.55,8.015-5.827,1.092-3.281,4.19-6.194,8.015-6.558,3.826-0.367,10.02,0.543,12.752-3.101,2.734-3.642,7.652-14.209,14.028-15.485,6.377-1.272,9.11,0.729,9.838,3.826,0.729,3.1-0.912,9.84-0.912,9.84l-2.549,5.463-8.564,2.918s-0.18,4.371-4.007,5.827c-3.825,1.46-12.752-0.543-17.854,0.729-5.1,1.275-4.653,3.521-11.295,4.737-11.548,2.116-12.173-5.009-18.232-2.59z"/>
|
||||
|
||||
<path d="m303.13,600.59c5.109-6.156,10.974,2.27,17.296-1.594,3.454-2.11,4.564-10.246,8.754-11.705,4.191-1.457,9.473,1.277,12.388-2.55,2.915-3.826,0.547-9.841,6.558-12.572,6.014-2.729,10.932-0.728,10.932-0.728s5.283-1.821,8.199-1.459c2.914,0.365,9.656,4.012,9.656,4.012s1.456,8.377-4.01,13.297c-5.465,4.921-10.565,7.471-15.303,5.83-3.05-1.055-4.917-1.641-7.285,0-2.37,1.641-2.37,4.373-6.924,4.373s-8.198-2.914-10.93-1.094c-2.733,1.819-4.158,5.658-8.928,6.377-12.231,1.844-13.419-5.218-20.403-2.187z"/>
|
||||
|
||||
<path d="m411.96,622.09s-3.158,2.917-5.102,3.767c-1.944,0.851-6.804,2.913-6.804,2.913s4.374,0.489,4.982,1.823c0.606,1.338-1.104,4.906-5.83,2.308,1.976,4.849,9.664,2.724,11.779,1.823,1.767-0.752,4.858-1.336,5.588-0.364,0.728,0.971-0.266,4.265-5.344,3.033,8.016,4.294,11.66-0.729,14.089-2.185,2.43-1.459,4.615-2.551,5.345-1.459,0.729,1.094,1.207,4.313-4.617,4.373,4.886,1.564,8.016-0.485,9.596-2.064,1.577-1.577,2.269-2.009,3.728-2.496,1.457-0.486,6.594-2.361,7.444-5.032,0.852-2.673,0.729-4.616-0.485-6.194-1.215-1.58-2.672-1.945-3.644-1.824-0.972,0.123-1.578,0.73-1.578,1.462,0,0.726,0.848,3.034-1.579,3.884-2.429,0.851-4.98,1.7-7.41,0.973-2.429-0.729-5.586-1.46-7.894-1.336-2.307,0.12-3.886,1.821-5.83,0.849-1.941-0.975-6.434-4.254-6.434-4.254z"/>
|
||||
|
||||
<path d="m547.98,521.16c1.943-1.212,4.13-2.668,7.044-0.483,2.916,2.187,5.155,6.253,8.258,8.987,7.825,6.896,5.394,11.146,1.7,16.516,2.193-9.182-4.856-11.494-4.126-7.041,0.563,3.434,3.069,5.672-1.946,15.06,0.391-3.2,0.244-5.831-0.484-7.531-0.729-1.698-2.672-1.942-2.672,0.245,0,2.182,3.234,10.336-6.073,17.971,7.12-10.635-0.428-14.188-1.944-9.957-2.623,7.322-0.748,14.635-11.173,16.03,8.362-6.896,4.658-12.913,1.215-9.47-4.158,4.158-1.54,11.074-9.961,16.03,4.636-7.661-0.893-7.363-3.155-6.073-1.702,0.971-8.504,5.341-8.504,5.341s0.972-4.37,1.217-8.256c0.24-3.886,1.456-4.614,1.456-4.614s6.315-0.728,8.258-4.617c1.944-3.887,3.158-9.231,7.531-11.172,4.372-1.945,12.631-3.645,14.817-7.771,2.186-4.13,1.701-6.076-0.242-8.988-1.944-2.919-4.131-4.854-3.888-6.559,0.243-1.703,1.186-2.718,2.672-3.648z"/>
|
||||
|
||||
<path d="m543.61,529.36c-2.599-2.209-8.987,0.305-8.987,0.305s4.978-1.125,6.618,1.246c0.909,1.307,0.092,2.912-0.273,2.912,0,0-0.001,0.004-0.003,0.004-0.602-0.506-1.366-0.822-2.213-0.822-1.911,0-3.46,1.55-3.46,3.461,0,1.864,1.467,3.346,3.295,3.593,2.319,0.313,4.523-0.357,5.842-1.771,2.023-2.171,2.825-5.83-0.819-8.928z"/>
|
||||
|
||||
<path d="m527.3,533.01c-2.945-0.79-8.198,1.273-8.198,1.273s5.968-0.32,7.378,1.457c1.167,1.472,0.584,2.716,0.376,3.006-0.519-0.248-1.098-0.393-1.712-0.393-2.181,0-3.948,1.766-3.948,3.949,0,2.179,1.768,3.947,3.948,3.947,0.183,0,1.249-0.166,1.609-0.344,0.58-0.287,1.353-0.709,1.829-1.141,1.935-1.756,2.5-3.363,2.726-6.291,0.14-1.814-1.063-4.67-4.008-5.463z"/>
|
||||
|
||||
<path d="m442.89,610.35s-0.639,3.826-5.102,5.559c-2.21,0.857-4.357,0.055-5.591-0.609,0.699-0.781,1.127-1.812,1.127-2.945,0-2.449-1.982-4.434-4.432-4.434-2.239,0-4.089,1.661-4.389,3.817-0.093,0.667-0.15,1.32,0.017,2.058,0.495,2.16,2.297,4.455,5.524,5.301,3.826,1,8.157-0.08,10.475-2.825,2.46-2.917,2.371-5.922,2.371-5.922z"/>
|
||||
|
||||
<path d="m502.07,460.92c19.043-1.048,17.543,12.202,9.716,15.062,0,0-3.765-0.242-8.016,0.123-4.801,0.409-5.969-5.17-0.094-5.92,5.737-0.733,9.562-5.813-1.606-9.265z"/>
|
||||
|
||||
<path d="m437.46,479.14c7.594-9.643,15.844,0.732,22.349-3.158,3.758-2.248,7.529-7.775,13.36-7.047,5.83,0.729,9.23,1.943,12.145,0.242,2.914-1.697,7.775-6.557,14.333-4.613,3.462,1.029,4.989,3.785,5.28,5.225,0.417,2.055,0.306,6.435,0.306,6.435l-10.201,1.7c-3.888,1.701-7.774,4.375-7.774,4.375s-4.129-5.831-7.773-6.075c-3.644-0.241-9.472,0.244-12.146,2.429-2.672,2.188-6.779,6.01-11.174,4.616-4.798-1.522-8.798-8.334-18.705-4.129z"/>
|
||||
|
||||
<path d="m428.23,491.77c11.636-9.71,11.386,3.228,21.761-6.71,4.08-3.907,8.063-1.188,13.46-4.463,5.13-3.113,9.715-9.23,16.031-7.287,6.316,1.939,9.961,7.529,9.961,7.529s-8.017,5.586-9.717,8.984c-1.701,3.402-10.033-1.645-14.575-1.214-5.101,0.489-7.685,3.819-12.389,6.075-14.333,6.877-13.583-5.686-24.532-2.914z"/>
|
||||
|
||||
<path d="m415.36,520.92c9.572,5.393,15.879,2.891,17.973,0,2.939-4.056,3.144-9.014,7.287-10.928,4.055-1.873,8.56,0.189,10.689-2.432,3.157-3.885-0.703-16.087,6.072-20.648,12.631-8.5,24.29,0.488,24.29,0.488s-5.708,5.828-6.558,13.848c-0.718,6.766,2.187,10.442,2.187,10.442s-3.479,6.896-13.118,5.59c-11.751-1.593-13.053-0.324-18.751,4.907-6.874,6.313-20.595,9.499-30.071-1.267z"/>
|
||||
|
||||
<path d="m405.88,539.38c8.234-5.445,10.359,1.742,19.433,1.214,4.143-0.241,7.289-2.185,9.959-6.798,2.672-4.617,4.616-7.047,9.959-7.286,5.344-0.245,9.716-0.977,11.902-4.378,2.187-3.398-1.699-12.875,3.887-19.187,5.588-6.317,14.333-4.376,14.333-4.376s-1.194,5.555,0.122,9.594c1.7,5.223,4.493,5.469,4.493,5.469s-5.068,2.232-6.314,8.621c-0.96,4.917,0.97,9.355,0.97,9.355s-6.316,6.559-16.76,5.102c-10.445-1.458-11.417-1.945-15.061,0.728-3.644,2.671-6.719,6.966-15.059,7.286-12.005,0.461-11.505-7.977-21.864-5.344z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g stroke-width="1.429" fill="#fff">
|
||||
|
||||
<path d="m387.91,572.9c-9.666,7.408-12.791,1.471-20.041-1.459-1.994-0.806-5.344-0.242-5.344-0.242l-1.699-3.888s-0.85-2.671,0.606-3.886c1.458-1.217,5.952-0.85,6.802,0.363,0.851,1.217,3.399,5.104,4.857,6.014,1.458,0.906,4.858,1.883,7.409,1.032,2.55-0.853,8.017-5.222,13.846-9.351,5.83-4.131,10.808-6.317,16.031-6.196,5.223,0.12,9.961,2.184,13.967,2.429,4.01,0.246,8.988-1.822,12.997-4.127,4.007-2.311,15.008-4.386,24.776-12.025,3.811-2.98,12.631-12.995,14.938-15.787,2.309-2.798,12.631,1.943,14.576,3.4,1.944,1.458,10.444,0.484,10.444,0.484s4.979-2.063,6.679-2.309c1.701-0.242,2.186,0.729,2.186,0.729s6.195,8.627,8.138,15.428c1.946,6.797,1.946,21.252,1.095,26.473-0.851,5.227-1.823,14.456-8.624,18.828-6.803,4.371-28.056,14.087-38.986,16.517-10.932,2.429-17.611,6.317-22.592,6.071-4.98-0.24-16.762-3.396-21.01-3.884-4.25-0.487-8.501-0.606-8.501-0.606s-0.487,6.68-2.309,10.808c-1.822,4.127-8.859,10.615-14.204,12.071-5.342,1.459-13.245,2.016-13.729,2.621-0.488,0.61-0.73,2.432,0,3.766,0.727,1.34,4.613,8.383,5.951,9.355,1.552,1.129,5.886,2.124,7.771,1.821,1.815-0.292,4.25,0,5.102,1.335,0.85,1.336,0.851,4.372,0.364,4.979-0.486,0.61-1.578,1.096-1.578,1.096s-0.244,2.188-1.215,2.796c-0.972,0.604-2.673,1.092-2.673,1.092s-0.122,0.847-1.215,1.696c-1.093,0.853-1.785,0.725-1.785,0.725s-1.369,0.237-2.464,0.978c-1.805,1.221-7.742-0.242-10.082-2.43-1.821-1.701-2.188-5.342-2.671-6.559-0.486-1.212-2.066-2.79-3.402-3.765-1.336-0.973-21.618-25.263-21.618-27.811,0-2.552,2.31-9.476,5.346-10.809,3.034-1.338,8.621-3.159,9.351-5.103,0.729-1.942-1.848-11.221,0.242-17.734,2.977-9.277,4.29-9.465,12.268-14.936z"/>
|
||||
|
||||
<path d="m363.62,573.09c4.938,1.414,1.375,8.039-3.461,5.828-2.339-1.069-4.281-4.279-3.279-6.833,1.001-2.546,2.825-3.277,4.098-3.005,1.275,0.271,3.644,1.824,3.644,1.824l-3.735,1.91c-0.762,1.458-0.364,2.732,1.094,3.464,1.305,0.651,3.077-0.711,1.639-3.188z"/>
|
||||
|
||||
<path d="m474.02,563.19s13.489,1.655,17.775,5.622c4.885,4.523,3.845,8.465,3.845,8.465s7.59-0.119,10.687,3.039c2.536,2.587,4.07,4.248,4.07,4.248l9.534,2.672c3.28,2.673,4.676,5.223,4.676,5.223s2.246-0.119,6.132,1.336c3.887,1.459,6.559,2.916,8.744,2.55,2.187-0.362,6.802-1.577,7.774-3.767,0.973-2.186-0.607-7.526,1.82-9.957,2.43-2.428,3.887-3.034,5.587-2.549,1.703,0.484,3.279,4.492,3.279,4.492l4.253,2.917c0.713,1.906,0,4.005,0,4.005s0.363,0.731,0.971,2.794c0.607,2.063-0.304,2.978-0.304,2.978s0.061,2.488-1.274,3.461c-1.338,0.973-3.158,1.216-3.158,1.216s0.561,2.091-0.168,3.425c-0.729,1.333-1.429,1.785-2.5,2.262-0.831,0.368-2.434,0.141-3.405,1.116-0.973,0.967-1.94,3.396-5.101,2.913-3.159-0.486-4.131-2.428-8.26-3.279-4.128-0.849-18.581-2.065-27.083-3.648-8.502-1.574-15.302-1.574-23.563-1.695-8.26-0.121-20.282-1.459-20.769-5.224-0.486-3.764,2.552-7.53,2.186-9.472-0.364-1.944-2.307-8.016-2.307-10.203s-1.568-10.701,0.849-13.848c1.216-1.579,4.008-1.579,5.71-1.092z"/>
|
||||
|
||||
<path d="m364.89,565.07c-0.399,3.365-3.587,1.303-2.824-0.64,0.581-1.479,3.279-1.641,4.646-1.003,1.366,0.639,3.644,3.464,3.825,4.372,0.183,0.911,0.51-3.675-1.183-5.555-1.641-1.825-5.193-2.825-8.018-1.367-2.445,1.261-3.279,3.188-2.915,5.919,0.313,2.352,2.565,3.931,4.646,4.192,3.548,0.448,6.298-3.177,1.823-5.918z"/>
|
||||
|
||||
<path d="m375.09,567.07c5.21-2.135,0.96-7.26-1.458-6.922-2.263,0.316-3.593,2.115-3.828,3.643-0.24,1.564,0.296,3.826,1.185,4.826,0.684,0.771,2.445,1.321,2.445,1.321s-1.904-2.741-0.714-4.597c1.142-1.78,3.58-2.093,2.37,1.729z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<path fill="#fff" stroke="none" d="m463.89,559c8.617,3.146,9.46,3.168,15.442,4.337,3.271,0.64,2.942,4.616,0.585,7.446s-0.472,10.842-2.357,13.199-6.579,5.318-6.579,5.318c-4.263-3.434-8.504-4.848-8.976-11.918-0.47-7.08,1.89-18.39,1.89-18.39z"/>
|
||||
|
||||
<g fill="#f4e109">
|
||||
|
||||
<path d="m399.81,658.08s-2.623-1.095-3.349,0.82c-0.728,1.916,0.488,2.89,0.488,2.89s4.104,0.651,1.55,5.729c6.804-3.952,1.311-9.439,1.311-9.439z"/>
|
||||
|
||||
<path d="m403.26,655.94s-2.242-0.728-2.632,1.055c-0.389,1.783,0.772,3.008,0.772,3.008s2.961,0.75,1.796,4.503c5.353-4.504,0.064-8.566,0.064-8.566z"/>
|
||||
|
||||
<path d="m409.39,649.88s-0.831-0.381-1.438,0.833c-0.606,1.218-0.005,2.833-0.005,2.833s3.294,0.391,3.339,4.549c4.642-6.596-1.896-8.215-1.896-8.215z"/>
|
||||
|
||||
<path d="m454.1,634.6s-2.087-0.613-2.574,2.422c-0.403,2.519,1.482,2.802,1.482,2.802s3.485-1.263,3.643,2.913c5.405-4.739-2.551-8.137-2.551-8.137z"/>
|
||||
|
||||
<path d="m457.37,630.71s-1.765-0.27-1.817,2.491c-0.043,2.289,1.474,2.295,1.474,2.295s3.4-1.688,4.208,2.357c4.254-4.857-3.865-7.143-3.865-7.143z"/>
|
||||
|
||||
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
@@ -91,13 +91,8 @@
|
||||
"related_applications": [
|
||||
{
|
||||
"platform": "play",
|
||||
"url": "https://play.google.com/store/apps/details?id=pub.ditto.app",
|
||||
"id": "pub.ditto.app"
|
||||
},
|
||||
{
|
||||
"platform": "itunes",
|
||||
"url": "https://apps.apple.com/us/app/ditto-fun-social-media/id6761851821",
|
||||
"id": "6761851821"
|
||||
"url": "https://play.google.com/store/apps/details?id=spot.agora.app",
|
||||
"id": "spot.agora.app"
|
||||
}
|
||||
],
|
||||
"prefer_related_applications": false
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
+35
-1
@@ -56,8 +56,42 @@ self.addEventListener('notificationclick', (event) => {
|
||||
});
|
||||
|
||||
// --- Activate immediately ---
|
||||
//
|
||||
// On activate:
|
||||
// 1. Wipe every Cache Storage entry. A previous version of Agora deployed
|
||||
// a precaching service worker (Workbox-style) that's still serving stale
|
||||
// HTML/JS to returning users on this origin. Clearing caches means future
|
||||
// requests bypass anything the old SW left behind.
|
||||
// 2. Take control of all open clients via clients.claim().
|
||||
// 3. Force each controlled tab to navigate to its own URL. clients.claim()
|
||||
// only changes which SW handles future fetches — it does not re-render
|
||||
// pages that already finished loading. Without the explicit navigate,
|
||||
// the user is stuck on the old rendered bundle until they manually
|
||||
// close and reopen the tab. Since this SW has no fetch handler, the
|
||||
// navigation falls through to the network and gets the new build.
|
||||
//
|
||||
// This SW has no 'fetch' handler, so it never repopulates a cache — push
|
||||
// notifications are the only thing it intercepts.
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
await self.clients.claim();
|
||||
|
||||
// Soft-reload every open same-origin tab so it picks up the fresh
|
||||
// index.html + hashed bundle from the network. WindowClient.navigate()
|
||||
// is same-origin-only by spec, which is exactly what we want.
|
||||
const windowClients = await self.clients.matchAll({ type: 'window' });
|
||||
await Promise.all(
|
||||
windowClients.map((client) =>
|
||||
'navigate' in client
|
||||
? client.navigate(client.url).catch(() => {})
|
||||
: Promise.resolve(),
|
||||
),
|
||||
);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Build a heavily-simplified land-polygon dataset for the hero globe.
|
||||
//
|
||||
// Input: Natural Earth 110m countries TopoJSON
|
||||
// (https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json)
|
||||
//
|
||||
// Output: src/lib/landPolygons.ts — an array of rings (each ring is a flat
|
||||
// array [lng0, lat0, lng1, lat1, ...]) representing landmasses.
|
||||
//
|
||||
// Run with: node scripts/build-land-polygons.mjs
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INPUT = process.argv[2] ?? '/tmp/opencode/countries-110m.json';
|
||||
const OUTPUT = path.join(REPO_ROOT, 'src/lib/landPolygons.ts');
|
||||
|
||||
const topo = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
|
||||
const layer = topo.objects.countries;
|
||||
const transform = topo.transform;
|
||||
|
||||
/** Decode a topojson arc into absolute [lng, lat] pairs. */
|
||||
function decodeArc(arc) {
|
||||
const out = [];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (const [dx, dy] of arc) {
|
||||
x += dx;
|
||||
y += dy;
|
||||
out.push([
|
||||
x * transform.scale[0] + transform.translate[0],
|
||||
y * transform.scale[1] + transform.translate[1],
|
||||
]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const arcs = topo.arcs.map(decodeArc);
|
||||
|
||||
/** Resolve a topojson arc index (negative means reversed) into points. */
|
||||
function resolveArc(i) {
|
||||
if (i < 0) {
|
||||
const arc = arcs[~i];
|
||||
return arc.slice().reverse();
|
||||
}
|
||||
return arcs[i];
|
||||
}
|
||||
|
||||
/** Build a ring from an array of arc indices. */
|
||||
function buildRing(arcIndices) {
|
||||
const ring = [];
|
||||
for (let i = 0; i < arcIndices.length; i++) {
|
||||
const seg = resolveArc(arcIndices[i]);
|
||||
// Skip the duplicated joining point between consecutive arcs.
|
||||
if (i === 0) ring.push(...seg);
|
||||
else ring.push(...seg.slice(1));
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
const rings = [];
|
||||
for (const feature of layer.geometries) {
|
||||
if (feature.type === 'Polygon') {
|
||||
for (const arcIndices of feature.arcs) {
|
||||
rings.push(buildRing(arcIndices));
|
||||
}
|
||||
} else if (feature.type === 'MultiPolygon') {
|
||||
for (const polygon of feature.arcs) {
|
||||
for (const arcIndices of polygon) {
|
||||
rings.push(buildRing(arcIndices));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the full Natural Earth 110m resolution. We skip Douglas-Peucker
|
||||
// entirely so coastlines look organic at hero scale rather than blocky.
|
||||
// We still quantize to 0.1° (well below the rendered pixel size on a
|
||||
// ~600 px globe) which is a free ~30 % byte saving with no visible loss.
|
||||
const MIN_VERTS = 3;
|
||||
|
||||
const simplifiedRings = [];
|
||||
for (const ring of rings) {
|
||||
if (ring.length < MIN_VERTS) continue;
|
||||
const flat = [];
|
||||
for (const [lng, lat] of ring) {
|
||||
flat.push(Math.round(lng * 10) / 10, Math.round(lat * 10) / 10);
|
||||
}
|
||||
simplifiedRings.push(flat);
|
||||
}
|
||||
|
||||
const totalCoords = simplifiedRings.reduce((sum, r) => sum + r.length / 2, 0);
|
||||
|
||||
const banner = `/**
|
||||
* Simplified land polygons for the hero globe.
|
||||
*
|
||||
* Generated from Natural Earth 110m country boundaries via
|
||||
* \`scripts/build-land-polygons.mjs\`. Each entry is a flat \`[lng, lat, lng,
|
||||
* lat, ...]\` ring. We keep the data inline (rather than fetching a TopoJSON
|
||||
* blob at runtime) so the hero renders instantly, with no network jitter and
|
||||
* no extra runtime dependency.
|
||||
*
|
||||
* Do not edit by hand — re-run the script to regenerate.
|
||||
*/
|
||||
`;
|
||||
|
||||
const body = `export const LAND_RINGS: readonly (readonly number[])[] = [\n${
|
||||
simplifiedRings.map((r) => ` [${r.join(',')}],`).join('\n')
|
||||
}\n];\n`;
|
||||
|
||||
fs.writeFileSync(OUTPUT, banner + body);
|
||||
|
||||
console.log(
|
||||
`Wrote ${OUTPUT}`,
|
||||
`\n rings: ${simplifiedRings.length}`,
|
||||
`\n vertices: ${totalCoords}`,
|
||||
`\n bytes: ${fs.statSync(OUTPUT).size}`,
|
||||
);
|
||||
@@ -25,7 +25,7 @@
|
||||
* node scripts/extract-release-notes.mjs <version> [--summary] [--changelog <path>]
|
||||
*
|
||||
* --summary Print only the summary paragraph (no headings, no bullets).
|
||||
* Falls back to "Ditto vX.Y.Z" if the section has no summary.
|
||||
* Falls back to "Agora vX.Y.Z" if the section has no summary.
|
||||
* --changelog Path to the changelog file. Defaults to CHANGELOG.md.
|
||||
*
|
||||
* Exits 0 with the extracted text on stdout. Exits non-zero if the version is
|
||||
@@ -128,7 +128,7 @@ if (!section) {
|
||||
|
||||
if (summary) {
|
||||
const text = extractSummary(section);
|
||||
stdout.write(text ?? `Ditto v${version}`);
|
||||
stdout.write(text ?? `Agora v${version}`);
|
||||
stdout.write('\n');
|
||||
} else {
|
||||
const body = trimBlankEdges(section).join('\n');
|
||||
@@ -136,6 +136,6 @@ if (summary) {
|
||||
stdout.write(body);
|
||||
stdout.write('\n');
|
||||
} else {
|
||||
stdout.write(`Ditto v${version}\n`);
|
||||
stdout.write(`Agora v${version}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-21
@@ -6,8 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { InferSeoMetaPlugin } from "@unhead/addons";
|
||||
import { createHead, UnheadProvider } from "@unhead/react/client";
|
||||
import { AppProvider } from "@/components/AppProvider";
|
||||
import { DMProviderWrapper } from "@/components/DMProviderWrapper";
|
||||
import { InitialSyncGate } from "@/components/InitialSyncGate";
|
||||
import { InitialSyncRunner } from "@/components/InitialSyncRunner";
|
||||
import { NativeNotifications } from "@/components/NativeNotifications";
|
||||
import NostrProvider from "@/components/NostrProvider";
|
||||
import { NostrSync } from "@/components/NostrSync";
|
||||
@@ -19,10 +18,8 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { SparkWalletProvider } from "@/contexts/SparkWalletContext";
|
||||
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import { PROTOCOL_MODE } from "@samthomson/nostr-messaging/core";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
const head = createHead({
|
||||
@@ -44,7 +41,7 @@ const hardcodedConfig: AppConfig = {
|
||||
appName: "Agora",
|
||||
appId: "agora",
|
||||
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
|
||||
homePage: "feed",
|
||||
homePage: "campaigns",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
magicMouse: false,
|
||||
theme: "system",
|
||||
@@ -156,17 +153,9 @@ const hardcodedConfig: AppConfig = {
|
||||
{ id: 'hot-posts' },
|
||||
{ id: 'ai-chat' },
|
||||
],
|
||||
messaging: {
|
||||
enabled: true,
|
||||
relayMode: 'hybrid',
|
||||
protocolMode: PROTOCOL_MODE.NIP17_ONLY,
|
||||
renderInlineMedia: true,
|
||||
soundEnabled: false,
|
||||
devMode: false,
|
||||
},
|
||||
aiBaseURL: 'https://ai.shakespeare.diy/v1',
|
||||
aiApiKey: '',
|
||||
aiModel: 'grok-4.1-fast',
|
||||
aiModel: 'google/gemma-4-26b',
|
||||
aiSystemPrompt: '',
|
||||
};
|
||||
|
||||
@@ -210,18 +199,13 @@ export function App() {
|
||||
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<InitialSyncRunner />
|
||||
<NativeNotifications />
|
||||
|
||||
<NWCProvider>
|
||||
<SparkWalletProvider>
|
||||
<DMProviderWrapper>
|
||||
<TooltipProvider>
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
<AppRouter />
|
||||
</TooltipProvider>
|
||||
</DMProviderWrapper>
|
||||
</SparkWalletProvider>
|
||||
</NWCProvider>
|
||||
</NostrProvider>
|
||||
</NostrLoginProvider>
|
||||
|
||||
+34
-8
@@ -6,7 +6,7 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { sidebarItemIcon } from "@/lib/sidebarItems";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
import { FundraiserLayout } from "./components/FundraiserLayout";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { VersionCheck } from "./components/VersionCheck";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
@@ -24,11 +24,16 @@ const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").th
|
||||
// Lazy-loaded emoji pack dialog
|
||||
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
// HomePage eagerly imported all page components; now lazy-loaded
|
||||
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
|
||||
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
|
||||
// when an naddr resolves to kind 30223.) The campaigns list IS the homepage;
|
||||
// the configurable HomePage delegation from the Twitter-era app is gone.
|
||||
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
|
||||
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
|
||||
const AllCampaignsPage = lazy(() => import("./pages/AllCampaignsPage").then(m => ({ default: m.AllCampaignsPage })));
|
||||
|
||||
// All other pages: code-split via React.lazy
|
||||
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
|
||||
const CreateActionPage = lazy(() => import("./pages/CreateActionPage").then(m => ({ default: m.CreateActionPage })));
|
||||
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
|
||||
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
|
||||
const AppearanceSettingsPage = lazy(() => import("./pages/AppearanceSettingsPage").then(m => ({ default: m.AppearanceSettingsPage })));
|
||||
@@ -40,21 +45,25 @@ const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ de
|
||||
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
|
||||
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
|
||||
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
|
||||
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
const DiscoverPage = lazy(() => import("./pages/DiscoverPage").then(m => ({ default: m.DiscoverPage })));
|
||||
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
|
||||
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
|
||||
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
|
||||
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
|
||||
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
|
||||
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
|
||||
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
|
||||
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
|
||||
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
|
||||
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
|
||||
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
|
||||
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
|
||||
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
|
||||
const MessagingSettingsPage = lazy(() => import("./pages/MessagingSettingsPage").then(m => ({ default: m.MessagingSettingsPage })));
|
||||
const MusicPage = lazy(() => import("./pages/MusicPage").then(m => ({ default: m.MusicPage })));
|
||||
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
|
||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||
@@ -76,11 +85,14 @@ const VerifiedPage = lazy(() => import("./pages/VerifiedPage").then(m => ({ defa
|
||||
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
|
||||
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
|
||||
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
|
||||
const WalletRecoveryPage = lazy(() => import("./pages/WalletRecoveryPage").then(m => ({ default: m.WalletRecoveryPage })));
|
||||
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
|
||||
const ReceivePage = lazy(() => import("./pages/ReceivePage").then(m => ({ default: m.ReceivePage })));
|
||||
const ClaimPage = lazy(() => import("./pages/ClaimPage").then(m => ({ default: m.ClaimPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
@@ -153,11 +165,17 @@ export function AppRouter() {
|
||||
<Routes>
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
<Route path="/receive" element={<ReceivePage />} />
|
||||
<Route path="/claim" element={<ClaimPage />} />
|
||||
|
||||
{/* All routes share the persistent MainLayout (sidebar + nav) */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
|
||||
<Route element={<FundraiserLayout />}>
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<Route path="/discover" element={<DiscoverPage />} />
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/campaigns" element={<Navigate to="/" replace />} />
|
||||
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
|
||||
<Route path="/campaigns/all" element={<AllCampaignsPage />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/messages" element={<MessagesPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
@@ -176,7 +194,6 @@ export function AppRouter() {
|
||||
path="/settings/notifications"
|
||||
element={<NotificationSettings />}
|
||||
/>
|
||||
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
|
||||
<Route
|
||||
path="/settings/advanced"
|
||||
element={<AdvancedSettingsPage />}
|
||||
@@ -185,6 +202,7 @@ export function AppRouter() {
|
||||
<Route path="/settings/network" element={<NetworkSettingsPage />} />
|
||||
<Route path="/lists" element={<UserListsPage />} />
|
||||
<Route path="/events" element={<EventsFeedPage />} />
|
||||
<Route path="/events/new" element={<CreateEventPage />} />
|
||||
<Route path="/photos" element={<PhotosFeedPage />} />
|
||||
<Route path="/videos" element={<VideosFeedPage />} />
|
||||
{/* /streams redirects to /videos for backward compatibility */}
|
||||
@@ -268,6 +286,8 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
|
||||
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
<Route path="/verified" element={<VerifiedPage />} />
|
||||
@@ -278,10 +298,13 @@ export function AppRouter() {
|
||||
<Route path="/bluesky" element={<BlueskyPage />} />
|
||||
<Route path="/wikipedia" element={<WikipediaPage />} />
|
||||
<Route path="/communities" element={<CommunitiesPage />} />
|
||||
<Route path="/communities/new" element={<CreateCommunityPage />} />
|
||||
<Route path="/letters" element={<LettersPage />} />
|
||||
<Route path="/letters/compose" element={<LetterComposePage />} />
|
||||
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
|
||||
<Route path="/help" element={<HelpPage />} />
|
||||
<Route path="/help/donors" element={<DonorGuidePage />} />
|
||||
<Route path="/help/activists" element={<ActivistGuidePage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/safety" element={<CSAEPolicyPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
@@ -292,6 +315,9 @@ export function AppRouter() {
|
||||
/>
|
||||
<Route path="/i/*" element={<ExternalContentPage />} />
|
||||
<Route path="/actions" element={<ActionsPage />} />
|
||||
<Route path="/actions/new" element={<CreateActionPage />} />
|
||||
<Route path="/pledges" element={<ActionsPage />} />
|
||||
<Route path="/pledges/new" element={<CreateActionPage />} />
|
||||
<Route path="/agent" element={<AIChatPage />} />
|
||||
<Route path="/organizers" element={<OrganizersPage />} />
|
||||
<Route path="/dashboard" element={<EventDashboardPage />} />
|
||||
|
||||
@@ -2,11 +2,14 @@ import { Link } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Bitcoin, Camera, Clock, Info, Megaphone, Palette } from 'lucide-react';
|
||||
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 { cn } from '@/lib/utils';
|
||||
|
||||
const ACTION_ICONS = {
|
||||
@@ -25,6 +28,7 @@ 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;
|
||||
|
||||
@@ -54,14 +58,17 @@ export function ActionContent({ event, compact = true }: { event: NostrEvent; co
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent" />
|
||||
{action.countryCode && (
|
||||
<span className="absolute left-3 top-3 text-2xl drop-shadow-md" title={getGeoDisplayName(action.countryCode)}>
|
||||
{countryCodeToFlag(action.countryCode)}
|
||||
</span>
|
||||
<CountryFlag
|
||||
code={action.countryCode}
|
||||
emoji={countryCodeToFlag(action.countryCode)}
|
||||
label={getGeoDisplayName(action.countryCode)}
|
||||
className="absolute left-3 top-3 text-2xl drop-shadow-md"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute bottom-3 left-3 right-3 flex items-center justify-between gap-2 text-white">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-black/45 px-2.5 py-1 text-xs font-semibold backdrop-blur-sm">
|
||||
<Icon className="size-3.5" />
|
||||
Action
|
||||
Pledge
|
||||
</span>
|
||||
{isExpired ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-black/45 px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
|
||||
@@ -89,9 +96,11 @@ export function ActionContent({ event, compact = true }: { event: NostrEvent; co
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Bitcoin className="size-4 shrink-0 text-primary" />
|
||||
<span className="font-semibold">{action.bounty.toLocaleString()}</span>
|
||||
<span className="text-xs text-muted-foreground">sats</span>
|
||||
<DollarSign className="size-4 shrink-0 text-primary" />
|
||||
<span className="font-semibold">
|
||||
{btcPrice ? satsToUSDWhole(action.bounty, btcPrice) : `${formatSats(action.bounty)} sats`}
|
||||
</span>
|
||||
{btcPrice && <span className="text-xs text-muted-foreground">~{formatSats(action.bounty)} sats</span>}
|
||||
{action.countryCode && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
|
||||
@@ -1,841 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { UserPlus, Loader2, X, Search, Crown, Users, PartyPopper } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { ImageUploadField } from '@/components/ImageUploadField';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
|
||||
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import { useSearchPeopleLists, type PeopleListSearchResult } from '@/hooks/useSearchPeopleLists';
|
||||
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
|
||||
import { parseAuthorEvent } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import {
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
BADGE_DEFINITION_KIND,
|
||||
BADGE_AWARD_KIND,
|
||||
EMPTY_MODERATION,
|
||||
type CommunityMember,
|
||||
type CommunityMembership,
|
||||
type CommunityModeration,
|
||||
type ParsedCommunity,
|
||||
} from '@/lib/communityUtils';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type MemberRole = 'moderator' | 'member';
|
||||
|
||||
interface PendingMember {
|
||||
profile: SearchProfile;
|
||||
role: MemberRole;
|
||||
}
|
||||
|
||||
interface BadgeRef {
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
interface CommunityMembersCacheValue {
|
||||
membership: CommunityMembership;
|
||||
moderation: CommunityModeration;
|
||||
rankMap: Map<string, CommunityMember>;
|
||||
}
|
||||
|
||||
interface AddMemberDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The raw community definition event. */
|
||||
communityEvent: NostrEvent;
|
||||
/** Parsed community data. */
|
||||
community: ParsedCommunity;
|
||||
/** Whether the current user is the founder (can add moderators). */
|
||||
isFounder: boolean;
|
||||
/** Existing active members and moderators, excluded from duplicate adds. */
|
||||
existingMemberPubkeys: string[];
|
||||
}
|
||||
|
||||
interface AddMemberPanelProps {
|
||||
/** The raw community definition event. */
|
||||
communityEvent: NostrEvent;
|
||||
/** Parsed community data. */
|
||||
community: ParsedCommunity;
|
||||
/** Whether the current user is the founder (can add moderators). */
|
||||
isFounder: boolean;
|
||||
/** Existing active members and moderators, excluded from duplicate adds. */
|
||||
existingMemberPubkeys: string[];
|
||||
/** Called after a successful publish so the host (dialog/page) can close or refresh. */
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
function parseBadgeATag(aTag: string | undefined): BadgeRef | undefined {
|
||||
if (!aTag) return undefined;
|
||||
const [kind, pubkey, ...identifierParts] = aTag.split(':');
|
||||
const identifier = identifierParts.join(':');
|
||||
if (kind !== String(BADGE_DEFINITION_KIND) || !pubkey || !identifier) return undefined;
|
||||
return { pubkey, identifier };
|
||||
}
|
||||
|
||||
function isHexPubkey(value: string): boolean {
|
||||
return /^[0-9a-f]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
function makeFallbackProfile(pubkey: string): SearchProfile {
|
||||
return {
|
||||
pubkey,
|
||||
metadata: {},
|
||||
event: {
|
||||
id: '',
|
||||
pubkey,
|
||||
created_at: 0,
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: '{}',
|
||||
sig: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function profileFromEvent(event: NostrEvent): SearchProfile {
|
||||
const parsed = parseAuthorEvent(event);
|
||||
return { pubkey: event.pubkey, metadata: parsed.metadata ?? {}, event };
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AddMemberDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
communityEvent,
|
||||
community,
|
||||
isFounder,
|
||||
existingMemberPubkeys,
|
||||
}: AddMemberDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
|
||||
|
||||
const dialogContentRef = useCallback((node: HTMLElement | null) => {
|
||||
setPortalContainer(node ?? undefined);
|
||||
}, []);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
|
||||
<PortalContainerProvider value={portalContainer}>
|
||||
<DialogHeader className="px-5 pt-5 pb-3">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="size-5 text-primary" />
|
||||
Add Members
|
||||
</DialogTitle>
|
||||
<DialogDescription>Add to community</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="px-5 pb-5">
|
||||
<AddMemberPanel
|
||||
communityEvent={communityEvent}
|
||||
community={community}
|
||||
isFounder={isFounder}
|
||||
existingMemberPubkeys={existingMemberPubkeys}
|
||||
onComplete={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PortalContainerProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline form that searches for people and adds them as community members or
|
||||
* moderators. Pulled out of `AddMemberDialog` so the same flow can be
|
||||
* embedded inside other surfaces — e.g. the members dialog on
|
||||
* `CommunityDetailPage` — without nesting a second `Dialog`.
|
||||
*/
|
||||
export function AddMemberPanel({
|
||||
communityEvent,
|
||||
community,
|
||||
isFounder,
|
||||
existingMemberPubkeys,
|
||||
onComplete,
|
||||
}: AddMemberPanelProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Form state
|
||||
const [pendingMembers, setPendingMembers] = useState<PendingMember[]>([]);
|
||||
const [badgeImageUrl, setBadgeImageUrl] = useState('');
|
||||
const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
|
||||
// Mutations
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
// Does this community already have a member badge definition?
|
||||
const existingBadgeATag = community.memberBadgeATag;
|
||||
const hasBadge = !!existingBadgeATag;
|
||||
const existingBadgeRef = useMemo(() => parseBadgeATag(existingBadgeATag), [existingBadgeATag]);
|
||||
const existingBadgeRefs = useMemo(() => existingBadgeRef ? [existingBadgeRef] : [], [existingBadgeRef]);
|
||||
const { badgeMap, isLoading: isBadgeLoading, isError: isBadgeError } = useBadgeDefinitions(existingBadgeRefs);
|
||||
const existingBadge = existingBadgeATag ? badgeMap.get(existingBadgeATag) : undefined;
|
||||
|
||||
// Are there any pending members with the "member" role?
|
||||
const hasPendingMembers = pendingMembers.some((m) => m.role === 'member');
|
||||
// Will we need to create a badge? (members added + no badge exists yet)
|
||||
const needsBadgeCreation = hasPendingMembers && !hasBadge;
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setPendingMembers([]);
|
||||
setBadgeImageUrl('');
|
||||
setIsBadgeImageUploading(false);
|
||||
setIsPublishing(false);
|
||||
}, []);
|
||||
|
||||
// ── People management ─────────────────────────────────────────────────────
|
||||
|
||||
const addPerson = useCallback((profile: SearchProfile) => {
|
||||
if (!user) return;
|
||||
if (profile.pubkey === community.founderPubkey) {
|
||||
toast({ title: 'Already the founder' });
|
||||
return;
|
||||
}
|
||||
if (existingMemberPubkeys.includes(profile.pubkey)) {
|
||||
toast({ title: 'Already in the community' });
|
||||
return;
|
||||
}
|
||||
if (pendingMembers.some((m) => m.profile.pubkey === profile.pubkey)) {
|
||||
toast({ title: 'Already added' });
|
||||
return;
|
||||
}
|
||||
setPendingMembers((prev) => [...prev, { profile, role: 'member' }]);
|
||||
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, toast]);
|
||||
|
||||
const addPeople = useCallback((profiles: SearchProfile[], sourceTitle?: string) => {
|
||||
if (!user) return;
|
||||
|
||||
const excluded = new Set([
|
||||
community.founderPubkey,
|
||||
...existingMemberPubkeys,
|
||||
...pendingMembers.map((m) => m.profile.pubkey),
|
||||
]);
|
||||
const nextProfiles: SearchProfile[] = [];
|
||||
|
||||
for (const profile of profiles) {
|
||||
if (excluded.has(profile.pubkey)) continue;
|
||||
excluded.add(profile.pubkey);
|
||||
nextProfiles.push(profile);
|
||||
}
|
||||
|
||||
if (nextProfiles.length === 0) {
|
||||
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingMembers((prev) => [...prev, ...nextProfiles.map((profile) => ({ profile, role: 'member' as const }))]);
|
||||
if (sourceTitle) {
|
||||
toast({ title: `Added ${nextProfiles.length} from ${sourceTitle}` });
|
||||
}
|
||||
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, toast]);
|
||||
|
||||
const removePerson = useCallback((pubkey: string) => {
|
||||
setPendingMembers((prev) => prev.filter((m) => m.profile.pubkey !== pubkey));
|
||||
}, []);
|
||||
|
||||
const setRole = useCallback((pubkey: string, role: MemberRole) => {
|
||||
if (!isFounder) return; // Only founder can appoint moderators
|
||||
setPendingMembers((prev) => prev.map((m) =>
|
||||
m.profile.pubkey === pubkey
|
||||
? { ...m, role }
|
||||
: m,
|
||||
));
|
||||
}, [isFounder]);
|
||||
|
||||
const applyOptimisticMembership = useCallback((members: PendingMember[], awardEvents: Map<string, NostrEvent>) => {
|
||||
queryClient.setQueryData<CommunityMembersCacheValue>(['community-members', community.aTag], (prev) => {
|
||||
const moderation = prev?.moderation ?? EMPTY_MODERATION;
|
||||
const rankMap = new Map(prev?.rankMap ?? []);
|
||||
const membershipByPubkey = new Map(
|
||||
(prev?.membership.members ?? []).map((member) => [member.pubkey, member] as const),
|
||||
);
|
||||
|
||||
const seedRankZero = (pubkey: string) => {
|
||||
if (moderation.bannedPubkeys.has(pubkey)) return;
|
||||
const member: CommunityMember = { pubkey, rank: 0 };
|
||||
if (!membershipByPubkey.has(pubkey)) membershipByPubkey.set(pubkey, member);
|
||||
if (!rankMap.has(pubkey)) rankMap.set(pubkey, member);
|
||||
};
|
||||
|
||||
seedRankZero(community.founderPubkey);
|
||||
community.moderatorPubkeys.forEach(seedRankZero);
|
||||
|
||||
for (const pending of members) {
|
||||
if (moderation.bannedPubkeys.has(pending.profile.pubkey)) continue;
|
||||
|
||||
const nextMember: CommunityMember = pending.role === 'moderator'
|
||||
? { pubkey: pending.profile.pubkey, rank: 0 }
|
||||
: {
|
||||
pubkey: pending.profile.pubkey,
|
||||
rank: 1,
|
||||
awardEvent: awardEvents.get(pending.profile.pubkey),
|
||||
awardedBy: user?.pubkey,
|
||||
};
|
||||
|
||||
const current = membershipByPubkey.get(nextMember.pubkey);
|
||||
if (!current || nextMember.rank < current.rank) {
|
||||
membershipByPubkey.set(nextMember.pubkey, nextMember);
|
||||
}
|
||||
|
||||
const currentRank = rankMap.get(nextMember.pubkey);
|
||||
if (!currentRank || nextMember.rank < currentRank.rank) {
|
||||
rankMap.set(nextMember.pubkey, nextMember);
|
||||
}
|
||||
}
|
||||
|
||||
const membership: CommunityMembership = {
|
||||
members: Array.from(membershipByPubkey.values()).sort((a, b) => a.rank - b.rank),
|
||||
};
|
||||
|
||||
return { membership, moderation, rankMap };
|
||||
});
|
||||
}, [community.aTag, community.founderPubkey, community.moderatorPubkeys, queryClient, user?.pubkey]);
|
||||
|
||||
// ── Publish ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!user || pendingMembers.length === 0) return;
|
||||
if (isBadgeImageUploading) {
|
||||
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
|
||||
return;
|
||||
}
|
||||
if (badgeImageUrl.trim() && !sanitizeUrl(badgeImageUrl.trim())) {
|
||||
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (needsBadgeCreation && !isFounder) {
|
||||
toast({ title: 'Member badge is missing', description: 'Only the founder can initialize community membership.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const newModerators = pendingMembers.filter((m) => m.role === 'moderator');
|
||||
const newMembers = pendingMembers.filter((m) => m.role === 'member');
|
||||
|
||||
let badgeATag = existingBadgeATag;
|
||||
|
||||
// Step 1: Create badge definition if needed
|
||||
if (newMembers.length > 0 && !hasBadge) {
|
||||
const badgeDTag = `${community.dTag}-member`;
|
||||
const existingBadge = await nostr.query([{
|
||||
kinds: [BADGE_DEFINITION_KIND],
|
||||
authors: [user.pubkey],
|
||||
'#d': [badgeDTag],
|
||||
limit: 1,
|
||||
}]);
|
||||
|
||||
if (existingBadge.length > 0) {
|
||||
toast({
|
||||
title: 'Member badge ID already in use',
|
||||
description: 'This community needs a member badge, but that badge identifier already exists on your account.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsPublishing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeTags: string[][] = [
|
||||
['d', badgeDTag],
|
||||
['name', 'Member'],
|
||||
['description', `Member of ${community.name}`],
|
||||
];
|
||||
const sanitizedBadgeImage = sanitizeUrl(badgeImageUrl.trim());
|
||||
if (sanitizedBadgeImage) {
|
||||
badgeTags.push(['image', sanitizedBadgeImage, '1024x1024']);
|
||||
}
|
||||
badgeTags.push(['alt', `Badge definition: Member of ${community.name}`]);
|
||||
|
||||
const badgeEvent = await publishEvent({
|
||||
kind: BADGE_DEFINITION_KIND,
|
||||
content: '',
|
||||
tags: badgeTags,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
|
||||
badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`;
|
||||
}
|
||||
|
||||
// Step 2: Republish community definition if needed
|
||||
// Needed when: adding moderators (new p tags) OR badge was just created (new a tag)
|
||||
const needsCommunityUpdate = newModerators.length > 0 || (newMembers.length > 0 && !hasBadge);
|
||||
|
||||
if (needsCommunityUpdate) {
|
||||
// Fetch fresh community event to avoid stale overwrites
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [COMMUNITY_DEFINITION_KIND],
|
||||
authors: [communityEvent.pubkey],
|
||||
'#d': [community.dTag],
|
||||
});
|
||||
|
||||
const baseTags = prev?.tags ?? communityEvent.tags;
|
||||
const updatedTags = [...baseTags];
|
||||
|
||||
// Add new moderator p tags
|
||||
for (const mod of newModerators) {
|
||||
// Don't add if already exists
|
||||
const exists = updatedTags.some(
|
||||
([n, v, , role]) => n === 'p' && v === mod.profile.pubkey && role === 'moderator',
|
||||
);
|
||||
if (!exists) {
|
||||
updatedTags.push(['p', mod.profile.pubkey, '', 'moderator']);
|
||||
}
|
||||
}
|
||||
|
||||
// Add badge a tag if badge was just created
|
||||
if (badgeATag && !hasBadge) {
|
||||
updatedTags.push(['a', badgeATag, '', 'member']);
|
||||
}
|
||||
|
||||
const updatedEvent = await publishEvent({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
content: prev?.content ?? '',
|
||||
tags: updatedTags,
|
||||
prev: prev ?? undefined,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
|
||||
|
||||
queryClient.setQueryData(
|
||||
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
|
||||
updatedEvent,
|
||||
);
|
||||
queryClient.setQueryData(['event', updatedEvent.id], updatedEvent);
|
||||
}
|
||||
|
||||
// Step 3: Publish badge awards for each member
|
||||
const memberAwardEvents = new Map<string, NostrEvent>();
|
||||
if (newMembers.length > 0 && badgeATag) {
|
||||
for (const member of newMembers) {
|
||||
const awardEvent = await publishEvent({
|
||||
kind: BADGE_AWARD_KIND,
|
||||
content: '',
|
||||
tags: [
|
||||
['a', badgeATag],
|
||||
['p', member.profile.pubkey],
|
||||
['alt', `Badge award: Member in ${community.name}`],
|
||||
],
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
memberAwardEvents.set(member.profile.pubkey, awardEvent);
|
||||
}
|
||||
}
|
||||
|
||||
applyOptimisticMembership(pendingMembers, memberAwardEvents);
|
||||
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag], refetchType: 'inactive' });
|
||||
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
|
||||
if (!hasBadge && newMembers.length > 0) {
|
||||
queryClient.invalidateQueries({ queryKey: ['badge-feed'] });
|
||||
}
|
||||
|
||||
const addedCount = pendingMembers.length;
|
||||
toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` });
|
||||
resetForm();
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to add members',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
}, [
|
||||
user, pendingMembers, existingBadgeATag, hasBadge, needsBadgeCreation, isFounder, community, communityEvent,
|
||||
badgeImageUrl, nostr, publishEvent, queryClient, toast, resetForm, onComplete, applyOptimisticMembership, isBadgeImageUploading,
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* People search */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Search</Label>
|
||||
<PersonSearch
|
||||
onAdd={addPerson}
|
||||
onAddMany={addPeople}
|
||||
excludePubkeys={[
|
||||
community.founderPubkey,
|
||||
...existingMemberPubkeys,
|
||||
...pendingMembers.map((m) => m.profile.pubkey),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pending members list */}
|
||||
{pendingMembers.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
People to add
|
||||
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
{pendingMembers.map((pm) => (
|
||||
<PendingMemberChip
|
||||
key={pm.profile.pubkey}
|
||||
pending={pm}
|
||||
onRemove={removePerson}
|
||||
onRoleChange={isFounder ? setRole : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPendingMembers && (
|
||||
<div className="space-y-2">
|
||||
<Label>Member badge</Label>
|
||||
{hasBadge ? (
|
||||
<div className="rounded-xl border border-border/60 bg-secondary/30 p-3">
|
||||
{isBadgeError ? (
|
||||
<p className="text-sm text-destructive">Failed to load the current member badge.</p>
|
||||
) : isBadgeLoading ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-12 animate-pulse rounded-lg bg-muted" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-44 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
) : existingBadge ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<BadgeThumbnail badge={existingBadge} size={48} className="shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{existingBadge.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{existingBadge.description || 'Selected members will receive this badge.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-destructive">The configured member badge could not be found.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ImageUploadField
|
||||
id="member-badge-image"
|
||||
label={<>Create Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
|
||||
value={badgeImageUrl}
|
||||
onChange={setBadgeImageUrl}
|
||||
onUploadingChange={setIsBadgeImageUploading}
|
||||
uploadToastTitle="Badge image uploaded"
|
||||
previewAlt="Badge preview"
|
||||
objectFit="contain"
|
||||
dropAreaClassName="min-h-24"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="size-4 animate-spin" /> Adding...</>
|
||||
) : (
|
||||
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-Components ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Inline type-ahead person search. */
|
||||
function PersonSearch({
|
||||
onAdd,
|
||||
onAddMany,
|
||||
excludePubkeys,
|
||||
}: {
|
||||
onAdd: (profile: SearchProfile) => void;
|
||||
onAddMany: (profiles: SearchProfile[], sourceTitle?: string) => void;
|
||||
excludePubkeys: string[];
|
||||
}) {
|
||||
const { nostr } = useNostr();
|
||||
const { toast } = useToast();
|
||||
const [query, setQuery] = useState('');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [isAddingPack, setIsAddingPack] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: profiles, isFetching } = useSearchProfiles(query);
|
||||
const { data: peopleLists, isFetching: isFetchingPeopleLists } = useSearchPeopleLists(query);
|
||||
|
||||
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
|
||||
const filteredProfiles = useMemo(
|
||||
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
|
||||
[profiles, excludeSet],
|
||||
);
|
||||
const filteredPeopleLists = useMemo(
|
||||
() => (peopleLists ?? []).filter((pack) => pack.pubkeys.some((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey.toLowerCase()))),
|
||||
[peopleLists, excludeSet],
|
||||
);
|
||||
const hasResults = filteredProfiles.length > 0 || filteredPeopleLists.length > 0;
|
||||
const isSearching = isFetching || isFetchingPeopleLists || isAddingPack;
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim().length > 0 && hasResults) {
|
||||
setDropdownOpen(true);
|
||||
} else if (query.trim().length === 0) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}, [hasResults, query]);
|
||||
|
||||
const handleSelect = useCallback((profile: SearchProfile) => {
|
||||
onAdd(profile);
|
||||
setQuery('');
|
||||
setDropdownOpen(false);
|
||||
inputRef.current?.focus();
|
||||
}, [onAdd]);
|
||||
|
||||
const handleSelectPeopleList = useCallback(async (pack: PeopleListSearchResult) => {
|
||||
const eligiblePubkeys = Array.from(new Set(
|
||||
pack.pubkeys
|
||||
.map((pubkey) => pubkey.toLowerCase())
|
||||
.filter((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey)),
|
||||
));
|
||||
|
||||
if (eligiblePubkeys.length === 0) {
|
||||
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (eligiblePubkeys.length > 20 && !window.confirm(`Add ${eligiblePubkeys.length} people from ${pack.title}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingPack(true);
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [0], authors: eligiblePubkeys, limit: eligiblePubkeys.length }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
|
||||
const latestByPubkey = new Map<string, NostrEvent>();
|
||||
for (const event of events) {
|
||||
const existing = latestByPubkey.get(event.pubkey);
|
||||
if (!existing || event.created_at > existing.created_at) latestByPubkey.set(event.pubkey, event);
|
||||
}
|
||||
|
||||
const profilesToAdd = eligiblePubkeys.map((pubkey) => {
|
||||
const event = latestByPubkey.get(pubkey);
|
||||
return event ? profileFromEvent(event) : makeFallbackProfile(pubkey);
|
||||
});
|
||||
|
||||
onAddMany(profilesToAdd, pack.title);
|
||||
setQuery('');
|
||||
setDropdownOpen(false);
|
||||
inputRef.current?.focus();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to load follow pack members',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsAddingPack(false);
|
||||
}
|
||||
}, [excludeSet, nostr, onAddMany, toast]);
|
||||
|
||||
return (
|
||||
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
|
||||
{isSearching && query.trim() && (
|
||||
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (query.trim().length > 0 && hasResults) {
|
||||
setDropdownOpen(true);
|
||||
}
|
||||
}}
|
||||
placeholder="Search people..."
|
||||
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
|
||||
>
|
||||
{hasResults ? (
|
||||
<div className="max-h-[200px] overflow-y-auto py-1">
|
||||
{filteredProfiles.map((profile) => (
|
||||
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
|
||||
))}
|
||||
{filteredPeopleLists.map((pack) => (
|
||||
<PeopleListSearchResultItem key={`${pack.event.kind}:${pack.event.pubkey}:${pack.event.tags.find(([name]) => name === 'd')?.[1] ?? pack.event.id}`} pack={pack} onClick={handleSelectPeopleList} />
|
||||
))}
|
||||
</div>
|
||||
) : query.trim().length >= 2 && !isSearching ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
No people or follow packs found
|
||||
</div>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/** A follow pack / follow set search result row. */
|
||||
function PeopleListSearchResultItem({ pack, onClick }: { pack: PeopleListSearchResult; onClick: (pack: PeopleListSearchResult) => void }) {
|
||||
return (
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
|
||||
onClick={() => onClick(pack)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="size-8 shrink-0 rounded-full bg-primary/10 text-primary flex items-center justify-center">
|
||||
<PartyPopper className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{pack.title}</span>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
Follow pack · {pack.pubkeys.length} people
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A pending member chip with role toggle and remove button. */
|
||||
function PendingMemberChip({
|
||||
pending,
|
||||
onRemove,
|
||||
onRoleChange,
|
||||
}: {
|
||||
pending: PendingMember;
|
||||
onRemove: (pubkey: string) => void;
|
||||
onRoleChange?: (pubkey: string, role: MemberRole) => void;
|
||||
}) {
|
||||
const { profile, role } = pending;
|
||||
const { metadata, pubkey } = profile;
|
||||
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-secondary/30 border border-border/50">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src={metadata.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 text-sm truncate">
|
||||
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
|
||||
</span>
|
||||
|
||||
{onRoleChange ? (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={role}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'member' || value === 'moderator') onRoleChange(pubkey, value);
|
||||
}}
|
||||
className="shrink-0 rounded-full bg-muted p-0.5"
|
||||
aria-label={`Role for ${displayName}`}
|
||||
>
|
||||
<ToggleGroupItem value="member" size="sm" className="h-7 rounded-full px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground" aria-label="Member">
|
||||
<Users className="size-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Member</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="moderator" size="sm" className="h-7 rounded-full px-2 text-xs data-[state=on]:bg-amber-500 data-[state=on]:text-white" aria-label="Moderator">
|
||||
<Crown className="size-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Moderator</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
) : (
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Users className="size-3" />
|
||||
Member
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(pubkey)}
|
||||
className="shrink-0 size-6 rounded-full hover:bg-destructive/10 flex items-center justify-center transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="size-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A profile search result row. */
|
||||
function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
|
||||
const { metadata, pubkey } = profile;
|
||||
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
|
||||
onClick={() => onClick(profile)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Avatar 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="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">
|
||||
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
|
||||
</span>
|
||||
{metadata.nip05 && (
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
|
||||
|
||||
/** Hardcoded default values for Agent provider fields. Used for reset buttons. */
|
||||
const DEFAULT_AI_BASE_URL = 'https://ai.shakespeare.diy/v1';
|
||||
const DEFAULT_AI_MODEL = 'grok-4.1-fast';
|
||||
const DEFAULT_AI_MODEL = 'google/gemma-4-26b';
|
||||
|
||||
/** The build-time default DSN from the environment variable. */
|
||||
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
|
||||
@@ -195,7 +195,7 @@ export function AdvancedSettings() {
|
||||
Model
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Model ID sent to the provider (e.g. <code className="bg-muted px-1 rounded">grok-4.1-fast</code>, <code className="bg-muted px-1 rounded">claude-opus-4.6</code>, <code className="bg-muted px-1 rounded">gpt-4o</code>).
|
||||
Model ID sent to the provider (e.g. <code className="bg-muted px-1 rounded">google/gemma-4-26b</code>, <code className="bg-muted px-1 rounded">claude-opus-4.6</code>, <code className="bg-muted px-1 rounded">gpt-4o</code>).
|
||||
</p>
|
||||
<Input
|
||||
id="ai-model"
|
||||
@@ -521,7 +521,7 @@ export function AdvancedSettings() {
|
||||
<h3 className="text-sm font-medium">Delete Account</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Permanently delete your data from the network, including your profile,
|
||||
posts, reactions, and direct messages. This action is irreversible.
|
||||
posts, and reactions. This action is irreversible.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -33,6 +33,8 @@ interface ArcBackgroundProps {
|
||||
variant: 'down' | 'up' | 'rect';
|
||||
/** Extra classes on the <svg> element. */
|
||||
className?: string;
|
||||
/** Extra classes on the filled background path. */
|
||||
fillClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +42,7 @@ interface ArcBackgroundProps {
|
||||
* MobileBottomNav. Draws a semi-transparent filled shape (rectangle + optional
|
||||
* curved arc) as a single path so there are no sub-pixel seams between layers.
|
||||
*/
|
||||
export function ArcBackground({ variant, className }: ArcBackgroundProps) {
|
||||
export function ArcBackground({ variant, className, fillClassName }: ArcBackgroundProps) {
|
||||
const path = variant === 'down' ? ARC_DOWN_PATH : variant === 'up' ? ARC_UP_PATH : RECT_PATH;
|
||||
const hasArc = variant !== 'rect';
|
||||
|
||||
@@ -57,7 +59,7 @@ export function ArcBackground({ variant, className }: ArcBackgroundProps) {
|
||||
preserveAspectRatio="none"
|
||||
style={hasArc ? (variant === 'up' ? arcUpHeightStyle : arcDownHeightStyle) : fullHeightStyle}
|
||||
>
|
||||
<path d={path} className="fill-background/85" />
|
||||
<path d={path} className={cn('fill-background/85', fillClassName)} />
|
||||
{variant === 'down' && <path d="M0,34 L50,46 L100,34" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
|
||||
{variant === 'up' && <path d="M0,40 L50,16 L100,40" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
|
||||
</svg>
|
||||
|
||||
@@ -18,42 +18,27 @@ import {
|
||||
} from '@/lib/communityUtils';
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Only content-level bans remain. Agora's organization trust model has no
|
||||
// "member" tier any more, so banning a user wholesale is no longer
|
||||
// modeled — hide each unwanted post individually instead.
|
||||
|
||||
interface BanContentProps {
|
||||
/** Ban a specific post. */
|
||||
mode: 'content';
|
||||
interface BanConfirmDialogProps {
|
||||
/** The event ID to ban. */
|
||||
eventId: string;
|
||||
/** The event author's pubkey. */
|
||||
targetPubkey: string;
|
||||
/** Display name for the dialog description. */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface BanMemberProps {
|
||||
/** Ban a member. */
|
||||
mode: 'member';
|
||||
eventId?: never;
|
||||
/** The pubkey of the member to ban. */
|
||||
targetPubkey: string;
|
||||
/** Display name for the dialog description. */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
type BanMode = BanContentProps | BanMemberProps;
|
||||
|
||||
type BanConfirmDialogProps = BanMode & {
|
||||
/** The community `A` tag coordinate. */
|
||||
communityATag: string;
|
||||
/** Display name for the dialog description. */
|
||||
displayName?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function BanConfirmDialog({
|
||||
mode,
|
||||
eventId,
|
||||
targetPubkey,
|
||||
displayName,
|
||||
communityATag,
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -62,23 +47,15 @@ export function BanConfirmDialog({
|
||||
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const title = mode === 'content' ? 'Remove from community' : `Ban ${displayName ? `@${displayName}` : 'member'} from community`;
|
||||
const description = mode === 'content'
|
||||
? 'This will hide the post from canonical community views.'
|
||||
: `This will ban ${displayName ? `@${displayName}` : 'this member'} from the community. Their recruits remain unaffected.`;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const tags: string[][] = [];
|
||||
|
||||
if (mode === 'content' && eventId) {
|
||||
tags.push(['e', eventId, 'other']);
|
||||
}
|
||||
|
||||
tags.push(['p', targetPubkey, 'other']);
|
||||
tags.push(['A', communityATag]);
|
||||
tags.push(['L', MODERATION_LABEL_NAMESPACE]);
|
||||
tags.push(['l', MODERATION_BAN_LABEL, MODERATION_LABEL_NAMESPACE]);
|
||||
const tags: string[][] = [
|
||||
['e', eventId, 'other'],
|
||||
['p', targetPubkey, 'other'],
|
||||
['A', communityATag],
|
||||
['L', MODERATION_LABEL_NAMESPACE],
|
||||
['l', MODERATION_BAN_LABEL, MODERATION_LABEL_NAMESPACE],
|
||||
];
|
||||
|
||||
await publishEvent({
|
||||
kind: REPORT_KIND,
|
||||
@@ -87,36 +64,23 @@ export function BanConfirmDialog({
|
||||
});
|
||||
|
||||
// Invalidate community queries so the moderation overlay updates
|
||||
// immediately (removes banned content/members without a page refresh).
|
||||
// The activity feed's key is `['community-activity-feed', <aTagsKey>]`
|
||||
// where aTagsKey is a comma-joined list of the viewer's subscribed A
|
||||
// tags. We match any feed whose aTagsKey contains this communityATag.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] }),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const [root, aTagsKey] = q.queryKey;
|
||||
return root === 'community-activity-feed'
|
||||
&& typeof aTagsKey === 'string'
|
||||
&& aTagsKey.split(',').includes(communityATag);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
// immediately (removes banned content without a page refresh).
|
||||
await queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] });
|
||||
|
||||
toast({ title: mode === 'content' ? 'Post removed from community' : 'Member banned from community' });
|
||||
toast({ title: 'Post removed from organization' });
|
||||
setReason('');
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast({ title: mode === 'content' ? 'Failed to remove post from community' : 'Failed to ban member from community', variant: 'destructive' });
|
||||
toast({ title: 'Failed to remove post from organization', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md rounded-2xl flex flex-col overflow-hidden">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogTitle>Remove from organization</DialogTitle>
|
||||
<DialogDescription className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
This will hide the post from canonical organization views.
|
||||
</DialogDescription>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -146,7 +110,7 @@ export function BanConfirmDialog({
|
||||
disabled={isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isPending ? 'Submitting...' : (mode === 'content' ? 'Remove' : 'Ban')}
|
||||
{isPending ? 'Submitting...' : 'Remove'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertTriangle, Check, Copy, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
interface BeneficiaryDonatePanelProps {
|
||||
/** Hex pubkey of the beneficiary. */
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline panel rendering a beneficiary's Taproot address as a scannable
|
||||
* BIP-21 QR code, a copyable string, and an "Open in wallet" button.
|
||||
*
|
||||
* Used both by `BeneficiaryDonateDialog` (modal context) and embedded
|
||||
* directly into the campaign page when there's a single beneficiary.
|
||||
*
|
||||
* Always shows the beneficiary's profile preview (avatar + name) as a
|
||||
* link to their Nostr profile — even when the surrounding page also
|
||||
* identifies a campaign organizer, the beneficiary is a distinct party
|
||||
* (the organizer may be running the campaign on someone else's behalf).
|
||||
*
|
||||
* Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
|
||||
* that's `DonateDialog`'s job.
|
||||
*/
|
||||
export function BeneficiaryDonatePanel({
|
||||
pubkey,
|
||||
}: BeneficiaryDonatePanelProps) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName =
|
||||
metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const picture = sanitizeUrl(metadata?.picture);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
const address = useMemo(
|
||||
() => nostrPubkeyToBitcoinAddress(pubkey),
|
||||
[pubkey],
|
||||
);
|
||||
// BIP-21 URI: most wallets recognize the `bitcoin:` scheme when scanning.
|
||||
// No amount field — donor picks one in their wallet.
|
||||
const bip21 = address ? `bitcoin:${address}` : '';
|
||||
|
||||
const copyAddress = async () => {
|
||||
if (!address) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
toast({ title: 'Address copied' });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: 'Select and copy the address manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!address) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
|
||||
<span>We couldn't derive a Bitcoin address for this beneficiary.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="flex items-center gap-3 rounded-md -mx-2 px-2 py-1.5 motion-safe:transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Avatar className="size-10 ring-1 ring-border">
|
||||
{picture && <AvatarImage src={picture} alt="" />}
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{displayName}</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* QR code */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={bip21} size={200} level="M" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyable address */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Bitcoin address
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAddress}
|
||||
className="w-full flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs break-all text-left hover:bg-muted/60 motion-safe:transition-colors"
|
||||
aria-label="Copy Bitcoin address"
|
||||
>
|
||||
<span className="break-all">{address}</span>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Privacy notice — informational only. Bitcoin is a public
|
||||
ledger, so the donation can be traced back to the donor's
|
||||
wallet. */}
|
||||
<BitcoinPublicDisclaimer
|
||||
tone="soft"
|
||||
includeCashOutAdvice={false}
|
||||
leadText="Donations are public and can be traced back to you."
|
||||
/>
|
||||
|
||||
{/* Open in wallet — relies on the `bitcoin:` URI handler. */}
|
||||
<Button asChild className="w-full">
|
||||
<a href={bip21}>
|
||||
<ExternalLink className="size-4 mr-1.5" />
|
||||
Open in wallet
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BeneficiaryDonateDialogProps {
|
||||
/** Hex pubkey of the beneficiary. */
|
||||
pubkey: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal wrapper around `BeneficiaryDonatePanel` for places that still want
|
||||
* the dialog UX (e.g. multi-beneficiary campaigns, where each row's
|
||||
* "Donate" button opens this dialog).
|
||||
*/
|
||||
export function BeneficiaryDonateDialog({
|
||||
pubkey,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BeneficiaryDonateDialogProps) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName =
|
||||
metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Donate to {displayName}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Scan the QR code or copy the Bitcoin address below to donate.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<BeneficiaryDonatePanel pubkey={pubkey} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = 'Money you send is public and can be traced back to you.',
|
||||
tone = 'destructive',
|
||||
includeCashOutAdvice = true,
|
||||
}: BitcoinPublicDisclaimerProps) {
|
||||
const showCheckbox = onAcknowledgedChange !== undefined;
|
||||
const isSoft = tone === 'soft';
|
||||
|
||||
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
|
||||
? 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100'
|
||||
: '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>
|
||||
{leadText}{' '}
|
||||
<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"
|
||||
>
|
||||
Learn more
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" className="w-72 text-xs leading-relaxed">
|
||||
Bitcoin is a public ledger. Transactions you send can
|
||||
be traced back to you forever, even after being
|
||||
exchanged by multiple people. Send it only to those
|
||||
you wish to support publicly
|
||||
{includeCashOutAdvice ? ', or cash out at an exchange.' : '.'}
|
||||
</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="I understand this transaction is public"
|
||||
/>
|
||||
<span>I understand this transaction is public.</span>
|
||||
</label>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,23 @@
|
||||
import { useMemo, useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BookOpen, MessageCircle, MessageSquare, MoreHorizontal, Star, Zap, AlertTriangle } from 'lucide-react';
|
||||
import { BookOpen, MessageSquare, Star, AlertTriangle } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { ReactionButton } from '@/components/ReactionButton';
|
||||
import { RepostMenu } from '@/components/RepostMenu';
|
||||
import { PostActionBar } from '@/components/PostActionBar';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
import { useUserZap } from '@/hooks/useUserZap';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useOpenPost } from '@/hooks/useOpenPost';
|
||||
import { useBookSummary } from '@/hooks/useBookSummary';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BOOKSTR_KINDS, extractISBNFromEvent, parseBookReview, ratingToStars } from '@/lib/bookstr';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
@@ -49,18 +42,13 @@ function encodeEventId(event: NostrEvent): string {
|
||||
}
|
||||
|
||||
export function BookFeedItem({ event, className }: BookFeedItemProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const { data: stats } = useEventStats(event.id, event);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
|
||||
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
|
||||
const isZapped = useUserZap(canZapAuthor ? event.id : undefined) === true;
|
||||
|
||||
const isbn = useMemo(() => extractISBNFromEvent(event), [event]);
|
||||
const isReview = event.kind === BOOKSTR_KINDS.BOOK_REVIEW;
|
||||
const isComment = event.kind === 1111;
|
||||
@@ -220,60 +208,12 @@ export function BookFeedItem({ event, className }: BookFeedItemProps) {
|
||||
{isbn && <InlineBookCard isbn={isbn} />}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-5 mt-3 -ml-2">
|
||||
<button
|
||||
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Reply"
|
||||
onClick={(e) => { e.stopPropagation(); setReplyOpen(true); }}
|
||||
>
|
||||
<MessageCircle className="size-5" />
|
||||
{stats?.replies ? <span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span> : null}
|
||||
</button>
|
||||
|
||||
<RepostMenu event={event}>
|
||||
{(isReposted: boolean) => (
|
||||
<button
|
||||
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? 'text-accent hover:text-accent/80 hover:bg-accent/10' : 'text-muted-foreground hover:text-accent hover:bg-accent/10'}`}
|
||||
title={isReposted ? 'Undo repost' : 'Repost'}
|
||||
>
|
||||
<RepostIcon className="size-5" />
|
||||
{(stats?.reposts || stats?.quotes) ? <span className="text-sm tabular-nums">{formatNumber((stats?.reposts ?? 0) + (stats?.quotes ?? 0))}</span> : null}
|
||||
</button>
|
||||
)}
|
||||
</RepostMenu>
|
||||
|
||||
<ReactionButton
|
||||
eventId={event.id}
|
||||
eventPubkey={event.pubkey}
|
||||
eventKind={event.kind}
|
||||
reactionCount={stats?.reactions}
|
||||
/>
|
||||
|
||||
{canZapAuthor && (
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 p-2 rounded-full transition-colors',
|
||||
isZapped
|
||||
? 'text-amber-500 hover:text-amber-500/80 hover:bg-amber-500/10'
|
||||
: 'text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10',
|
||||
)}
|
||||
title={isZapped ? 'Zapped' : 'Zap'}
|
||||
>
|
||||
<Zap className="size-5" fill={isZapped ? 'currentColor' : 'none'} />
|
||||
{stats?.zapAmount ? <span className="text-sm tabular-nums">{formatNumber(stats.zapAmount)}</span> : null}
|
||||
</button>
|
||||
</ZapDialog>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="More"
|
||||
onClick={(e) => { e.stopPropagation(); setMoreMenuOpen(true); }}
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<PostActionBar
|
||||
event={event}
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
className="mt-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CalendarClock, HandHeart, MapPin, Target, Users, Archive } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CampaignModerationMenu } from '@/components/CampaignModerationMenu';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import {
|
||||
type ParsedCampaign,
|
||||
encodeCampaignNaddr,
|
||||
getCampaignCountryLabel,
|
||||
getCampaignPrimaryTagLabel,
|
||||
} from '@/lib/campaign';
|
||||
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = unixSeconds - now;
|
||||
if (diff <= 0) return { label: 'Ended', isPast: true };
|
||||
const days = Math.ceil(diff / 86_400);
|
||||
if (days <= 1) return { label: 'Ends today', isPast: false };
|
||||
if (days < 30) return { label: `${days} days left`, isPast: false };
|
||||
const months = Math.round(days / 30);
|
||||
return { label: `${months} mo left`, isPast: false };
|
||||
}
|
||||
|
||||
/** Short helper rendered both inline (cards) and in the detail page. */
|
||||
export function CampaignProgress({
|
||||
raisedSats,
|
||||
goalSats,
|
||||
btcPrice,
|
||||
className,
|
||||
}: {
|
||||
raisedSats: number;
|
||||
goalSats?: number;
|
||||
btcPrice?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const hasGoal = !!goalSats && goalSats > 0;
|
||||
const pct = hasGoal ? Math.min(100, Math.round((raisedSats / goalSats!) * 100)) : 0;
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
{hasGoal && <Progress value={pct} className="h-2" />}
|
||||
<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 {formatCampaignAmount(goalSats!, btcPrice)} goal</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CampaignCardProps {
|
||||
campaign: ParsedCampaign;
|
||||
/** Visual variant: `compact` for grid items, `featured` for hero placement. */
|
||||
variant?: 'compact' | 'featured';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single campaign as a clickable card. The whole card is a
|
||||
* `<Link>` to the campaign's naddr-based detail route.
|
||||
*/
|
||||
export function CampaignCard({ campaign, variant = 'compact', className }: CampaignCardProps) {
|
||||
const author = useAuthor(campaign.pubkey);
|
||||
const { data: stats } = useCampaignDonations(campaign.aTag);
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const { data: moderation } = useCampaignModeration();
|
||||
|
||||
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
|
||||
const cover = sanitizeUrl(campaign.image);
|
||||
const creatorName =
|
||||
author.data?.metadata?.display_name ||
|
||||
author.data?.metadata?.name ||
|
||||
genUserName(campaign.pubkey);
|
||||
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
|
||||
const raisedSats = stats?.totalSats ?? 0;
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
const tagLabel = getCampaignPrimaryTagLabel(campaign);
|
||||
|
||||
const isFeaturedVariant = variant === 'featured';
|
||||
const isApproved = moderation.approvedCoords.has(campaign.aTag);
|
||||
const isHidden = moderation.hiddenCoords.has(campaign.aTag);
|
||||
const isFeatured = moderation.featuredCoords.has(campaign.aTag);
|
||||
|
||||
return (
|
||||
<Link
|
||||
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',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col',
|
||||
isFeaturedVariant && 'sm:flex-row sm:items-stretch',
|
||||
)}
|
||||
>
|
||||
{/* Cover image */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full bg-gradient-to-br from-primary/15 via-primary/5 to-secondary',
|
||||
isFeaturedVariant ? 'aspect-[16/10] sm:aspect-auto sm:w-1/2 sm:min-h-[280px]' : 'aspect-[16/9]',
|
||||
)}
|
||||
>
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<HandHeart className="size-12 text-primary/40" />
|
||||
</div>
|
||||
)}
|
||||
{tagLabel && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute top-3 left-3 backdrop-blur bg-background/80 border-border/40"
|
||||
>
|
||||
{tagLabel}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2">
|
||||
{campaign.archived && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-background/85 border-border/40"
|
||||
>
|
||||
<Archive className="size-3.5 mr-1" />
|
||||
Archived
|
||||
</Badge>
|
||||
)}
|
||||
{isHidden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30"
|
||||
>
|
||||
Hidden
|
||||
</Badge>
|
||||
)}
|
||||
<CampaignModerationMenu
|
||||
coord={campaign.aTag}
|
||||
campaignTitle={campaign.title}
|
||||
isApproved={isApproved}
|
||||
isHidden={isHidden}
|
||||
isFeatured={isFeatured}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className={cn('flex flex-col gap-3 p-5', isFeaturedVariant && 'sm:w-1/2 sm:p-6')}>
|
||||
<div className="space-y-2">
|
||||
<h3
|
||||
className={cn(
|
||||
'font-bold leading-tight tracking-tight',
|
||||
isFeaturedVariant ? 'text-2xl sm:text-3xl' : 'text-lg',
|
||||
)}
|
||||
>
|
||||
{campaign.title}
|
||||
</h3>
|
||||
{campaign.summary && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
isFeaturedVariant ? 'text-base line-clamp-3' : 'text-sm line-clamp-2',
|
||||
)}
|
||||
>
|
||||
{campaign.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<CampaignProgress raisedSats={raisedSats} goalSats={campaign.goalSats} btcPrice={btcPrice} />
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{campaign.recipients.length}{' '}
|
||||
{campaign.recipients.length === 1 ? 'recipient' : 'recipients'}
|
||||
</span>
|
||||
{stats && stats.donorCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Target className="size-3.5" />
|
||||
{stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'}
|
||||
</span>
|
||||
)}
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-3.5" />
|
||||
{countryLabel}
|
||||
</span>
|
||||
)}
|
||||
{deadline && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5',
|
||||
deadline.isPast && 'text-destructive',
|
||||
)}
|
||||
>
|
||||
<CalendarClock className="size-3.5" />
|
||||
{deadline.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
|
||||
by <span className="font-medium text-foreground">{creatorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Loading placeholder mirroring {@link CampaignCard} dimensions. */
|
||||
export function CampaignCardSkeleton({
|
||||
variant = 'compact',
|
||||
className,
|
||||
}: {
|
||||
variant?: 'compact' | 'featured';
|
||||
className?: string;
|
||||
}) {
|
||||
const isFeatured = variant === 'featured';
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'overflow-hidden border-border/70 shadow-sm h-full flex flex-col',
|
||||
isFeatured && 'sm:flex-row sm:items-stretch',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Skeleton
|
||||
className={cn(
|
||||
'w-full rounded-none',
|
||||
isFeatured ? 'aspect-[16/10] sm:aspect-auto sm:w-1/2 sm:min-h-[280px]' : 'aspect-[16/9]',
|
||||
)}
|
||||
/>
|
||||
<div className={cn('flex-1 p-5 space-y-3', isFeatured && 'sm:w-1/2 sm:p-6')}>
|
||||
<Skeleton className={cn('w-3/4', isFeatured ? 'h-7' : 'h-5')} />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
interface CampaignHeroBackgroundProps {
|
||||
/**
|
||||
* Image URL for the active campaign. Each new URL crossfades over the
|
||||
* previous one — we keep up to two layers mounted at a time so the
|
||||
* transition is smooth even when the source changes mid-fade.
|
||||
*/
|
||||
imageUrl: string | undefined;
|
||||
/** Optional className for the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Layer {
|
||||
/** Stable key so React doesn't tear down the layer mid-transition. */
|
||||
id: number;
|
||||
/** Sanitized URL (or `null` for the gradient-only fallback). */
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
const FADE_MS = 1500;
|
||||
|
||||
/**
|
||||
* Full-bleed crossfading background built from the active campaign's banner
|
||||
* image. Modelled after Treasures' HeroGallery: each image gets its own
|
||||
* stacked layer and we toggle opacity to crossfade. The previous layer
|
||||
* unmounts after the fade completes, so we never accumulate more than a
|
||||
* couple of layers in the DOM.
|
||||
*
|
||||
* A warm tint + subtle film-grain SVG sit on top so headlines stay readable
|
||||
* over any photo.
|
||||
*/
|
||||
export function CampaignHeroBackground({ imageUrl, className }: CampaignHeroBackgroundProps) {
|
||||
const idRef = useRef(0);
|
||||
const [layers, setLayers] = useState<Layer[]>([]);
|
||||
const lastUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const safe = sanitizeUrl(imageUrl) ?? null;
|
||||
if (safe === lastUrlRef.current) return;
|
||||
lastUrlRef.current = safe;
|
||||
|
||||
const id = ++idRef.current;
|
||||
// Add the new layer; existing layers stay mounted so the crossfade has
|
||||
// something to fade from.
|
||||
setLayers((prev) => [...prev, { id, url: safe }]);
|
||||
|
||||
// After the fade completes, drop everything except the most recent
|
||||
// layer to keep the DOM tidy.
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLayers((prev) => prev.filter((l) => l.id === id));
|
||||
}, FADE_MS + 50);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [imageUrl]);
|
||||
|
||||
return (
|
||||
<div className={cn('absolute inset-0 overflow-hidden', className)} aria-hidden="true">
|
||||
{layers.map((layer, i) => {
|
||||
const isTop = i === layers.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
opacity: isTop ? 1 : 0,
|
||||
transition: `opacity ${FADE_MS}ms ease-in-out`,
|
||||
}}
|
||||
>
|
||||
{layer.url ? (
|
||||
<img
|
||||
src={layer.url}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
// Slow continuous pan toward the left — pairs with the
|
||||
// right-anchored globe so the scene reads as moving toward
|
||||
// the headline copy.
|
||||
className="absolute inset-0 w-full h-full object-cover hero-pan-left"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-background to-secondary/40" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Dark vertical scrim — strong at the bottom (spotlight card) and
|
||||
lighter at the top so the photo still reads. Uses black instead of
|
||||
background so the overlay is consistent across light/dark themes. */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/45 to-black/15" />
|
||||
{/* Warm primary tint — gives the hero its brand feel. */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-primary/10" />
|
||||
|
||||
{/* Left wash — mobile only, where the globe arc crosses the headline.
|
||||
Dark so white headline text has a reliable backdrop. */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/75 via-black/35 to-transparent sm:hidden" />
|
||||
|
||||
{/* Film grain — same trick as Treasures' HeroGallery. Helps the
|
||||
composited globe + photo feel like one image. */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ opacity: 0.18 }}
|
||||
>
|
||||
<filter id="hero-grain">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="2" stitchTiles="stitch" />
|
||||
<feColorMatrix type="saturate" values="0" />
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#hero-grain)" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, EyeOff, Eye, Loader2, MoreHorizontal, ShieldCheck, ShieldOff, Sparkles, SparklesIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useCampaignModeration, type ModerationLabel } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface CampaignModerationMenuProps {
|
||||
/** The campaign's `30223:<pubkey>:<d>` coordinate. */
|
||||
coord: string;
|
||||
/** Visible label for the campaign (for toast feedback). */
|
||||
campaignTitle: string;
|
||||
/** Whether the campaign is currently approved. */
|
||||
isApproved: boolean;
|
||||
/** Whether the campaign is currently hidden. */
|
||||
isHidden: boolean;
|
||||
/** Whether the campaign is currently featured. */
|
||||
isFeatured: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-card kebab menu exposing the six moderation actions:
|
||||
* Approve / Unapprove (axis = approval)
|
||||
* Hide / Unhide (axis = hide)
|
||||
* Feature / Unfeature (axis = featured)
|
||||
*
|
||||
* Renders `null` for users who are not Team Soapbox pack members. Sits
|
||||
* inside the clickable `CampaignCard` `<Link>`, so the trigger swallows
|
||||
* its own click + the dropdown content stops propagation, otherwise every
|
||||
* menu interaction would navigate to the campaign detail page.
|
||||
*/
|
||||
export function CampaignModerationMenu({
|
||||
coord,
|
||||
campaignTitle,
|
||||
isApproved,
|
||||
isHidden,
|
||||
isFeatured,
|
||||
className,
|
||||
}: CampaignModerationMenuProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const { moderate } = useCampaignModeration();
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState<ModerationLabel | null>(null);
|
||||
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
if (!isMod) return null;
|
||||
|
||||
const runAction = async (action: ModerationLabel, verbPast: string) => {
|
||||
if (busy) return;
|
||||
setBusy(action);
|
||||
try {
|
||||
await moderate.mutateAsync({ coord, action });
|
||||
toast({ title: `${verbPast}`, description: campaignTitle });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
toast({
|
||||
title: `Failed to ${action}`,
|
||||
description: message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Moderate campaign"
|
||||
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Moderator actions
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{isApproved ? (
|
||||
<DropdownMenuItem onClick={() => runAction('unapproved', 'Removed from homepage')}>
|
||||
<ShieldOff className="h-4 w-4 mr-2" />
|
||||
Unapprove
|
||||
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> Approved
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => runAction('approved', 'Approved for homepage')}>
|
||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||
Approve
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isHidden ? (
|
||||
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Unhide
|
||||
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> Hidden
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() => runAction('hidden', 'Hidden')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{isFeatured ? (
|
||||
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
|
||||
<SparklesIcon className="h-4 w-4 mr-2" />
|
||||
Unfeature
|
||||
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> Featured
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => runAction('featured', 'Featured on homepage')}>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Feature
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import { useScryfallCard } from '@/hooks/useScryfallCard';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { customFlagAsset, hasCustomFlag } from '@/lib/customFlags';
|
||||
import { useCountryFeed } from '@/contexts/CountryFeedContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFlagPalette } from '@/lib/flagPalette';
|
||||
@@ -144,10 +146,10 @@ const KIND_LABELS: Record<number, string> = {
|
||||
32267: 'a Zapstore app',
|
||||
34139: 'a playlist',
|
||||
34236: 'a divine',
|
||||
34550: 'a community',
|
||||
34550: 'an organization',
|
||||
9041: 'a goal',
|
||||
35128: 'an nsite',
|
||||
36639: 'an action',
|
||||
36639: 'a pledge',
|
||||
36787: 'a track',
|
||||
37381: 'a Magic deck',
|
||||
37516: 'a treasure',
|
||||
@@ -244,7 +246,7 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
|
||||
37381: 'deck',
|
||||
37516: 'treasure',
|
||||
30621: 'constellation',
|
||||
34550: 'community',
|
||||
34550: 'organization',
|
||||
30054: 'episode',
|
||||
30055: 'trailer',
|
||||
34139: 'playlist',
|
||||
@@ -383,6 +385,7 @@ function EventHoverLink({ display, link, hoverContent }: EventHoverLinkProps) {
|
||||
interface CommentContextProps {
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -390,7 +393,7 @@ interface CommentContextProps {
|
||||
* When the parent item (lowercase k tag) is another kind 1111 comment, shows "Replying to @user"
|
||||
* using the lowercase p tag (parent author). Otherwise shows "Commenting on [root]".
|
||||
*/
|
||||
export function CommentContext({ event, className }: CommentContextProps) {
|
||||
export function CommentContext({ event, className, prefix = 'Commenting on' }: CommentContextProps) {
|
||||
// If the direct parent is another comment (k="1111"), show "Replying to @user"
|
||||
const parentKind = event.tags.find(([name]) => name === 'k')?.[1];
|
||||
const parentAuthorPubkey = event.tags.findLast(([name]) => name === 'p')?.[1];
|
||||
@@ -405,11 +408,11 @@ export function CommentContext({ event, className }: CommentContextProps) {
|
||||
|
||||
switch (root.type) {
|
||||
case 'addr':
|
||||
return <AddrCommentContext root={root} className={className} />;
|
||||
return <AddrCommentContext root={root} className={className} prefix={prefix} />;
|
||||
case 'event':
|
||||
return <EventCommentContext root={root} className={className} />;
|
||||
return <EventCommentContext root={root} className={className} prefix={prefix} />;
|
||||
case 'external':
|
||||
return <ExternalCommentContext root={root} className={className} />;
|
||||
return <ExternalCommentContext root={root} className={className} prefix={prefix} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -444,27 +447,27 @@ function ReplyToCommentContext({ pubkey, eventId, className }: { pubkey: string;
|
||||
}
|
||||
|
||||
/** Comment context for addressable event roots (A tag). */
|
||||
function AddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
||||
function AddrCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
|
||||
// Kind 0 (profile) roots get special treatment — show "@DisplayName" with a profile link
|
||||
if (root.addr?.kind === 0) {
|
||||
return <ProfileCommentContext pubkey={root.addr.pubkey} className={className} />;
|
||||
return <ProfileCommentContext pubkey={root.addr.pubkey} className={className} prefix={prefix} />;
|
||||
}
|
||||
|
||||
// Kind 10008 or 30008 (profile badges) roots — show "@User's profile badges"
|
||||
if (root.addr?.kind === 10008 || root.addr?.kind === 30008) {
|
||||
return <ProfileBadgesCommentContext root={root} className={className} />;
|
||||
return <ProfileBadgesCommentContext root={root} className={className} prefix={prefix} />;
|
||||
}
|
||||
|
||||
// Kind 3 follow lists have no title of their own — synthesize one from the author's name
|
||||
if (root.addr?.kind === 3) {
|
||||
return <FollowListCommentContext pubkey={root.addr.pubkey} className={className} />;
|
||||
return <FollowListCommentContext pubkey={root.addr.pubkey} className={className} prefix={prefix} />;
|
||||
}
|
||||
|
||||
return <GenericAddrCommentContext root={root} className={className} />;
|
||||
return <GenericAddrCommentContext root={root} className={className} prefix={prefix} />;
|
||||
}
|
||||
|
||||
/** Comment context for kind 3 (follow list) roots — shows "Commenting on @Name's follow list". */
|
||||
function FollowListCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
function FollowListCommentContext({ pubkey, className, prefix }: { pubkey: string; className?: string; prefix: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
|
||||
@@ -475,7 +478,7 @@ function FollowListCommentContext({ pubkey, className }: { pubkey: string; class
|
||||
);
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
|
||||
<CommentContextRow prefix={prefix} className={className} loading={author.isLoading}>
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link
|
||||
to={`/${npubEncoded}`}
|
||||
@@ -498,14 +501,14 @@ function FollowListCommentContext({ pubkey, className }: { pubkey: string; class
|
||||
}
|
||||
|
||||
/** Comment context for kind 0 (profile) roots — shows "Commenting on @Name". */
|
||||
function ProfileCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
function ProfileCommentContext({ pubkey, className, prefix }: { pubkey: string; className?: string; prefix: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
|
||||
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
|
||||
<CommentContextRow prefix={prefix} className={className} loading={author.isLoading}>
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link
|
||||
to={`/${npubEncoded}`}
|
||||
@@ -520,7 +523,7 @@ function ProfileCommentContext({ pubkey, className }: { pubkey: string; classNam
|
||||
}
|
||||
|
||||
/** Comment context for kind 10008/30008 (profile badges) roots — shows "Commenting on profile badges by @User". */
|
||||
function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
||||
function ProfileBadgesCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
|
||||
const pubkey = root.addr?.pubkey ?? '';
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
@@ -542,7 +545,7 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
|
||||
<CommentContextRow prefix={prefix} className={className} loading={author.isLoading}>
|
||||
{link && hoverContent ? (
|
||||
<EventHoverLink
|
||||
display={{ text: 'profile badges', icon: Award }}
|
||||
@@ -570,11 +573,11 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
|
||||
}
|
||||
|
||||
/** Comment context for non-profile addressable event roots (A tag). */
|
||||
function GenericAddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
||||
function GenericAddrCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
|
||||
const { data: event, isLoading } = useAddrEvent(root.addr, root.relayHint ? [root.relayHint] : undefined);
|
||||
|
||||
const isCommunity = root.rootKind === '34550' || root.addr?.kind === 34550;
|
||||
const prefix = isCommunity ? 'Posted in' : 'Commenting on';
|
||||
const rowPrefix = isCommunity && prefix === 'Commenting on' ? 'Posted in' : prefix;
|
||||
|
||||
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
|
||||
const link = event ? getRootLink(event) : undefined;
|
||||
@@ -588,7 +591,7 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
|
||||
<CommentContextRow prefix={rowPrefix} className={className} loading={isLoading}>
|
||||
{link && hoverContent ? (
|
||||
<EventHoverLink display={display} link={link} hoverContent={hoverContent} />
|
||||
) : link ? (
|
||||
@@ -608,7 +611,7 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
|
||||
}
|
||||
|
||||
/** Comment context for regular event roots (E tag). */
|
||||
function EventCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
||||
function EventCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
|
||||
const { data: event, isLoading } = useEvent(
|
||||
root.eventId,
|
||||
root.relayHint ? [root.relayHint] : undefined,
|
||||
@@ -617,12 +620,12 @@ function EventCommentContext({ root, className }: { root: CommentRoot; className
|
||||
|
||||
// Kind 7 reactions get special treatment
|
||||
if (event?.kind === 7) {
|
||||
return <ReactionCommentContext event={event} className={className} />;
|
||||
return <ReactionCommentContext event={event} className={className} prefix={prefix} />;
|
||||
}
|
||||
|
||||
// Kind 1018 poll votes get special treatment
|
||||
if (event?.kind === 1018) {
|
||||
return <PollVoteCommentContext event={event} className={className} />;
|
||||
return <PollVoteCommentContext event={event} className={className} prefix={prefix} />;
|
||||
}
|
||||
|
||||
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
|
||||
@@ -639,7 +642,7 @@ function EventCommentContext({ root, className }: { root: CommentRoot; className
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
|
||||
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
|
||||
{link && hoverContent ? (
|
||||
<EventHoverLink display={display} link={link} hoverContent={hoverContent} />
|
||||
) : (
|
||||
@@ -650,7 +653,7 @@ function EventCommentContext({ root, className }: { root: CommentRoot; className
|
||||
}
|
||||
|
||||
/** Comment context for kind 7 reaction roots — shows "Commenting on {emoji} by @{name}". */
|
||||
function ReactionCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
|
||||
function ReactionCommentContext({ event, className, prefix }: { event: NostrEvent; className?: string; prefix: string }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
@@ -658,7 +661,7 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
|
||||
const profileLink = `/${nip19.npubEncode(event.pubkey)}`;
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className}>
|
||||
<CommentContextRow prefix={prefix} className={className}>
|
||||
<Link
|
||||
to={reactionLink}
|
||||
className="text-primary hover:underline shrink-0 cursor-pointer"
|
||||
@@ -685,7 +688,7 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
|
||||
}
|
||||
|
||||
/** Comment context for kind 1018 poll vote roots — shows "Commenting on @{name}'s vote for {option}". */
|
||||
function PollVoteCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
|
||||
function PollVoteCommentContext({ event, className, prefix }: { event: NostrEvent; className?: string; prefix: string }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
@@ -695,7 +698,7 @@ function PollVoteCommentContext({ event, className }: { event: NostrEvent; class
|
||||
const voteLabel = usePollVoteLabel(event);
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className}>
|
||||
<CommentContextRow prefix={prefix} className={className}>
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="h-3.5 w-16 inline-block" />
|
||||
) : (
|
||||
@@ -722,12 +725,12 @@ function PollVoteCommentContext({ event, className }: { event: NostrEvent; class
|
||||
}
|
||||
|
||||
/** Comment context for external content roots (I tag). */
|
||||
function ExternalCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
||||
function ExternalCommentContext({ root, className, prefix }: { root: CommentRoot; className?: string; prefix: string }) {
|
||||
const identifier = root.identifier ?? '';
|
||||
|
||||
// ISBN identifiers get special treatment — show book title instead of raw ISBN
|
||||
if (identifier.startsWith('isbn:')) {
|
||||
return <IsbnCommentContext identifier={identifier} className={className} />;
|
||||
return <IsbnCommentContext identifier={identifier} className={className} prefix={prefix} />;
|
||||
}
|
||||
|
||||
// URL identifiers get special treatment — show page title with favicon.
|
||||
@@ -736,21 +739,21 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN
|
||||
if (identifier.startsWith('http://') || identifier.startsWith('https://')) {
|
||||
const gathererCard = extractGathererCard(identifier);
|
||||
if (gathererCard) {
|
||||
return <GathererCardCommentContext card={gathererCard} url={identifier} className={className} />;
|
||||
return <GathererCardCommentContext card={gathererCard} url={identifier} className={className} prefix={prefix} />;
|
||||
}
|
||||
return <UrlCommentContext url={identifier} className={className} />;
|
||||
return <UrlCommentContext url={identifier} className={className} prefix={prefix} />;
|
||||
}
|
||||
|
||||
// ISO 3166 country/subdivision identifiers get special treatment
|
||||
if (identifier.startsWith('iso3166:')) {
|
||||
return <CountryCommentContext identifier={identifier} className={className} />;
|
||||
return <CountryCommentContext identifier={identifier} className={className} prefix={prefix} />;
|
||||
}
|
||||
|
||||
// Generic fallback for other external identifiers
|
||||
const link = `/i/${encodeURIComponent(identifier)}`;
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className}>
|
||||
<CommentContextRow prefix={prefix} className={className}>
|
||||
<Link
|
||||
to={link}
|
||||
className="text-primary hover:underline truncate"
|
||||
@@ -763,7 +766,7 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN
|
||||
}
|
||||
|
||||
/** Comment context for URL identifiers — fetches and displays the page title with favicon. */
|
||||
function UrlCommentContext({ url, className }: { url: string; className?: string }) {
|
||||
function UrlCommentContext({ url, className, prefix }: { url: string; className?: string; prefix: string }) {
|
||||
const { data: preview, isLoading } = useLinkPreview(url);
|
||||
const link = `/i/${encodeURIComponent(url)}`;
|
||||
|
||||
@@ -777,7 +780,7 @@ function UrlCommentContext({ url, className }: { url: string; className?: string
|
||||
const title = preview?.title;
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
|
||||
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
|
||||
<ExternalFavicon url={url} size={14} className="shrink-0" />
|
||||
<HoverCard openDelay={300} closeDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
@@ -810,6 +813,16 @@ function CountryPillBadge({ identifier, className }: { identifier: string; class
|
||||
const link = `/i/${encodeURIComponent(identifier)}`;
|
||||
|
||||
const flag = info?.flag ?? '🌍';
|
||||
// Treat ISO codes with a curated custom flag (Tibet) as country-level
|
||||
// throughout the pill chrome — display its own name, drop the parent
|
||||
// country sub-line, and label it as a country rather than a region.
|
||||
const treatAsCountry = hasCustomFlag(code);
|
||||
const displayLabel = treatAsCountry
|
||||
? info?.subdivisionName ?? info?.name ?? code
|
||||
: info?.subdivisionName ?? info?.name ?? code;
|
||||
const subLabel = !treatAsCountry && info?.subdivisionName && info.name ? info.name : null;
|
||||
const tierLabel = info?.subdivisionName && !treatAsCountry ? 'Region' : 'Country';
|
||||
const ariaLabel = info ? `Flag of ${displayLabel}` : code;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={300} closeDelay={150}>
|
||||
@@ -827,9 +840,9 @@ function CountryPillBadge({ identifier, className }: { identifier: string; class
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span role="img" aria-label={info ? `Flag of ${info.name}` : code}>
|
||||
{flag}
|
||||
</span>
|
||||
{/* CountryFlag swaps in a bundled SVG (Tibet's Snow Lion etc.)
|
||||
when the ISO code has no Unicode flag — emoji otherwise. */}
|
||||
<CountryFlag code={code} emoji={flag} label={ariaLabel} />
|
||||
</Link>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
@@ -840,20 +853,23 @@ function CountryPillBadge({ identifier, className }: { identifier: string; class
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="text-2xl leading-none shrink-0" role="img" aria-label={info ? `Flag of ${info.name}` : code}>
|
||||
{flag}
|
||||
</span>
|
||||
<CountryFlag
|
||||
code={code}
|
||||
emoji={flag}
|
||||
label={ariaLabel}
|
||||
className="text-2xl shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPin className="size-3 shrink-0" />
|
||||
<span>{info?.subdivisionName ? 'Region' : 'Country'}</span>
|
||||
<span>{tierLabel}</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate mt-0.5">
|
||||
{info?.subdivisionName ?? info?.name ?? code}
|
||||
{displayLabel}
|
||||
</p>
|
||||
{info?.subdivisionName && info.name && (
|
||||
{subLabel && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{info.name}
|
||||
{subLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -958,12 +974,18 @@ export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
|
||||
|
||||
if (!ctx) return null;
|
||||
|
||||
// Bundled-asset override: for codes with a curated flag SVG (currently
|
||||
// CN-XZ / Tibet) we skip Wikipedia entirely — its lead image is often
|
||||
// a parent-country map or an administrative-region inset, neither of
|
||||
// which reads as a flag behind a note. The Snow Lion SVG is what the
|
||||
// post is editorially "about", so it earns the backdrop slot.
|
||||
const bundledAsset = customFlagAsset(ctx.code);
|
||||
// For country articles Wikipedia returns the flag as the page's lead image
|
||||
// — the same source used by `CountryContentHeader`. Prefer the original
|
||||
// (full-resolution) over the 330px thumbnail; the thumbnail gets upscaled
|
||||
// and looks fuzzy when stretched across a full-width feed card.
|
||||
const flagImage = !imageFailed
|
||||
? (wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null)
|
||||
? (bundledAsset ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null)
|
||||
: null;
|
||||
|
||||
// Pre-built gradient using the palette (sampled from the flag emoji at
|
||||
@@ -1018,12 +1040,12 @@ export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
|
||||
* `CountryCommentPill`, so we suppress the in-body version to avoid
|
||||
* duplication.
|
||||
*/
|
||||
function CountryCommentContext(_props: { identifier: string; className?: string }) {
|
||||
function CountryCommentContext(_props: { identifier: string; className?: string; prefix: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Comment context for ISBN identifiers — fetches and displays the book title with hover preview. */
|
||||
function IsbnCommentContext({ identifier, className }: { identifier: string; className?: string }) {
|
||||
function IsbnCommentContext({ identifier, className, prefix }: { identifier: string; className?: string; prefix: string }) {
|
||||
const isbn = identifier.slice('isbn:'.length);
|
||||
const { data: bookInfo, isLoading } = useBookInfo(isbn);
|
||||
const link = `/i/${encodeURIComponent(identifier)}`;
|
||||
@@ -1032,7 +1054,7 @@ function IsbnCommentContext({ identifier, className }: { identifier: string; cla
|
||||
const authors = bookInfo?.authors?.map((a) => a.name).join(', ');
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
|
||||
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
|
||||
<HoverCard openDelay={300} closeDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link
|
||||
@@ -1094,10 +1116,12 @@ function GathererCardCommentContext({
|
||||
card,
|
||||
url,
|
||||
className,
|
||||
prefix,
|
||||
}: {
|
||||
card: GathererCard;
|
||||
url: string;
|
||||
className?: string;
|
||||
prefix: string;
|
||||
}) {
|
||||
const lookup = useMemo(() => (
|
||||
card.kind === 'multiverse'
|
||||
@@ -1111,7 +1135,7 @@ function GathererCardCommentContext({
|
||||
const coverUrl = scryCard ? cardPrimaryImage(scryCard, 'small') : undefined;
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
|
||||
<CommentContextRow prefix={prefix} className={className} loading={isLoading}>
|
||||
<HoverCard openDelay={300} closeDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Award, Loader2 } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { ImageUploadField } from '@/components/ImageUploadField';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useBadgeDefinitions, type BadgeDefinition } from '@/hooks/useBadgeDefinitions';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
interface BadgeRef {
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
interface CommunityBadgePanelProps {
|
||||
communityEvent: NostrEvent;
|
||||
community: ParsedCommunity;
|
||||
isFounder: boolean;
|
||||
}
|
||||
|
||||
function parseBadgeATag(aTag: string | undefined): BadgeRef | undefined {
|
||||
if (!aTag) return undefined;
|
||||
const [kind, pubkey, ...identifierParts] = aTag.split(':');
|
||||
const identifier = identifierParts.join(':');
|
||||
if (kind !== String(BADGE_DEFINITION_KIND) || !pubkey || !identifier) return undefined;
|
||||
return { pubkey, identifier };
|
||||
}
|
||||
|
||||
function buildBadgeTags(baseTags: string[][], dTag: string, name: string, description: string, imageUrl: string): string[][] {
|
||||
const tags = baseTags.filter(([tagName]) => !['d', 'name', 'description', 'image', 'thumb', 'alt'].includes(tagName));
|
||||
const nextTags: string[][] = [
|
||||
['d', dTag],
|
||||
['name', name.trim()],
|
||||
];
|
||||
|
||||
if (description.trim()) {
|
||||
nextTags.push(['description', description.trim()]);
|
||||
}
|
||||
|
||||
const image = sanitizeUrl(imageUrl.trim());
|
||||
if (image) {
|
||||
nextTags.push(['image', image, '1024x1024']);
|
||||
}
|
||||
|
||||
nextTags.push(...tags);
|
||||
nextTags.push(['alt', `Badge definition: ${name.trim()}`]);
|
||||
return nextTags;
|
||||
}
|
||||
|
||||
function buildCommunityBadgeTags(baseTags: string[][], badgeATag: string): string[][] {
|
||||
return [
|
||||
...baseTags.filter(([tagName, value, , role]) => !(tagName === 'a' && value?.startsWith(`${BADGE_DEFINITION_KIND}:`) && role === 'member')),
|
||||
['a', badgeATag, '', 'member'],
|
||||
];
|
||||
}
|
||||
|
||||
export function CommunityBadgePanel({ communityEvent, community, isFounder }: CommunityBadgePanelProps) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const badgeRef = useMemo(() => parseBadgeATag(community.memberBadgeATag), [community.memberBadgeATag]);
|
||||
const badgeRefs = useMemo(() => badgeRef ? [badgeRef] : [], [badgeRef]);
|
||||
const { badgeMap, isLoading, isError } = useBadgeDefinitions(badgeRefs);
|
||||
const badge = community.memberBadgeATag ? badgeMap.get(community.memberBadgeATag) : undefined;
|
||||
|
||||
const badgeButtonLabel = badge ? `Edit ${badge.name} badge` : 'Set member badge';
|
||||
|
||||
const badgeVisual = isLoading ? (
|
||||
<div className="size-10 animate-pulse rounded-lg bg-muted" />
|
||||
) : badge ? (
|
||||
<BadgeThumbnail badge={badge} size={40} className="shrink-0" />
|
||||
) : (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Award className="size-4" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Member badge</p>
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
{isFounder ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="shrink-0 rounded-lg transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
aria-label={badgeButtonLabel}
|
||||
title={badgeButtonLabel}
|
||||
>
|
||||
{badgeVisual}
|
||||
</button>
|
||||
) : badgeVisual}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load badge</p>
|
||||
) : isLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
) : badge ? (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium">{badge.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">Community member badge</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium">Member badge</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{isFounder ? 'Click the badge image to set one' : 'No badge set yet'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFounder && (
|
||||
<CommunityBadgeDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
communityEvent={communityEvent}
|
||||
community={community}
|
||||
badge={badge}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommunityBadgeEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
communityEvent,
|
||||
community,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
communityEvent: NostrEvent;
|
||||
community: ParsedCommunity;
|
||||
}) {
|
||||
const badgeRef = useMemo(() => parseBadgeATag(community.memberBadgeATag), [community.memberBadgeATag]);
|
||||
const badgeRefs = useMemo(() => badgeRef ? [badgeRef] : [], [badgeRef]);
|
||||
const { badgeMap } = useBadgeDefinitions(badgeRefs);
|
||||
const badge = community.memberBadgeATag ? badgeMap.get(community.memberBadgeATag) : undefined;
|
||||
|
||||
return (
|
||||
<CommunityBadgeDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
communityEvent={communityEvent}
|
||||
community={community}
|
||||
badge={badge}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityBadgeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
communityEvent,
|
||||
community,
|
||||
badge,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
communityEvent: NostrEvent;
|
||||
community: ParsedCommunity;
|
||||
badge?: BadgeDefinition;
|
||||
}) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const [name, setName] = useState('Member');
|
||||
const [description, setDescription] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||
|
||||
const canEditExistingBadge = !!badge && !!user && badge.event.pubkey === user.pubkey;
|
||||
const canSave = !badge || canEditExistingBadge;
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setName(badge?.name || 'Member');
|
||||
setDescription(badge?.description || `Member of ${community.name}`);
|
||||
setImageUrl(badge?.image || badge?.thumbs[0]?.url || '');
|
||||
setIsPublishing(false);
|
||||
setIsImageUploading(false);
|
||||
}, [badge, community.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) resetForm();
|
||||
}, [open, resetForm]);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
if (!nextOpen) resetForm();
|
||||
onOpenChange(nextOpen);
|
||||
}, [onOpenChange, resetForm]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!user || user.pubkey !== communityEvent.pubkey) return;
|
||||
if (!name.trim()) {
|
||||
toast({ title: 'Enter a badge name', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (isImageUploading) {
|
||||
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
|
||||
return;
|
||||
}
|
||||
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
|
||||
toast({ title: 'Badge image must be a valid https URL', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (badge && !canEditExistingBadge) {
|
||||
toast({ title: 'Badge cannot be edited', description: 'Only the badge issuer can edit this member badge.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const targetDTag = badge?.identifier || `${community.dTag}-member`;
|
||||
const prevBadge = await fetchFreshEvent(nostr, {
|
||||
kinds: [BADGE_DEFINITION_KIND],
|
||||
authors: [user.pubkey],
|
||||
'#d': [targetDTag],
|
||||
});
|
||||
const baseBadge = prevBadge ?? badge?.event;
|
||||
|
||||
const badgeEvent = await publishEvent({
|
||||
kind: BADGE_DEFINITION_KIND,
|
||||
content: baseBadge?.content ?? '',
|
||||
tags: buildBadgeTags(baseBadge?.tags ?? [['d', targetDTag]], targetDTag, name, description, imageUrl),
|
||||
prev: prevBadge ?? undefined,
|
||||
});
|
||||
|
||||
const badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${targetDTag}`;
|
||||
|
||||
if (!community.memberBadgeATag) {
|
||||
const prevCommunity = await fetchFreshEvent(nostr, {
|
||||
kinds: [COMMUNITY_DEFINITION_KIND],
|
||||
authors: [communityEvent.pubkey],
|
||||
'#d': [community.dTag],
|
||||
});
|
||||
const baseCommunity = prevCommunity ?? communityEvent;
|
||||
const updatedCommunity = await publishEvent({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
content: baseCommunity.content,
|
||||
tags: buildCommunityBadgeTags(baseCommunity.tags, badgeATag),
|
||||
prev: prevCommunity ?? undefined,
|
||||
});
|
||||
queryClient.setQueryData(['addr-event', COMMUNITY_DEFINITION_KIND, updatedCommunity.pubkey, community.dTag], updatedCommunity);
|
||||
}
|
||||
|
||||
queryClient.setQueryData(['addr-event', BADGE_DEFINITION_KIND, badgeEvent.pubkey, targetDTag], badgeEvent);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['badge-definitions-batch'], exact: false }),
|
||||
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag], exact: false }),
|
||||
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false }),
|
||||
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false }),
|
||||
]);
|
||||
|
||||
toast({ title: badge ? 'Member badge updated' : 'Member badge added' });
|
||||
handleOpenChange(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to update member badge',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
}, [
|
||||
user, communityEvent, name, isImageUploading, imageUrl, badge, canEditExistingBadge, community, nostr,
|
||||
publishEvent, description, queryClient, toast, handleOpenChange,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
|
||||
<DialogHeader className="px-5 pt-5 pb-3">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Award className="size-5 text-primary" />
|
||||
Member Badge
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This badge is awarded to members of {community.name}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-5 pb-5">
|
||||
{badge && !canEditExistingBadge && (
|
||||
<p className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
This badge was issued by another account, so it cannot be edited here.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-member-badge-name">Badge name *</Label>
|
||||
<Input
|
||||
id="community-member-badge-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={!canSave || isPublishing}
|
||||
maxLength={80}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-member-badge-description">Description</Label>
|
||||
<Textarea
|
||||
id="community-member-badge-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={!canSave || isPublishing}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ImageUploadField
|
||||
id="community-member-badge-image"
|
||||
label={<>Badge image <span className="text-muted-foreground font-normal">(recommended)</span></>}
|
||||
value={imageUrl}
|
||||
onChange={setImageUrl}
|
||||
onUploadingChange={setIsImageUploading}
|
||||
uploadToastTitle="Badge image uploaded"
|
||||
previewAlt="Member badge preview"
|
||||
objectFit="contain"
|
||||
dropAreaClassName="min-h-28"
|
||||
disabled={!canSave || isPublishing}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || !name.trim() || isPublishing || isImageUploading}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isPublishing ? <><Loader2 className="size-4 animate-spin" /> Saving...</> : <><Award className="size-4" /> Save Badge</>}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { ContentWarningGuard } from '@/components/ContentWarningGuard';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCommunityChatMessages, COMMUNITY_CHAT_KIND } from '@/hooks/useCommunityChatMessages';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import type { CommunityMember, CommunityModeration } from '@/lib/communityUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CommunityChatPanelProps {
|
||||
communityATag: string;
|
||||
moderation: CommunityModeration;
|
||||
rankMap: ReadonlyMap<string, CommunityMember>;
|
||||
isMembershipLoading: boolean;
|
||||
}
|
||||
|
||||
function shortTimeAgo(timestamp: number): string {
|
||||
const diff = Math.max(0, Math.floor(Date.now() / 1000) - timestamp);
|
||||
if (diff < 60) return 'now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
return `${Math.floor(diff / 86400)}d`;
|
||||
}
|
||||
|
||||
export function CommunityChatPanel({
|
||||
communityATag,
|
||||
moderation,
|
||||
rankMap,
|
||||
isMembershipLoading,
|
||||
}: CommunityChatPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: messages, isLoading, isError, error, queryKey } = useCommunityChatMessages(communityATag, moderation);
|
||||
|
||||
const isBanned = !!user && moderation.bannedPubkeys.has(user.pubkey);
|
||||
const isMember = !!user && rankMap.has(user.pubkey) && !isBanned;
|
||||
const disabledReason = !user
|
||||
? 'Log in to chat with this community.'
|
||||
: isMembershipLoading
|
||||
? 'Loading membership...'
|
||||
: isBanned
|
||||
? 'You are banned from this community.'
|
||||
: !isMember
|
||||
? 'Only community members can chat.'
|
||||
: undefined;
|
||||
const canSend = !disabledReason;
|
||||
|
||||
const chatPublish = useMemo(() => ({
|
||||
kind: COMMUNITY_CHAT_KIND,
|
||||
tags: [['a', communityATag, '', 'root']],
|
||||
suppressSuccessToast: true,
|
||||
}), [communityATag]);
|
||||
|
||||
const handlePublished = useCallback((event: NostrEvent) => {
|
||||
queryClient.setQueryData<NostrEvent[]>(queryKey, (old = []) => {
|
||||
if (old.some((existing) => existing.id === event.id)) return old;
|
||||
return [...old, event].sort((a, b) => b.created_at - a.created_at);
|
||||
});
|
||||
}, [queryClient, queryKey]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{disabledReason && (
|
||||
<p className="px-4 pt-3 text-center text-xs text-muted-foreground">{disabledReason}</p>
|
||||
)}
|
||||
{canSend && (
|
||||
<ComposeBox
|
||||
compact
|
||||
placeholder="What's up?"
|
||||
customPublish={chatPublish}
|
||||
hidePoll
|
||||
submitLabel="Send"
|
||||
onPublished={handlePublished}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<CommunityChatSkeleton />
|
||||
) : isError ? (
|
||||
<div className="py-12 px-4 text-center text-sm text-destructive">
|
||||
{error instanceof Error ? error.message : 'Failed to load community chat.'}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
|
||||
<div className="mb-3 rounded-full bg-primary/10 p-3">
|
||||
<MessageSquare className="size-6 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">No messages yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Start the first live conversation here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{messages.map((event, index) => {
|
||||
const previous = messages[index - 1];
|
||||
const showAvatar = !previous
|
||||
|| previous.pubkey !== event.pubkey
|
||||
|| previous.created_at - event.created_at > 300;
|
||||
return <CommunityChatMessage key={event.id} event={event} showAvatar={showAvatar} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityChatSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 px-2 py-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<Skeleton className="size-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2 pt-1">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className={cn('h-4', index % 2 === 0 ? 'w-4/5' : 'w-2/3')} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityChatMessage({ event, showAvatar }: { event: NostrEvent; showAvatar: boolean }) {
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata: NostrMetadata | undefined = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const isOwnMessage = user?.pubkey === event.pubkey;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex gap-3 px-4 py-3 transition-colors hover:bg-secondary/40',
|
||||
!showAvatar && 'py-2',
|
||||
isOwnMessage && 'justify-end',
|
||||
)}
|
||||
>
|
||||
{!isOwnMessage && <ChatMessageAvatar showAvatar={showAvatar} profileUrl={profileUrl} metadata={metadata} displayName={displayName} createdAt={event.created_at} />}
|
||||
<div className={cn('min-w-0 flex-1', isOwnMessage && 'flex flex-col items-end')}>
|
||||
{showAvatar && (
|
||||
<div className={cn('mb-0.5 flex items-baseline gap-2', isOwnMessage && 'justify-end')}>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className={cn('truncate text-xs font-semibold text-primary hover:underline', isOwnMessage && 'order-2')}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{displayName}
|
||||
</Link>
|
||||
<span className={cn('text-[10px] text-muted-foreground/60', isOwnMessage && 'order-1')}>{shortTimeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
<ContentWarningGuard event={event} className="w-full max-w-[64%] sm:max-w-xs">
|
||||
<div
|
||||
className={cn(
|
||||
'inline-block w-fit max-w-[64%] break-words rounded-2xl px-3 py-2 text-sm leading-relaxed sm:max-w-xs',
|
||||
isOwnMessage ? 'rounded-tr-md bg-primary text-primary-foreground text-right' : 'rounded-tl-md bg-secondary/60',
|
||||
)}
|
||||
>
|
||||
<NoteContent event={event} disableNoteEmbeds />
|
||||
</div>
|
||||
</ContentWarningGuard>
|
||||
</div>
|
||||
{isOwnMessage && <ChatMessageAvatar showAvatar={showAvatar} profileUrl={profileUrl} metadata={metadata} displayName={displayName} createdAt={event.created_at} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessageAvatar({
|
||||
showAvatar,
|
||||
profileUrl,
|
||||
metadata,
|
||||
displayName,
|
||||
createdAt,
|
||||
}: {
|
||||
showAvatar: boolean;
|
||||
profileUrl: string;
|
||||
metadata: NostrMetadata | undefined;
|
||||
displayName: string;
|
||||
createdAt: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-8 shrink-0">
|
||||
{showAvatar ? (
|
||||
<Link to={profileUrl} onClick={(event) => event.stopPropagation()}>
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/15 text-[10px] text-primary">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="hidden pt-0.5 text-[10px] text-muted-foreground/60 group-hover:block">
|
||||
{shortTimeAgo(createdAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ function getTag(tags: string[][], name: string): string | undefined {
|
||||
}
|
||||
|
||||
function parseCommunityEvent(event: NostrEvent) {
|
||||
const name = getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unnamed Community';
|
||||
const name = getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unnamed Organization';
|
||||
const description = getTag(event.tags, 'description') || '';
|
||||
const image = getTag(event.tags, 'image');
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const REPORT_TYPE_LABELS: Record<Nip56ReportType, string> = {
|
||||
illegal: 'illegal content',
|
||||
malware: 'malware',
|
||||
impersonation: 'impersonation',
|
||||
other: 'community guidelines',
|
||||
other: 'organization guidelines',
|
||||
};
|
||||
|
||||
interface CommunityContentWarningProps {
|
||||
@@ -68,8 +68,8 @@ export function CommunityContentWarning({ event, children, className }: Communit
|
||||
<p className="text-sm font-medium text-foreground">Reported Content</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{reporterCount === 1
|
||||
? `Reported by a community member for ${typeLabels}.`
|
||||
: `Reported by ${reporterCount} community members for ${typeLabels}.`}
|
||||
? `Reported by an organization moderator for ${typeLabels}.`
|
||||
: `Reported by ${reporterCount} organization moderators for ${typeLabels}.`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* CommunityPulsePanel
|
||||
*
|
||||
* "Pulse" tab on the community detail page — an infinite-scrolling feed of
|
||||
* posts published by community members *outside* this community. The intent
|
||||
* is to surface what members are sharing in the wider Nostr ecosystem, as
|
||||
* opposed to the in-community Activity tab.
|
||||
*
|
||||
* Implementation notes:
|
||||
* - Authors come from the community `rankMap` (founders + moderators +
|
||||
* members). Without authors the relay would return the entire global
|
||||
* timeline.
|
||||
* - Kinds come from `getEnabledFeedKinds(feedSettings)` so the feed
|
||||
* respects the user's "Notes / Articles / Reposts / etc." preferences,
|
||||
* exactly like the home feed.
|
||||
* - Events tagged with this community's `a` reference are dropped — those
|
||||
* belong on the Activity tab.
|
||||
* - Replies (NIP-10 / NIP-22) are dropped so the Pulse reads like a
|
||||
* timeline of top-level posts, not threaded responses.
|
||||
* - Mute list, content-warning, and repost unwrap behavior come for free
|
||||
* by reusing `useTabFeed` + the `feedUtils` helpers.
|
||||
*/
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
import { useTabFeed } from '@/hooks/useProfileFeed';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
import { isReplyEvent } from '@/lib/nostrEvents';
|
||||
|
||||
interface CommunityPulsePanelProps {
|
||||
/** `34550:<pubkey>:<d>` — used both for the cache key and the in-community filter. */
|
||||
communityATag: string;
|
||||
/** Author allowlist — founders + moderators + members. */
|
||||
memberPubkeys: string[];
|
||||
/** True while membership is still resolving; suppresses an empty-state flash. */
|
||||
isMembershipLoading: boolean;
|
||||
}
|
||||
|
||||
export function CommunityPulsePanel({
|
||||
communityATag,
|
||||
memberPubkeys,
|
||||
isMembershipLoading,
|
||||
}: CommunityPulsePanelProps) {
|
||||
const { muteItems } = useMuteList();
|
||||
const { ref: sentinelRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
|
||||
// Build the TabFeed filter — kinds default to the user's enabled feed kinds
|
||||
// (handled inside useTabFeed when `kinds` is omitted from the filter).
|
||||
const filter = useMemo<NostrFilter | null>(
|
||||
() => (memberPubkeys.length > 0 ? { authors: memberPubkeys } : null),
|
||||
[memberPubkeys],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useTabFeed(filter, `community-pulse-${communityATag}`, memberPubkeys.length > 0);
|
||||
|
||||
// Fetch next page when the sentinel scrolls into view.
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
/**
|
||||
* Drop events that reference *this* community via an `a` tag — they belong
|
||||
* to the Activity tab, not Pulse. We check both the original event and the
|
||||
* embedded event of a repost.
|
||||
*/
|
||||
const referencesThisCommunity = (tags: string[][]): boolean => {
|
||||
for (const tag of tags) {
|
||||
if (tag[0] === 'a' && tag[1] === communityATag) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Flatten pages, dedupe, and apply mute / content-warning / reply /
|
||||
// in-community filters.
|
||||
const feedItems = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
return data.pages
|
||||
.flatMap((page) => page.items)
|
||||
.filter((item) => {
|
||||
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
|
||||
if (shouldHideFeedEvent(item.event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
|
||||
// Hide replies on original (non-repost) text notes; a repost of a
|
||||
// reply is still a legitimate top-level surface.
|
||||
if (item.event.kind === 1 && !item.repostedBy && isReplyEvent(item.event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drop anything authored against this community — that's Activity.
|
||||
if (referencesThisCommunity(item.event.tags)) return false;
|
||||
if (item.repostEvent && referencesThisCommunity(item.repostEvent.tags)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
// `referencesThisCommunity` and `communityATag` referenced via closure —
|
||||
// adding `communityATag` to deps is sufficient.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.pages, muteItems, communityATag]);
|
||||
|
||||
// ── States ────────────────────────────────────────────────────────────────
|
||||
if (memberPubkeys.length === 0 && !isMembershipLoading) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
No community members yet — nothing to surface here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ((isLoading || isMembershipLoading) && feedItems.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-11 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (feedItems.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
No posts from community members elsewhere yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{feedItems.map((item) => (
|
||||
<NoteCard
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-6">
|
||||
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export function CommunityReportDialog({
|
||||
<DialogContent className="max-w-md max-h-[85dvh] rounded-2xl flex flex-col overflow-hidden">
|
||||
<DialogTitle>Report post</DialogTitle>
|
||||
<DialogDescription className="text-sm text-muted-foreground">
|
||||
Select a reason for reporting this post to the community.
|
||||
Select a reason for reporting this post to the organization.
|
||||
</DialogDescription>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 -mx-6 px-6">
|
||||
|
||||
@@ -1,511 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, Loader2, Plus, Wallet, X, Zap } from 'lucide-react';
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useCommunityBatchZaps } from '@/hooks/useCommunityBatchZaps';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useSparkWallet } from '@/hooks/useSparkWallet';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { CommunityMember, ParsedCommunity } from '@/lib/communityUtils';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
|
||||
type RecipientRole = 'founder' | 'moderator' | 'member';
|
||||
type RecipientStatus = 'ready' | 'loading' | 'missing-ln' | 'removed' | 'self';
|
||||
|
||||
interface RecipientView {
|
||||
pubkey: string;
|
||||
role: RecipientRole;
|
||||
name: string;
|
||||
picture?: string;
|
||||
lightningAddress?: string;
|
||||
authorEvent?: NostrEvent;
|
||||
status: RecipientStatus;
|
||||
}
|
||||
|
||||
interface CommunityZapDialogProps {
|
||||
community: ParsedCommunity;
|
||||
members: CommunityMember[];
|
||||
membersLoading: boolean;
|
||||
triggerClassName?: string;
|
||||
onZapLaunched?: (details: { count: number; totalSats: number }) => void;
|
||||
}
|
||||
|
||||
function roleLabel(role: RecipientRole): string {
|
||||
switch (role) {
|
||||
case 'founder': return 'Founder';
|
||||
case 'moderator': return 'Moderator';
|
||||
case 'member': return 'Member';
|
||||
}
|
||||
}
|
||||
|
||||
function memberRole(member: CommunityMember, community: ParsedCommunity): RecipientRole {
|
||||
if (member.pubkey === community.founderPubkey) return 'founder';
|
||||
if (member.rank === 0) return 'moderator';
|
||||
return 'member';
|
||||
}
|
||||
|
||||
function shortAddress(value: string): string {
|
||||
if (value.length <= 42) return value;
|
||||
return `${value.slice(0, 20)}...${value.slice(-16)}`;
|
||||
}
|
||||
|
||||
export function CommunityZapDialog({
|
||||
community,
|
||||
members,
|
||||
membersLoading,
|
||||
triggerClassName,
|
||||
onZapLaunched,
|
||||
}: CommunityZapDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [amount, setAmount] = useState('100');
|
||||
const [comment, setComment] = useState(`Zapped the whole ${community.name} community!`);
|
||||
const [removedPubkeys, setRemovedPubkeys] = useState<Set<string>>(new Set());
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const sparkWallet = useSparkWallet();
|
||||
const { toast } = useToast();
|
||||
const { zapCommunity } = useCommunityBatchZaps();
|
||||
|
||||
const pubkeys = useMemo(() => members.map((member) => member.pubkey), [members]);
|
||||
const authors = useAuthors(pubkeys);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setRemovedPubkeys(new Set());
|
||||
setComment(`Zapped the whole ${community.name} community!`);
|
||||
}, [open, community.name]);
|
||||
|
||||
const recipients = useMemo<RecipientView[]>(() => {
|
||||
return members.map((member) => {
|
||||
const author = authors.data?.get(member.pubkey);
|
||||
const metadata: NostrMetadata | undefined = author?.metadata;
|
||||
const lightningAddress = metadata?.lud16 || metadata?.lud06;
|
||||
const removed = removedPubkeys.has(member.pubkey);
|
||||
const status: RecipientStatus = user?.pubkey === member.pubkey
|
||||
? 'self'
|
||||
: removed
|
||||
? 'removed'
|
||||
: authors.isLoading && !author?.event
|
||||
? 'loading'
|
||||
: lightningAddress && author?.event
|
||||
? 'ready'
|
||||
: 'missing-ln';
|
||||
|
||||
return {
|
||||
pubkey: member.pubkey,
|
||||
role: memberRole(member, community),
|
||||
name: getDisplayName(metadata, member.pubkey),
|
||||
picture: metadata?.picture,
|
||||
lightningAddress,
|
||||
authorEvent: author?.event,
|
||||
status,
|
||||
};
|
||||
});
|
||||
}, [authors.data, authors.isLoading, community, members, removedPubkeys, user?.pubkey]);
|
||||
|
||||
const amountSats = parseInt(amount, 10);
|
||||
const selectedRecipients = recipients.filter(
|
||||
(recipient) => recipient.status === 'ready' && recipient.authorEvent,
|
||||
);
|
||||
const skippedCount = recipients.filter((recipient) => recipient.status === 'missing-ln' || recipient.status === 'self').length;
|
||||
const removedCount = recipients.filter((recipient) => recipient.status === 'removed').length;
|
||||
const totalSats = Number.isFinite(amountSats) && amountSats > 0
|
||||
? amountSats * selectedRecipients.length
|
||||
: 0;
|
||||
const walletReady = sparkWallet.isEnabled && sparkWallet.isInitialized;
|
||||
const canSubmit = !!user
|
||||
&& walletReady
|
||||
&& selectedRecipients.length > 0
|
||||
&& Number.isFinite(amountSats)
|
||||
&& amountSats > 0
|
||||
&& sparkWallet.balance >= totalSats
|
||||
&& !isLaunching;
|
||||
|
||||
const toggleRemoved = (pubkey: string, remove: boolean) => {
|
||||
setRemovedPubkeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (remove) next.add(pubkey);
|
||||
else next.delete(pubkey);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!user) {
|
||||
toast({ title: 'Login required', description: 'Log in to zap this community.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (!walletReady) {
|
||||
toast({ title: 'Wallet required', description: 'Set up your Agora Wallet to zap a community.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(amountSats) || amountSats <= 0) {
|
||||
toast({ title: 'Invalid amount', description: 'Enter a positive amount in sats.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (selectedRecipients.length === 0) {
|
||||
toast({ title: 'No recipients', description: 'No selected members can receive zaps.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (sparkWallet.balance < totalSats) {
|
||||
toast({
|
||||
title: 'Insufficient balance',
|
||||
description: `You need at least ${totalSats.toLocaleString()} sats before Lightning fees.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const batchRecipients = selectedRecipients
|
||||
.filter((recipient): recipient is RecipientView & { authorEvent: NostrEvent } => !!recipient.authorEvent)
|
||||
.map((recipient) => ({ pubkey: recipient.pubkey, authorEvent: recipient.authorEvent }));
|
||||
|
||||
setIsLaunching(true);
|
||||
setOpen(false);
|
||||
onZapLaunched?.({ count: batchRecipients.length, totalSats });
|
||||
toast({
|
||||
title: `Zapping ${batchRecipients.length} members...`,
|
||||
description: `${totalSats.toLocaleString()} sats are on the way.`,
|
||||
});
|
||||
|
||||
void zapCommunity({
|
||||
community,
|
||||
recipients: batchRecipients,
|
||||
amountSats,
|
||||
comment,
|
||||
}).then((summary) => {
|
||||
setIsLaunching(false);
|
||||
if (summary.failed.length > 0) {
|
||||
toast({
|
||||
title: `Zapped ${summary.succeeded} of ${summary.attempted} members`,
|
||||
description: `${summary.failed.length} zap${summary.failed.length === 1 ? '' : 's'} failed.`,
|
||||
variant: summary.succeeded > 0 ? 'default' : 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
notificationSuccess();
|
||||
toast({
|
||||
title: `Zapped ${summary.succeeded} members`,
|
||||
description: `${summary.totalSats.toLocaleString()} sats sent to ${community.name}.`,
|
||||
});
|
||||
}).catch((error) => {
|
||||
setIsLaunching(false);
|
||||
const message = error instanceof Error ? error.message : 'Community zap failed.';
|
||||
toast({ title: 'Community zap failed', description: message, variant: 'destructive' });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center',
|
||||
triggerClassName ?? 'p-2 rounded-full shadow-md bg-white text-black hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors',
|
||||
)}
|
||||
aria-label="Zap community"
|
||||
title="Zap community"
|
||||
>
|
||||
<Zap className="size-5" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[88vh] flex flex-col overflow-hidden p-0 gap-0 [&>button]:top-3 [&>button]:right-3">
|
||||
<DialogHeader className="px-5 pt-5 pb-3 border-b border-border shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Zap className="size-5 text-amber-500" />
|
||||
Zap Community
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a real Nostr zap to each selected active member.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="community-zap-amount">Amount per member</Label>
|
||||
<div className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
Balance <span className="tabular-nums text-foreground">{sparkWallet.balance.toLocaleString()} sats</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="community-zap-amount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
onWheel={(e) => (e.target as HTMLInputElement).blur()}
|
||||
className="h-12 rounded-full bg-background/90 pr-14 text-center text-lg font-semibold"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-5 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
sats
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="community-zap-comment">Comment</Label>
|
||||
<Textarea
|
||||
id="community-zap-comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!walletReady && (
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<Wallet className="size-4 mt-0.5 shrink-0" />
|
||||
Set up and unlock your Agora Wallet before zapping the community.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border">
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Recipients</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedRecipients.length} selected
|
||||
{removedCount > 0 ? ` · ${removedCount} removed` : ''}
|
||||
{skippedCount > 0 ? ` · ${skippedCount} skipped` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{membersLoading || authors.isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{recipients.map((recipient) => (
|
||||
<RecipientRow
|
||||
key={recipient.pubkey}
|
||||
recipient={recipient}
|
||||
amountSats={amountSats}
|
||||
onRemove={() => toggleRemoved(recipient.pubkey, true)}
|
||||
onRestore={() => toggleRemoved(recipient.pubkey, false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-4 shrink-0">
|
||||
<HoldToZapButton
|
||||
disabled={!canSubmit}
|
||||
isLaunching={isLaunching}
|
||||
selectedCount={selectedRecipients.length}
|
||||
totalSats={totalSats}
|
||||
onComplete={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const HOLD_DURATION_MS = 3000;
|
||||
|
||||
function HoldToZapButton({
|
||||
disabled,
|
||||
isLaunching,
|
||||
selectedCount,
|
||||
totalSats,
|
||||
onComplete,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
isLaunching: boolean;
|
||||
selectedCount: number;
|
||||
totalSats: number;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [holding, setHolding] = useState(false);
|
||||
const rafRef = useRef(0);
|
||||
const startedAtRef = useRef(0);
|
||||
const completedRef = useRef(false);
|
||||
|
||||
const cancelHold = () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = 0;
|
||||
startedAtRef.current = 0;
|
||||
completedRef.current = false;
|
||||
setHolding(false);
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
const elapsed = performance.now() - startedAtRef.current;
|
||||
const nextProgress = Math.min(1, elapsed / HOLD_DURATION_MS);
|
||||
setProgress(nextProgress);
|
||||
if (nextProgress >= 1) {
|
||||
completedRef.current = true;
|
||||
setHolding(false);
|
||||
setProgress(0);
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const startHold = () => {
|
||||
if (disabled || isLaunching || holding) return;
|
||||
completedRef.current = false;
|
||||
startedAtRef.current = performance.now();
|
||||
setHolding(true);
|
||||
setProgress(0);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
useEffect(() => () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || isLaunching) cancelHold();
|
||||
}, [disabled, isLaunching]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="relative h-12 w-full overflow-hidden rounded-full border border-primary bg-primary text-primary-foreground hover:bg-primary"
|
||||
disabled={disabled}
|
||||
onPointerDown={(event) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
startHold();
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
if (!completedRef.current) cancelHold();
|
||||
}}
|
||||
onPointerCancel={cancelHold}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.key === 'Enter' || event.key === ' ') && !event.repeat) {
|
||||
event.preventDefault();
|
||||
startHold();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (!completedRef.current) cancelHold();
|
||||
}
|
||||
}}
|
||||
aria-label={`Hold for 3 seconds to zap ${selectedCount} members with ${totalSats.toLocaleString()} sats total`}
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0 origin-left rounded-full bg-background/25 transition-transform duration-75 ease-linear"
|
||||
style={{ transform: `scaleX(${progress})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_rgba(255,255,255,0.12)]" aria-hidden="true" />
|
||||
<span className="relative z-10 flex items-center justify-center mix-blend-normal">
|
||||
{isLaunching ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Launching...
|
||||
</>
|
||||
) : (
|
||||
`Zap ${totalSats.toLocaleString()} sats`
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function RecipientRow({
|
||||
recipient,
|
||||
amountSats,
|
||||
onRemove,
|
||||
onRestore,
|
||||
}: {
|
||||
recipient: RecipientView;
|
||||
amountSats: number;
|
||||
onRemove: () => void;
|
||||
onRestore: () => void;
|
||||
}) {
|
||||
const isReady = recipient.status === 'ready';
|
||||
const isRemoved = recipient.status === 'removed';
|
||||
const isUnavailable = recipient.status === 'missing-ln' || recipient.status === 'loading' || recipient.status === 'self';
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3 px-5 py-3', (isRemoved || isUnavailable) && 'opacity-55')}>
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={recipient.picture} />
|
||||
<AvatarFallback>{recipient.name[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className={cn('font-medium truncate', isRemoved && 'line-through')}>{recipient.name}</p>
|
||||
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 shrink-0">
|
||||
{roleLabel(recipient.role)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{recipient.status === 'loading'
|
||||
? 'Loading profile...'
|
||||
: recipient.status === 'self'
|
||||
? 'You · skipped'
|
||||
: recipient.lightningAddress
|
||||
? shortAddress(recipient.lightningAddress)
|
||||
: 'No Lightning address · skipped'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isReady && Number.isFinite(amountSats) && amountSats > 0 && (
|
||||
<span className="hidden text-xs font-medium tabular-nums text-muted-foreground sm:inline">
|
||||
{amountSats.toLocaleString()} sats
|
||||
</span>
|
||||
)}
|
||||
{isReady ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="rounded-full p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Remove ${recipient.name} from this zap`}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : isRemoved ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
className="rounded-full p-1.5 text-muted-foreground hover:bg-primary/10 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`Add ${recipient.name} back to this zap`}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<Check className="size-4 text-muted-foreground/50" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -132,6 +132,10 @@ interface ComposeBoxProps {
|
||||
hideAvatar?: boolean;
|
||||
/** If true, suppresses the bottom border. Use when the composer sits directly above a visually distinct section (e.g. tabs with an arc background) that already provides separation. */
|
||||
hideBorder?: boolean;
|
||||
/** Extra class names merged onto the outer wrapper. Useful for
|
||||
* overriding the default `bg-background/85` when the composer is
|
||||
* rendered inside a card surface. */
|
||||
className?: string;
|
||||
/** Controlled preview mode (for modal usage). */
|
||||
previewMode?: boolean;
|
||||
/** Callback to notify parent of previewable content changes. */
|
||||
@@ -152,6 +156,10 @@ interface ComposeBoxProps {
|
||||
hidePoll?: boolean;
|
||||
/** Label for the primary submit button. */
|
||||
submitLabel?: string;
|
||||
/** Tags added to new top-level kind 1 notes without putting them in content. */
|
||||
defaultTags?: string[][];
|
||||
/** If true, the composer starts expanded without taking modal/flex behavior. */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/** Circular progress ring for character count. */
|
||||
@@ -202,6 +210,7 @@ export function ComposeBox({
|
||||
forceExpanded = false,
|
||||
hideAvatar = false,
|
||||
hideBorder = false,
|
||||
className,
|
||||
previewMode: controlledPreviewMode,
|
||||
onHasPreviewableContentChange,
|
||||
initialContent = '',
|
||||
@@ -209,6 +218,8 @@ export function ComposeBox({
|
||||
customPublish,
|
||||
hidePoll = false,
|
||||
submitLabel = 'Post!',
|
||||
defaultTags = [],
|
||||
defaultExpanded = false,
|
||||
}: ComposeBoxProps) {
|
||||
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
|
||||
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
|
||||
@@ -243,7 +254,7 @@ export function ComposeBox({
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [cwEnabled, setCwEnabled] = useState(false);
|
||||
const [cwText, setCwText] = useState('');
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -300,7 +311,7 @@ export function ComposeBox({
|
||||
setContent('');
|
||||
setCwEnabled(false);
|
||||
setCwText('');
|
||||
setExpanded(false);
|
||||
setExpanded(defaultExpanded);
|
||||
setPickerOpen(false);
|
||||
setTrayOpen(false);
|
||||
setInternalPreviewMode(false);
|
||||
@@ -315,7 +326,7 @@ export function ComposeBox({
|
||||
setDestination('world');
|
||||
// Clear the auto-saved draft
|
||||
try { localStorage.removeItem(draftKey); } catch { /* ignore */ }
|
||||
}, [initialMode, draftKey]);
|
||||
}, [initialMode, draftKey, defaultExpanded]);
|
||||
|
||||
// Use controlled preview mode if provided, otherwise use internal state
|
||||
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
|
||||
@@ -1106,7 +1117,7 @@ export function ComposeBox({
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content: finalContent,
|
||||
tags,
|
||||
tags: [...defaultTags, ...tags],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
@@ -1217,6 +1228,7 @@ export function ComposeBox({
|
||||
forceExpanded ? "flex-1 min-h-0 rounded-2xl" : "",
|
||||
pickerOpen ? "pb-0" : "pb-3",
|
||||
!forceExpanded && !hideBorder && "border-b border-border",
|
||||
className,
|
||||
)}>
|
||||
{/* Preview toggle at top when not controlled and has previewable content */}
|
||||
{hasPreviewableContent && controlledPreviewMode === undefined && (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { customFlagAsset } from '@/lib/customFlags';
|
||||
|
||||
interface CountryFlagProps {
|
||||
/**
|
||||
* ISO 3166-1 alpha-2 country code (e.g. `US`, `BR`) or ISO 3166-2
|
||||
* subdivision code (e.g. `CN-XZ`, `GB-SCT`). Case-insensitive.
|
||||
*/
|
||||
code: string;
|
||||
/** The flag emoji to render when no custom asset is available. */
|
||||
emoji: string;
|
||||
/** Accessible label / `alt` for the flag. */
|
||||
label: string;
|
||||
/** Optional extra classes applied to the rendering element. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a flag for a country or subdivision. For codes with a bundled
|
||||
* SVG (currently Tibet) we emit an `<img>` that visually matches the
|
||||
* surrounding emoji line-height; for everything else we drop the emoji
|
||||
* straight into a `<span>` so it inherits font color and selection
|
||||
* behaviour like the rest of the text run.
|
||||
*
|
||||
* Callers control sizing via Tailwind classes — pass `text-3xl` to size
|
||||
* the emoji and the SVG will scale to match (`h-[1em] w-auto`).
|
||||
*/
|
||||
export function CountryFlag({ code, emoji, label, className }: CountryFlagProps) {
|
||||
const customAsset = customFlagAsset(code);
|
||||
|
||||
if (customAsset) {
|
||||
return (
|
||||
// The wrapper span carries the font-size class so the inner image
|
||||
// can size itself in `em` units and stay in lockstep with the
|
||||
// emoji glyphs on neighbouring chips. A thin border + tiny radius
|
||||
// keeps the SVG reading as a *flag* and not a colored rectangle
|
||||
// when it's shrunk down inside a small chip.
|
||||
<span
|
||||
className={cn('inline-flex items-center leading-none', className)}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<img
|
||||
src={customAsset}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="inline-block h-[1em] w-auto rounded-[2px] align-middle ring-1 ring-black/10 dark:ring-white/15 shadow-sm"
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn('leading-none select-none', className)}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ImagePlus, Loader2, X } from 'lucide-react';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Template thumbnail row: each click sets the cover URL to that template's
|
||||
* URL. The thumbnail strip is optional — pass `templates` to enable it.
|
||||
*/
|
||||
export interface CoverImageTemplate {
|
||||
id: string;
|
||||
/** Sanitized https URL the picker will publish if this template is chosen. */
|
||||
url: string;
|
||||
/** Display name for `title` / `aria-label`. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CoverImageFieldProps {
|
||||
/** Current cover URL (controlled). Empty string means "no cover". */
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
/** Notifies parent forms so they can block submit while Blossom upload runs. */
|
||||
onUploadingChange?: (uploading: boolean) => void;
|
||||
/** Optional template gallery shown between the dropzone and the URL input. */
|
||||
templates?: readonly CoverImageTemplate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified cover-image affordance shared by CreateActionPage and
|
||||
* CreateCampaignPage. Includes:
|
||||
*
|
||||
* - A dashed dropzone (`<label>`) that accepts both click-to-open and
|
||||
* native drag-and-drop. Both paths funnel through the same MIME check
|
||||
* and `useUploadFile` upload.
|
||||
* - An optional template gallery — clicking a thumbnail just sets the
|
||||
* controlled `value`, so the URL input and dropzone preview both
|
||||
* update from a single source of truth.
|
||||
* - A plain URL `<Input>` so users can paste any https:// image.
|
||||
*
|
||||
* The dropzone preview goes through `sanitizeUrl()`, which rejects
|
||||
* anything other than a well-formed https URL — that's deliberate, since
|
||||
* the same value is what gets published in the Nostr event's `image` tag.
|
||||
*/
|
||||
export function CoverImageField({ value, onChange, onUploadingChange, templates }: CoverImageFieldProps) {
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { toast } = useToast();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const sanitized = sanitizeUrl(value);
|
||||
|
||||
useEffect(() => {
|
||||
onUploadingChange?.(isUploading);
|
||||
}, [isUploading, onUploadingChange]);
|
||||
|
||||
/**
|
||||
* Shared upload path used by both the file-input change handler and
|
||||
* the drag-and-drop handler. Validates the MIME type up front so a
|
||||
* stray dragged-in PDF or video doesn't end up posted to Blossom.
|
||||
*/
|
||||
const uploadCoverFile = async (file: File) => {
|
||||
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) {
|
||||
toast({
|
||||
title: 'Unsupported file type',
|
||||
description: 'Cover image must be PNG, JPG, or WEBP.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [[, url]] = await uploadFile(file);
|
||||
onChange(url);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
// Without preventDefault, the browser navigates to the dropped file
|
||||
// instead of letting our onDrop handler claim it.
|
||||
e.preventDefault();
|
||||
if (isUploading) return;
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
if (!isDragging) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
// Only clear the highlight when the cursor actually leaves the label.
|
||||
// Dragging over a child element fires dragleave on the parent in some
|
||||
// browsers, so we re-check relatedTarget.
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (isUploading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadCoverFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'relative block h-40 w-full cursor-pointer overflow-hidden rounded-xl border-2 border-dashed border-border bg-gradient-to-br from-muted/40 via-background to-muted/20 motion-safe:transition-colors hover:border-primary sm:h-48',
|
||||
isDragging && 'border-primary bg-primary/5',
|
||||
isUploading && 'opacity-70 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{sanitized ? (
|
||||
<>
|
||||
<img
|
||||
src={sanitized}
|
||||
alt=""
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChange('');
|
||||
}}
|
||||
className="absolute top-3 right-3 rounded-full bg-background/85 backdrop-blur p-1.5 hover:bg-background motion-safe:transition-colors"
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="size-8 animate-spin" />
|
||||
<span className="text-sm">Uploading…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ImagePlus className="size-8" />
|
||||
<span className="text-sm">Click or drag an image here</span>
|
||||
<span className="text-xs">PNG, JPG, or WEBP</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="sr-only"
|
||||
disabled={isUploading}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.currentTarget.value = '';
|
||||
if (file) void uploadCoverFile(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{templates && templates.length > 0 && (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1">
|
||||
{templates.map((template) => {
|
||||
const isActive = value === template.url;
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => onChange(template.url)}
|
||||
className={cn(
|
||||
'relative h-20 w-28 flex-shrink-0 rounded-md overflow-hidden border-2 transition-all',
|
||||
isActive
|
||||
? 'border-primary ring-2 ring-primary/50'
|
||||
: 'border-border hover:border-primary/50',
|
||||
)}
|
||||
title={template.name}
|
||||
aria-label={`Use ${template.name} cover`}
|
||||
>
|
||||
<img
|
||||
src={template.url}
|
||||
alt={template.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="Or paste an https:// image URL"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Camera, Check, ChevronRight, Clock, Info, Loader2, Megaphone, Palette, Plus, Upload } from 'lucide-react';
|
||||
import { Check, ChevronRight, Clock, Loader2, Megaphone, Plus, Upload } from 'lucide-react';
|
||||
|
||||
import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,17 +10,19 @@ import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } f
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import type { Action } from '@/hooks/useActions';
|
||||
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
|
||||
import { countryCodeToFlag, getAllCountries, getGeoDisplayName } from '@/lib/countries';
|
||||
import { DEFAULT_ACTION_COVERS, DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
||||
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
||||
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
|
||||
import { unixSecondsInTimezone } from '@/lib/timezone';
|
||||
import { usdToSats } from '@/lib/bitcoin';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CreateActionDialogProps {
|
||||
@@ -33,10 +35,8 @@ interface CreateActionDialogProps {
|
||||
interface CreateActionFormState {
|
||||
title: string;
|
||||
description: string;
|
||||
type: Action['type'];
|
||||
bounty: string;
|
||||
startDate: string;
|
||||
startTime: string;
|
||||
tagInput: string;
|
||||
pledgeUsd: string;
|
||||
deadline: string;
|
||||
time: string;
|
||||
coverImage: string;
|
||||
@@ -44,31 +44,20 @@ interface CreateActionFormState {
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
function unixSecondsInTimezone(year: number, month: number, day: number, hours: number, minutes: number, timezone: string): number {
|
||||
const utcGuess = Date.UTC(year, month - 1, day, hours, minutes, 0);
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = Object.fromEntries(
|
||||
formatter.formatToParts(new Date(utcGuess)).map((p) => [p.type, p.value]),
|
||||
);
|
||||
const asWallClock = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second),
|
||||
);
|
||||
return Math.floor((utcGuess + (utcGuess - asWallClock)) / 1000);
|
||||
function normalizePledgeTag(value: string): string {
|
||||
return value.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
function parseCommunityAuthor(communityATag: string): string | undefined {
|
||||
const [, pubkey] = communityATag.split(':');
|
||||
return pubkey || undefined;
|
||||
function parsePledgeTagInput(value: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
for (const part of value.split(',')) {
|
||||
const tag = normalizePledgeTag(part);
|
||||
if (!tag || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
tags.push(tag);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function CreateActionForm({
|
||||
@@ -87,12 +76,9 @@ function CreateActionForm({
|
||||
pageCountryCode?: string;
|
||||
}) {
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const allCountries = useMemo(() => getAllCountries(), []);
|
||||
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
|
||||
const [selectedDefaultId, setSelectedDefaultId] = useState<string | null>(() => {
|
||||
const match = DEFAULT_ACTION_COVERS.find((c) => c.url === formData.coverImage);
|
||||
return match?.id ?? null;
|
||||
});
|
||||
|
||||
const countryOptions = useMemo(() => {
|
||||
const options: Array<{ value: string; label: string; flag: string }> = [
|
||||
@@ -119,7 +105,6 @@ function CreateActionForm({
|
||||
try {
|
||||
const [[, url]] = await uploadFile(file);
|
||||
setFormData({ ...formData, coverImage: url });
|
||||
setSelectedDefaultId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload cover image:', error);
|
||||
}
|
||||
@@ -177,28 +162,6 @@ function CreateActionForm({
|
||||
<div className="relative w-full h-32 rounded-lg overflow-hidden border border-border">
|
||||
<img src={formData.coverImage || DEFAULT_COVER_IMAGE} alt="Cover preview" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1">
|
||||
{DEFAULT_ACTION_COVERS.map((cover) => {
|
||||
const isActive = selectedDefaultId === cover.id || formData.coverImage === cover.url;
|
||||
return (
|
||||
<button
|
||||
key={cover.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormData({ ...formData, coverImage: cover.url });
|
||||
setSelectedDefaultId(cover.id);
|
||||
}}
|
||||
className={cn('relative h-20 w-28 flex-shrink-0 rounded-md overflow-hidden border-2 transition-all', isActive ? 'border-primary ring-2 ring-primary/50' : 'border-border hover:border-primary/50')}
|
||||
title={cover.name}
|
||||
aria-label={`Select ${cover.name} cover`}
|
||||
>
|
||||
<img src={cover.url} alt={cover.name} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="cover-upload" className="flex-1 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 border border-border rounded-lg hover:bg-primary/10 transition-colors">
|
||||
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
@@ -217,7 +180,7 @@ function CreateActionForm({
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Explain what submissions should look like, why this matters, and how the bounty will be paid out..."
|
||||
placeholder="Explain the action, evidence, or outcome you want to inspire and what submissions should include..."
|
||||
className="min-h-[80px]"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
@@ -226,55 +189,49 @@ function CreateActionForm({
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select value={formData.type} onValueChange={(value) => setFormData({ ...formData, type: value as Action['type'] })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="photo"><div className="flex items-center gap-2"><Camera className="h-4 w-4" /> Photo</div></SelectItem>
|
||||
<SelectItem value="art"><div className="flex items-center gap-2"><Palette className="h-4 w-4" /> Art</div></SelectItem>
|
||||
<SelectItem value="info"><div className="flex items-center gap-2"><Info className="h-4 w-4" /> Info</div></SelectItem>
|
||||
<SelectItem value="action"><div className="flex items-center gap-2"><Megaphone className="h-4 w-4" /> Action</div></SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label htmlFor="pledge-tags">Tags</Label>
|
||||
<Input id="pledge-tags" placeholder="beach-cleanup, legal-defense" value={formData.tagInput} onChange={(e) => setFormData({ ...formData, tagInput: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bounty">Bounty (sats)</Label>
|
||||
<Input id="bounty" type="number" placeholder="10000" value={formData.bounty} onChange={(e) => setFormData({ ...formData, bounty: e.target.value })} />
|
||||
<Label htmlFor="pledgeUsd">Pledge</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="pledgeUsd"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="100"
|
||||
value={formData.pledgeUsd}
|
||||
onChange={(e) => setFormData({ ...formData, pledgeUsd: e.target.value })}
|
||||
className="pl-7 pr-14"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
|
||||
USD
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startDate">Start date (optional)</Label>
|
||||
<Input id="startDate" type="date" className="w-full min-w-0" value={formData.startDate} onChange={(e) => setFormData({ ...formData, startDate: e.target.value })} />
|
||||
{formData.startDate && <Input id="startTime" type="time" className="w-full min-w-0" value={formData.startTime} onChange={(e) => setFormData({ ...formData, startTime: e.target.value })} />}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{!formData.startDate && 'Defaults to now if not specified'}
|
||||
{formData.startDate && !formData.startTime && ' • Starts at midnight'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deadline">Deadline (optional)</Label>
|
||||
<Input id="deadline" type="date" className="w-full min-w-0" value={formData.deadline} onChange={(e) => setFormData({ ...formData, deadline: e.target.value })} />
|
||||
{formData.deadline && <Input id="time" type="time" className="w-full min-w-0" value={formData.time} onChange={(e) => setFormData({ ...formData, time: e.target.value })} />}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{!formData.deadline && 'Defaults to 48 hours after start'}
|
||||
{formData.deadline && !formData.time && ' • Ends at 23:59 local time'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(formData.startDate || formData.deadline) && (
|
||||
{formData.deadline && (
|
||||
<div className="space-y-2 bg-muted/30 p-3 rounded-lg border border-border/50 animate-in slide-in-from-top-2 duration-200">
|
||||
<Label className="text-sm font-medium flex items-center gap-2"><Clock className="h-4 w-4" /> Timezone</Label>
|
||||
<TimezoneSwitcher value={formData.timezone} onChange={(timezone) => setFormData({ ...formData, timezone })} />
|
||||
<p className="text-xs text-muted-foreground">Start and deadline times will be interpreted in this timezone.</p>
|
||||
<p className="text-xs text-muted-foreground">Deadline time will be interpreted in this timezone.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-4 pt-2">
|
||||
<Button onClick={handleSubmit} disabled={!formData.title || !formData.description || !formData.bounty || isSubmitting} className="gap-2 w-full">
|
||||
<Button onClick={handleSubmit} disabled={!formData.title || !formData.description || !formData.pledgeUsd || usdToSats(Number(formData.pledgeUsd.replace(/[, $]/g, '')), btcPrice) <= 0 || isSubmitting} className="gap-2 w-full">
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
Create action
|
||||
Create pledge
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCancel} className="w-full">Cancel</Button>
|
||||
</div>
|
||||
@@ -286,6 +243,7 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useIsMobile();
|
||||
const { toast } = useToast();
|
||||
@@ -294,10 +252,8 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha
|
||||
const [formData, setFormData] = useState<CreateActionFormState>({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'photo',
|
||||
bounty: '',
|
||||
startDate: '',
|
||||
startTime: '',
|
||||
tagInput: '',
|
||||
pledgeUsd: '',
|
||||
deadline: '',
|
||||
time: '',
|
||||
coverImage: DEFAULT_COVER_IMAGE,
|
||||
@@ -311,28 +267,24 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha
|
||||
try {
|
||||
const now = Date.now();
|
||||
const slug = formData.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
const dTag = `${slug || 'action'}-${now}`;
|
||||
const dTag = `${slug || 'pledge'}-${now}`;
|
||||
const pledgeSats = usdToSats(Number(formData.pledgeUsd.replace(/[, $]/g, '')), btcPrice);
|
||||
if (pledgeSats <= 0) throw new Error('Waiting for BTC/USD price to calculate the pledge amount.');
|
||||
const pledgeTags = parsePledgeTagInput(formData.tagInput);
|
||||
const tags: string[][] = [
|
||||
['d', dTag],
|
||||
['title', formData.title],
|
||||
['challenge-type', formData.type],
|
||||
['bounty', formData.bounty],
|
||||
['bounty', String(pledgeSats)],
|
||||
['t', 'agora-action'],
|
||||
['alt', `Agora activist action: ${formData.title}`],
|
||||
['alt', `Agora pledge: ${formData.title}`],
|
||||
];
|
||||
for (const tag of pledgeTags) tags.push(['t', tag]);
|
||||
if (formData.selectedCountry) tags.push(['i', createCountryIdentifier(formData.selectedCountry.toUpperCase())]);
|
||||
if (communityATag) {
|
||||
const communityAuthor = parseCommunityAuthor(communityATag);
|
||||
tags.push(['A', communityATag], ['K', '34550']);
|
||||
if (communityAuthor) tags.push(['P', communityAuthor]);
|
||||
tags.push(...createOrganizationAssociationTags(communityATag));
|
||||
}
|
||||
if (formData.coverImage) tags.push(['image', formData.coverImage]);
|
||||
|
||||
if (formData.startDate) {
|
||||
const [year, month, day] = formData.startDate.split('-').map(Number);
|
||||
const [hours, minutes] = formData.startTime ? formData.startTime.split(':').map(Number) : [0, 0];
|
||||
tags.push(['start', String(unixSecondsInTimezone(year, month, day, hours, minutes, formData.timezone))]);
|
||||
}
|
||||
if (formData.deadline) {
|
||||
const [year, month, day] = formData.deadline.split('-').map(Number);
|
||||
const [hours, minutes] = formData.time ? formData.time.split(':').map(Number) : [23, 59];
|
||||
@@ -358,17 +310,17 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha
|
||||
}
|
||||
|
||||
setFormData({
|
||||
title: '', description: '', type: 'photo', bounty: '',
|
||||
startDate: '', startTime: '', deadline: '', time: '',
|
||||
title: '', description: '', tagInput: '', pledgeUsd: '',
|
||||
deadline: '', time: '',
|
||||
coverImage: DEFAULT_COVER_IMAGE,
|
||||
selectedCountry: countryCode || '',
|
||||
timezone: browserTimezone,
|
||||
});
|
||||
onOpenChange(false);
|
||||
toast({ title: 'Action created' });
|
||||
toast({ title: 'Pledge created' });
|
||||
} catch (error) {
|
||||
console.error('Failed to create action:', error);
|
||||
toast({ title: 'Failed to create action', variant: 'destructive' });
|
||||
console.error('Failed to create pledge:', error);
|
||||
toast({ title: 'Failed to create pledge', variant: 'destructive' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -377,17 +329,17 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha
|
||||
if (!user) return null;
|
||||
|
||||
const description = communityATag
|
||||
? 'New community action. You can optionally choose a country below.'
|
||||
? 'New organization pledge. You can optionally choose a country below.'
|
||||
: countryCode
|
||||
? `New action for ${getGeoDisplayName(countryCode)}.`
|
||||
: 'New action. You can optionally choose a country below.';
|
||||
? `New pledge for ${getGeoDisplayName(countryCode)}.`
|
||||
: 'New pledge. You can optionally choose a country below.';
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="h-[85dvh] max-h-[85dvh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create action</DrawerTitle>
|
||||
<DrawerTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DrawerTitle>
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto flex-1 pb-safe">
|
||||
@@ -402,7 +354,7 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md sm:max-w-lg md:max-w-2xl max-h-[85vh] w-[calc(100vw-2rem)] sm:w-full overflow-hidden flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create action</DialogTitle>
|
||||
<DialogTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto overflow-x-hidden flex-1 min-h-0">
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users, Loader2 } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ImageUploadField } from '@/components/ImageUploadField';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Convert text into a URL-safe slug for the d-tag identifier. */
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CreateCommunityDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Existing community event when editing. Omit to create a new community. */
|
||||
communityEvent?: NostrEvent;
|
||||
/** Parsed existing community data when editing. */
|
||||
community?: ParsedCommunity;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CreateCommunityDialog({ open, onOpenChange, communityEvent, community }: CreateCommunityDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!communityEvent && !!community;
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||
|
||||
// Mutations
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
// Derived
|
||||
const effectiveSlug = isEditing && community ? community.dTag : slugify(name);
|
||||
|
||||
const populateFromCommunity = useCallback(() => {
|
||||
setName(community?.name ?? '');
|
||||
setDescription(community?.description ?? '');
|
||||
setImageUrl(community?.image ?? '');
|
||||
setIsPublishing(false);
|
||||
setIsImageUploading(false);
|
||||
}, [community]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
if (isEditing) {
|
||||
populateFromCommunity();
|
||||
} else {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setImageUrl('');
|
||||
setIsPublishing(false);
|
||||
setIsImageUploading(false);
|
||||
}
|
||||
}, [isEditing, populateFromCommunity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && isEditing) {
|
||||
populateFromCommunity();
|
||||
}
|
||||
}, [open, isEditing, populateFromCommunity]);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
if (!nextOpen) resetForm();
|
||||
onOpenChange(nextOpen);
|
||||
}, [onOpenChange, resetForm]);
|
||||
|
||||
const buildUpdatedCommunityTags = useCallback((baseTags: string[][]): string[][] => {
|
||||
const tags = baseTags.filter(([name]) => !['d', 'name', 'description', 'image', 'alt'].includes(name));
|
||||
const nextTags: string[][] = [
|
||||
['d', effectiveSlug],
|
||||
['name', name.trim()],
|
||||
];
|
||||
|
||||
if (description.trim()) {
|
||||
nextTags.push(['description', description.trim()]);
|
||||
}
|
||||
|
||||
const sanitizedImage = sanitizeUrl(imageUrl.trim());
|
||||
if (sanitizedImage) {
|
||||
nextTags.push(['image', sanitizedImage]);
|
||||
}
|
||||
|
||||
nextTags.push(...tags);
|
||||
nextTags.push(['alt', `Community: ${name.trim()}`]);
|
||||
|
||||
return nextTags;
|
||||
}, [description, effectiveSlug, imageUrl, name]);
|
||||
|
||||
// ── Publish ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!user || !name.trim() || !effectiveSlug) return;
|
||||
if (isImageUploading) {
|
||||
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
|
||||
return;
|
||||
}
|
||||
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
|
||||
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
if (isEditing && communityEvent && community) {
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [COMMUNITY_DEFINITION_KIND],
|
||||
authors: [communityEvent.pubkey],
|
||||
'#d': [community.dTag],
|
||||
});
|
||||
|
||||
const updatedEvent = await publishEvent({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
content: prev?.content ?? communityEvent.content,
|
||||
tags: buildUpdatedCommunityTags(prev?.tags ?? communityEvent.tags),
|
||||
prev: prev ?? undefined,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
|
||||
|
||||
queryClient.setQueryData(
|
||||
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
|
||||
updatedEvent,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
|
||||
|
||||
toast({ title: 'Community updated!' });
|
||||
handleOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for d-tag collision (same author, same kind, same d-tag)
|
||||
const existing = await nostr.query([{
|
||||
kinds: [COMMUNITY_DEFINITION_KIND],
|
||||
authors: [user.pubkey],
|
||||
'#d': [effectiveSlug],
|
||||
limit: 1,
|
||||
}]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
toast({
|
||||
title: 'Name already in use',
|
||||
description: 'You already have a community with this name. Please choose a different name.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsPublishing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeDTag = `${effectiveSlug}-member`;
|
||||
const existingBadge = await nostr.query([{
|
||||
kinds: [BADGE_DEFINITION_KIND],
|
||||
authors: [user.pubkey],
|
||||
'#d': [badgeDTag],
|
||||
limit: 1,
|
||||
}]);
|
||||
|
||||
if (existingBadge.length > 0) {
|
||||
toast({
|
||||
title: 'Member badge ID already in use',
|
||||
description: 'Choose a different community name so the member badge can be created safely.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsPublishing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeEvent = await publishEvent({
|
||||
kind: BADGE_DEFINITION_KIND,
|
||||
content: '',
|
||||
tags: [
|
||||
['d', badgeDTag],
|
||||
['name', 'Member'],
|
||||
['description', `Member of ${name.trim()}`],
|
||||
['alt', `Badge definition: Member of ${name.trim()}`],
|
||||
],
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
|
||||
// Founder as moderator (p tag) plus one flat member badge reference.
|
||||
const communityTags = buildUpdatedCommunityTags([
|
||||
['a', `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`, '', 'member'],
|
||||
['p', user.pubkey, '', 'moderator'],
|
||||
]);
|
||||
|
||||
// Publish community definition (kind 34550)
|
||||
const createdEvent = await publishEvent({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
content: '',
|
||||
tags: communityTags,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
|
||||
// Navigate to the new community
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
pubkey: createdEvent.pubkey,
|
||||
identifier: effectiveSlug,
|
||||
});
|
||||
|
||||
toast({ title: 'Community created!' });
|
||||
handleOpenChange(false);
|
||||
navigate(`/${naddr}`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: isEditing ? 'Failed to update community' : 'Failed to create community',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
}, [
|
||||
user, name, effectiveSlug, isEditing, communityEvent, community, nostr, isImageUploading, imageUrl,
|
||||
publishEvent, buildUpdatedCommunityTags, queryClient, toast, handleOpenChange, navigate,
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
|
||||
<DialogHeader className="px-5 pt-5 pb-3">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Users className="size-5 text-primary" />
|
||||
{isEditing ? 'Edit Community' : 'Create a Community'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? 'Update the name, image, and description. Moderators are preserved.'
|
||||
: "Start a new community on Nostr. You'll be the founder."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[calc(100vh-9rem)] sm:max-h-none">
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
{/* Community name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-name">Community Name *</Label>
|
||||
<Input
|
||||
id="community-name"
|
||||
placeholder="e.g. The Arbiter's Guard"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
{name.trim() && (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
ID: {effectiveSlug || '...'}{isEditing ? ' (unchanged)' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImageUploadField
|
||||
id="community-image"
|
||||
label={<>Community Image <span className="text-muted-foreground font-normal">(recommended)</span></>}
|
||||
value={imageUrl}
|
||||
onChange={setImageUrl}
|
||||
onUploadingChange={setIsImageUploading}
|
||||
previewAlt="Community image preview"
|
||||
dropAreaClassName="min-h-32"
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="community-description">
|
||||
Description
|
||||
<span className="text-muted-foreground font-normal ml-1">(recommended)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="community-description"
|
||||
placeholder="What is this community about?"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || !effectiveSlug || isPublishing || isImageUploading}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="size-4 animate-spin" /> {isEditing ? 'Saving...' : 'Creating...'}</>
|
||||
) : (
|
||||
<><Users className="size-4" /> {isEditing ? 'Save Changes' : 'Create Community'}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
interface CreateCommunityEventDialogProps {
|
||||
@@ -80,11 +81,6 @@ function toLocalTimestamp(date: string, time: string): number {
|
||||
return Math.floor(new Date(`${date}T${time}:00`).getTime() / 1000);
|
||||
}
|
||||
|
||||
function parseCommunityAuthor(communityATag: string): string | undefined {
|
||||
const [, pubkey] = communityATag.split(':');
|
||||
return pubkey || undefined;
|
||||
}
|
||||
|
||||
export function CreateCommunityEventDialog({ communityATag, open, onOpenChange, event }: CreateCommunityEventDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
@@ -243,16 +239,12 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
|
||||
const tags: string[][] = [
|
||||
['d', dTag],
|
||||
['title', trimmedTitle],
|
||||
['alt', `${isCommunityEvent ? 'Community event' : 'Calendar event'}: ${trimmedTitle}`],
|
||||
['alt', `${isCommunityEvent ? 'Organization event' : 'Calendar event'}: ${trimmedTitle}`],
|
||||
...preservedTags,
|
||||
];
|
||||
|
||||
if (effectiveCommunityATag) {
|
||||
const communityAuthor = parseCommunityAuthor(effectiveCommunityATag);
|
||||
tags.push(['A', effectiveCommunityATag], ['K', '34550']);
|
||||
if (communityAuthor) {
|
||||
tags.push(['P', communityAuthor]);
|
||||
}
|
||||
tags.push(...createOrganizationAssociationTags(effectiveCommunityATag));
|
||||
}
|
||||
|
||||
if (description.trim()) {
|
||||
@@ -339,6 +331,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
|
||||
queryClient.invalidateQueries({ queryKey: ['addr-event', kind, publishedEvent.pubkey, dTag] }),
|
||||
...(effectiveCommunityATag ? [
|
||||
queryClient.invalidateQueries({ queryKey: ['community-events', effectiveCommunityATag] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['organization-activity', effectiveCommunityATag] }),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const [root, aTagsKey] = q.queryKey;
|
||||
|
||||
@@ -165,7 +165,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
|
||||
<Label htmlFor="goal-title">Title</Label>
|
||||
<Input
|
||||
id="goal-title"
|
||||
placeholder="e.g. Community meetup expenses"
|
||||
placeholder="e.g. Organization meetup expenses"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { type ReactNode, useCallback, useMemo } from "react";
|
||||
import { DMProvider } from "@samthomson/nostr-messaging/core";
|
||||
import { DEFAULT_NEW_MESSAGE_SOUNDS } from "@samthomson/nostr-messaging/core";
|
||||
import type { NostrEvent } from "@nostrify/nostrify";
|
||||
import { useNostr } from "@nostrify/react";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useNostrPublish } from "@/hooks/useNostrPublish";
|
||||
import { useUploadFile } from "@/hooks/useUploadFile";
|
||||
import { useProfileSupplementary } from "@/hooks/useProfileData";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { getDisplayName } from "@/lib/getDisplayName";
|
||||
import { getEffectiveRelays } from "@/lib/appRelays";
|
||||
import { useAuthors } from "@/hooks/useAuthors";
|
||||
|
||||
interface DMProviderWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DMProviderWrapper({ children }: DMProviderWrapperProps) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { mutateAsync: uploadFileMutation } = useUploadFile();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const { data: profileData } = useProfileSupplementary(user?.pubkey);
|
||||
const follows = useMemo(() => profileData?.following ?? [], [profileData]);
|
||||
|
||||
const handlePublishEvent = useCallback(async (
|
||||
event: Omit<NostrEvent, "id" | "pubkey" | "sig">,
|
||||
): Promise<void> => {
|
||||
await publishEvent(event);
|
||||
}, [publishEvent]);
|
||||
|
||||
const handleUploadFile = useCallback(async (file: File): Promise<string> => {
|
||||
const tags = await uploadFileMutation(file);
|
||||
return tags[0][1] ?? "";
|
||||
}, [uploadFileMutation]);
|
||||
|
||||
const handleGetDisplayName = useCallback((
|
||||
pubkey: string,
|
||||
metadata?: Parameters<typeof getDisplayName>[0],
|
||||
) => {
|
||||
return getDisplayName(metadata, pubkey);
|
||||
}, []);
|
||||
|
||||
const handleNotify = useCallback((options: { title?: string; description?: string; variant?: "default" | "destructive" }) => {
|
||||
toast(options);
|
||||
}, []);
|
||||
|
||||
const messaging = useMemo(() => config.messaging ?? {}, [config.messaging]);
|
||||
|
||||
const discoveryRelays = useMemo(() => {
|
||||
if (messaging.discoveryRelays?.length) {
|
||||
return messaging.discoveryRelays;
|
||||
}
|
||||
|
||||
return getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays).relays
|
||||
.filter((relay) => relay.read)
|
||||
.map((relay) => relay.url);
|
||||
}, [messaging.discoveryRelays, config.relayMetadata, config.useAppRelays, config.useUserRelays]);
|
||||
|
||||
const relayMode = messaging.relayMode ?? "hybrid";
|
||||
const protocolMode = messaging.protocolMode;
|
||||
const messagingEnabled = messaging.enabled ?? true;
|
||||
const renderInlineMedia = messaging.renderInlineMedia ?? true;
|
||||
const soundEnabled = messaging.soundEnabled ?? false;
|
||||
const soundId = messaging.soundId ?? DEFAULT_NEW_MESSAGE_SOUNDS[0]?.id ?? "";
|
||||
const devMode = messaging.devMode ?? false;
|
||||
|
||||
const messagingConfig = useMemo(() => ({
|
||||
enabled: messagingEnabled,
|
||||
discoveryRelays,
|
||||
relayMode,
|
||||
protocolMode,
|
||||
renderInlineMedia,
|
||||
devMode,
|
||||
appName: config.appName,
|
||||
appDescription: `Direct messages on ${config.appName}`,
|
||||
soundPref: {
|
||||
options: DEFAULT_NEW_MESSAGE_SOUNDS,
|
||||
value: { enabled: soundEnabled, soundId },
|
||||
onChange: () => {},
|
||||
},
|
||||
}), [
|
||||
messagingEnabled,
|
||||
discoveryRelays,
|
||||
relayMode,
|
||||
protocolMode,
|
||||
renderInlineMedia,
|
||||
devMode,
|
||||
config.appName,
|
||||
soundEnabled,
|
||||
soundId,
|
||||
]);
|
||||
|
||||
const uiConfig = useMemo(() => ({
|
||||
showShorts: false,
|
||||
showSearch: true,
|
||||
showHeader: false,
|
||||
isMobile,
|
||||
}), [isMobile]);
|
||||
|
||||
return (
|
||||
<DMProvider
|
||||
nostr={nostr}
|
||||
user={user ?? null}
|
||||
messagingConfig={messagingConfig}
|
||||
onNotify={handleNotify}
|
||||
getDisplayName={handleGetDisplayName}
|
||||
fetchAuthorsBatch={useAuthors}
|
||||
publishEvent={handlePublishEvent}
|
||||
uploadFile={handleUploadFile}
|
||||
follows={follows}
|
||||
ui={uiConfig}
|
||||
>
|
||||
{children}
|
||||
</DMProvider>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,8 @@ import { useWeather } from '@/hooks/useWeather';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { customFlagAsset, hasCustomFlag } from '@/lib/customFlags';
|
||||
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
|
||||
import { useCountryFacts, type CountryFacts } from '@/hooks/useCountryFacts';
|
||||
import { useCommonsAudio } from '@/hooks/useCommonsAudio';
|
||||
@@ -231,30 +233,39 @@ function BlueskyPostHeader({ author, rkey, url }: { author: string; rkey: string
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-5 mt-3 -ml-2">
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleComment}
|
||||
className="inline-flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-sky-500 hover:bg-sky-500/10 transition-colors"
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-sky-500 hover:bg-sky-500/10 transition-colors"
|
||||
title="Comment"
|
||||
>
|
||||
<MessageCircle className="size-[18px]" />
|
||||
{post.replyCount > 0 && <span className="text-sm tabular-nums">{formatCount(post.replyCount)}</span>}
|
||||
{post.replyCount > 0 ? (
|
||||
<span className="tabular-nums">{formatCount(post.replyCount)}</span>
|
||||
) : (
|
||||
<span className="hidden sm:inline">Comment</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRepost}
|
||||
className="inline-flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
|
||||
title="Share to feed"
|
||||
>
|
||||
<Repeat2 className="size-[18px]" />
|
||||
{post.repostCount > 0 && <span className="text-sm tabular-nums">{formatCount(post.repostCount)}</span>}
|
||||
{post.repostCount > 0 ? (
|
||||
<span className="tabular-nums">{formatCount(post.repostCount)}</span>
|
||||
) : (
|
||||
<span className="hidden sm:inline">Repost</span>
|
||||
)}
|
||||
</button>
|
||||
<ExternalReactionButton content={externalContent} iconSize="size-[18px]" count={post.likeCount} />
|
||||
<ExternalReactionButton content={externalContent} count={post.likeCount} variant="chip" />
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShare}
|
||||
className="inline-flex items-center p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Share link"
|
||||
>
|
||||
<Share2 className="size-[18px]" />
|
||||
@@ -899,15 +910,20 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const heroImage = wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null;
|
||||
// For codes with a bundled flag asset (Tibet's Snow Lion), drive the
|
||||
// hero banner from that SVG instead of Wikipedia's lead image. The
|
||||
// Wikipedia article for `Tibet (autonomous region)` typically returns a
|
||||
// map or administrative photo, which contradicts the editorial choice
|
||||
// to surface Tibet as a country in its own right.
|
||||
const heroImage = customFlagAsset(code) ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null;
|
||||
const isDay = weather?.isDay ?? true;
|
||||
// Sky-tint gradient layered above the hero photo. Warm amber/rose during
|
||||
// local daytime, deep indigo/violet at night. Same gradient shape, only
|
||||
// the colour palette flips — preserves the cinematic curve while the mood
|
||||
// follows the destination.
|
||||
const skyOverlay = isDay
|
||||
? 'bg-[linear-gradient(to_bottom,rgba(254,202,87,0.18)_0%,rgba(255,107,107,0.12)_30%,rgba(0,0,0,0.65)_70%,hsl(var(--background))_100%)]'
|
||||
: 'bg-[linear-gradient(to_bottom,rgba(30,27,75,0.55)_0%,rgba(15,23,42,0.55)_30%,rgba(0,0,0,0.85)_70%,hsl(var(--background))_100%)]';
|
||||
? 'bg-[linear-gradient(to_bottom,rgba(254,202,87,0.18)_0%,rgba(255,107,107,0.12)_30%,rgba(0,0,0,0.65)_70%,hsl(var(--card))_100%)]'
|
||||
: 'bg-[linear-gradient(to_bottom,rgba(30,27,75,0.55)_0%,rgba(15,23,42,0.55)_30%,rgba(0,0,0,0.85)_70%,hsl(var(--card))_100%)]';
|
||||
|
||||
// Whether to show the coat of arms inside the hero. Subdivisions get a
|
||||
// thumbnail in the flag slot already (from Wikipedia), so we skip the coat
|
||||
@@ -920,7 +936,7 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
// hero replaces the page header (it carries its own back arrow + follow
|
||||
// button overlaid on the photo), so no negative top margin is needed to
|
||||
// tuck under a sibling header band.
|
||||
<section className="relative isolate overflow-hidden mb-2">
|
||||
<section className="relative isolate overflow-hidden">
|
||||
{/* Hero — Wikipedia photo (or gradient fallback) with day/night sky
|
||||
overlay that fades into the page background. Aspect ratio scales
|
||||
from a compact 2:1 on phones to a cinematic 21:9 on tablets+. */}
|
||||
@@ -992,23 +1008,25 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
white text legible against any underlying photo. */}
|
||||
<div className="absolute bottom-0 left-0 right-0 px-5 pb-4 pt-10 [text-shadow:0_1px_4px_rgba(0,0,0,0.7),0_2px_8px_rgba(0,0,0,0.4)]">
|
||||
<div className="flex items-end gap-3">
|
||||
{/* Flag + (optional) coat of arms. Subdivisions show a small
|
||||
Wikipedia thumbnail in the same slot when available. */}
|
||||
{/* Flag + (optional) coat of arms. Subdivisions normally
|
||||
show a small Wikipedia thumbnail in the same slot when
|
||||
available; entries with a bundled custom flag asset
|
||||
(Tibet's Snow Lion) bypass that branch so our editorial
|
||||
flag wins. */}
|
||||
<div className="flex items-end gap-2 [text-shadow:none] shrink-0">
|
||||
{info.subdivision && wiki?.thumbnail ? (
|
||||
{info.subdivision && wiki?.thumbnail && !hasCustomFlag(code) ? (
|
||||
<img
|
||||
src={wiki.thumbnail.source}
|
||||
alt={info.subdivisionName ?? info.subdivision}
|
||||
className="size-14 sm:size-16 rounded-md object-cover shadow-lg border border-white/20"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-5xl sm:text-6xl leading-none drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]"
|
||||
role="img"
|
||||
aria-label={`Flag of ${info.name}`}
|
||||
>
|
||||
{info.flag}
|
||||
</span>
|
||||
<CountryFlag
|
||||
code={code}
|
||||
emoji={info.flag}
|
||||
label={`Flag of ${info.subdivisionName ?? info.name}`}
|
||||
className="text-5xl sm:text-6xl drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]"
|
||||
/>
|
||||
)}
|
||||
{showCoatOfArms && (
|
||||
<img
|
||||
@@ -1030,7 +1048,7 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
<AnthemButton filename={facts.anthemFilename} title={facts.anthemTitle} />
|
||||
)}
|
||||
</div>
|
||||
{info.subdivision ? (
|
||||
{info.subdivision && !hasCustomFlag(code) ? (
|
||||
<p className="text-sm text-white/85 mt-0.5 truncate">
|
||||
{info.name}{info.subdivisionName ? '' : ` · ${info.subdivision}`}
|
||||
</p>
|
||||
@@ -1236,14 +1254,24 @@ function BookPreview({ isbn, link }: { isbn: string; link: string }) {
|
||||
function CountryPreview({ code, link }: { code: string; link: string }) {
|
||||
const info = getCountryInfo(code);
|
||||
|
||||
// For ISO 3166-2 codes we treat editorially as countries (Tibet today),
|
||||
// prefer the subdivision's own name and let `CountryFlag` swap in the
|
||||
// bundled Snow Lion SVG instead of the parent-country emoji.
|
||||
const displayName = hasCustomFlag(code)
|
||||
? info?.subdivisionName ?? info?.name ?? code
|
||||
: info?.name ?? code;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<span className="text-2xl leading-none shrink-0" role="img" aria-label={info ? `Flag of ${info.name}` : code}>
|
||||
{info?.flag ?? '🌍'}
|
||||
</span>
|
||||
<CountryFlag
|
||||
code={code}
|
||||
emoji={info?.flag ?? '🌍'}
|
||||
label={`Flag of ${displayName}`}
|
||||
className="text-2xl shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
@@ -1251,7 +1279,7 @@ function CountryPreview({ code, link }: { code: string; link: string }) {
|
||||
<span>Country</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate mt-0.5">
|
||||
{info?.name ?? code}
|
||||
{displayName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1275,7 +1303,7 @@ export function CommunityPreview({ addr }: { addr: { kind: number; pubkey: strin
|
||||
|
||||
const communityName = event?.tags.find(([n]) => n === 'name')?.[1]
|
||||
|| event?.tags.find(([n]) => n === 'd')?.[1]
|
||||
|| 'Community';
|
||||
|| 'Organization';
|
||||
const communityImage = event?.tags.find(([n]) => n === 'image')?.[1];
|
||||
const communityDescription = event?.tags.find(([n]) => n === 'description')?.[1];
|
||||
const moderatorCount = event?.tags.filter(([n, , , role]) => n === 'p' && role === 'moderator').length ?? 0;
|
||||
@@ -1319,7 +1347,7 @@ export function CommunityPreview({ addr }: { addr: { kind: number; pubkey: strin
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="size-3 shrink-0" />
|
||||
<span>Community</span>
|
||||
<span>Organization</span>
|
||||
{moderatorCount > 0 && (
|
||||
<span className="text-muted-foreground/60">· {moderatorCount} mod{moderatorCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
@@ -1433,7 +1461,7 @@ const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
|
||||
3063: 'Zapstore Asset',
|
||||
15128: 'Nsite',
|
||||
35128: 'Nsite',
|
||||
36639: 'Action',
|
||||
36639: 'Pledge',
|
||||
};
|
||||
|
||||
export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { parseExternalUri, headerLabel } from '@/lib/externalContent';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { useBookInfo } from '@/hooks/useBookInfo';
|
||||
|
||||
@@ -31,7 +32,14 @@ function ExternalSidebarIcon({ id }: { id: string }) {
|
||||
if (content.type === 'iso3166') {
|
||||
const info = getCountryInfo(content.code);
|
||||
if (info?.flag) {
|
||||
return <span className="text-lg leading-none shrink-0">{info.flag}</span>;
|
||||
return (
|
||||
<CountryFlag
|
||||
code={content.code}
|
||||
emoji={info.flag}
|
||||
label={info.subdivisionName ?? info.name}
|
||||
className="text-lg shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,13 @@ interface ExternalReactionButtonProps {
|
||||
count?: number;
|
||||
/** Extra class names on the trigger button. */
|
||||
className?: string;
|
||||
/**
|
||||
* Visual variant.
|
||||
* - `pill` (default): compact icon-pill matching the legacy action bar.
|
||||
* - `chip`: rounded chip with a label fallback when there's no count,
|
||||
* matching the GoFundMe-style PostActionBar / NoteCard action row.
|
||||
*/
|
||||
variant?: 'pill' | 'chip';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +58,7 @@ interface ExternalReactionButtonProps {
|
||||
* Includes hover-to-open emoji picker via `QuickReactMenu`, optimistic UI,
|
||||
* and displays the user's existing reaction & total count.
|
||||
*/
|
||||
export function ExternalReactionButton({ content, iconSize = 'size-5', count, className }: ExternalReactionButtonProps) {
|
||||
export function ExternalReactionButton({ content, iconSize = 'size-5', count, className, variant = 'pill' }: ExternalReactionButtonProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutate: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -132,7 +139,10 @@ export function ExternalReactionButton({ content, iconSize = 'size-5', count, cl
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 p-2 rounded-full transition-colors',
|
||||
'transition-colors',
|
||||
variant === 'chip'
|
||||
? 'inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium'
|
||||
: 'flex items-center gap-1.5 p-2 rounded-full',
|
||||
hasReacted
|
||||
? 'text-pink-500'
|
||||
: 'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
|
||||
@@ -155,9 +165,13 @@ export function ExternalReactionButton({ content, iconSize = 'size-5', count, cl
|
||||
) : (
|
||||
<Heart className={iconSize} />
|
||||
)}
|
||||
{(count ?? reactionCount) > 0 && (
|
||||
<span className="text-sm tabular-nums">{formatNumber(count ?? reactionCount)}</span>
|
||||
)}
|
||||
{(count ?? reactionCount) > 0 ? (
|
||||
<span className={cn('tabular-nums', variant === 'chip' ? '' : 'text-sm')}>
|
||||
{formatNumber(count ?? reactionCount)}
|
||||
</span>
|
||||
) : variant === 'chip' ? (
|
||||
<span className="hidden sm:inline">React</span>
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
+120
-342
@@ -1,36 +1,31 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { usePageRefresh } from '@/hooks/usePageRefresh';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroGlobe } from '@/components/HeroGlobe';
|
||||
import { LandingHero } from '@/components/LandingHero';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Globe2, Loader2, Users } from 'lucide-react';
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import AuthDialog from '@/components/auth/AuthDialog';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useFollowingFeed } from '@/hooks/useFollowingFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedTab } from '@/hooks/useFeedTab';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useTabFeed } from '@/hooks/useProfileFeed';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
|
||||
import { useWorldFeed } from '@/hooks/useWorldFeed';
|
||||
import { useAgoraFeed } from '@/hooks/useAgoraFeed';
|
||||
import { shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNavHidden } from '@/contexts/LayoutContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
import type { SavedFeed } from '@/contexts/AppContext';
|
||||
|
||||
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world';
|
||||
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world' | 'agora';
|
||||
type FeedTab = CoreFeedTab | string; // string = saved feed id
|
||||
|
||||
interface FeedProps {
|
||||
@@ -48,69 +43,59 @@ interface FeedProps {
|
||||
feedId?: string;
|
||||
}
|
||||
|
||||
const FEED_BACKDROP_HUE_INTERVAL_MS = 45_000;
|
||||
const FEED_BACKDROP_HUE_FADE_MS = 18_000;
|
||||
const AGORA_DEFAULT_NOTE_TAGS = [['t', 'agora']];
|
||||
|
||||
function FeedGlobeBackground() {
|
||||
const [hueIndex, setHueIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
|
||||
}, FEED_BACKDROP_HUE_INTERVAL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const activeHue = HOPE_PALETTE[hueIndex];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 pointer-events-none overflow-hidden bg-secondary/30" aria-hidden="true">
|
||||
<HeroAtmosphere hue={activeHue} fadeMs={FEED_BACKDROP_HUE_FADE_MS} className="opacity-55" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background/10 via-background/20 to-background/55" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<HeroGlobe
|
||||
hue={activeHue}
|
||||
className="aspect-square max-w-none opacity-70 drop-shadow-2xl"
|
||||
style={{ width: 'clamp(552px, 86.4dvw, 984px)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-background/70" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, feedId = 'home' }: FeedProps = {}) {
|
||||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const { muteItems } = useMuteList();
|
||||
const { savedFeeds } = useSavedFeeds();
|
||||
const navHidden = useNavHidden();
|
||||
|
||||
// Tab settings from localStorage
|
||||
const showGlobalFeed = (() => {
|
||||
const stored = localStorage.getItem('ditto:showGlobalFeed');
|
||||
return stored !== null ? stored === 'true' : false;
|
||||
})();
|
||||
|
||||
const showWorldFeed = (() => {
|
||||
const stored = localStorage.getItem('agora:showWorldFeed');
|
||||
return stored !== null ? stored === 'true' : true;
|
||||
})();
|
||||
|
||||
const showCommunityFeed = (() => {
|
||||
const stored = localStorage.getItem('ditto:showCommunityFeed');
|
||||
return stored !== null ? stored === 'true' : false;
|
||||
})();
|
||||
|
||||
const communityLabel = (() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('ditto:community');
|
||||
if (stored) {
|
||||
const community = JSON.parse(stored);
|
||||
return community.label || 'Community';
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
return 'Community';
|
||||
})();
|
||||
|
||||
const [rawActiveTab, handleSetActiveTab] = useFeedTab<FeedTab>(feedId);
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const { startSignup } = useOnboarding();
|
||||
const [authDialogOpen, setAuthDialogOpen] = useState(false);
|
||||
const isHomeAgoraFeed = !kinds && !tagFilters;
|
||||
|
||||
// Kind-specific pages only support Follows + Global. Clamp any other
|
||||
// persisted tab (e.g. 'world', 'communities') back to the appropriate default.
|
||||
// Logged-out users on the home feed land on 'world' to see global content.
|
||||
// The home feed is Agora-only. Specialized feed pages keep Follows + Global.
|
||||
const activeTab: FeedTab = (() => {
|
||||
if (isHomeAgoraFeed) return 'agora';
|
||||
if (!kinds) {
|
||||
// Migrate legacy 'ditto' tab to 'world'
|
||||
if (rawActiveTab === 'ditto') return 'world';
|
||||
// Legacy hashtag:/geotag: tabs are now part of the combined Following
|
||||
// feed; surface them there instead of rendering a missing sub-feed.
|
||||
if (rawActiveTab.startsWith('hashtag:') || rawActiveTab.startsWith('geotag:')) return 'follows';
|
||||
return rawActiveTab;
|
||||
if (rawActiveTab === 'global') return 'global';
|
||||
if (rawActiveTab === 'follows' && user) return 'follows';
|
||||
return user ? 'follows' : 'global';
|
||||
}
|
||||
if (rawActiveTab === 'global') return 'global';
|
||||
if (rawActiveTab === 'follows' && user) return 'follows';
|
||||
return user ? 'follows' : 'global';
|
||||
})();
|
||||
|
||||
// Is the active tab a saved feed?
|
||||
const activeSavedFeed = useMemo(
|
||||
() => savedFeeds.find((f) => f.id === activeTab) ?? null,
|
||||
[savedFeeds, activeTab],
|
||||
);
|
||||
|
||||
// Migrate legacy hashtag:/geotag: tabs (which used to render their own
|
||||
// sub-feeds) back to the home Following feed. Followed hashtags/geotags
|
||||
// now contribute to the combined Following feed instead of getting
|
||||
@@ -122,22 +107,15 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
}, [rawActiveTab, handleSetActiveTab]);
|
||||
|
||||
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
|
||||
// Extra tabs (World, Community, saved feeds) are only for the home feed.
|
||||
const isKindSpecificPage = !!kinds;
|
||||
|
||||
// When logged out (and not on a kind-specific page), show the World feed.
|
||||
const useWorldForLoggedOut = !user && !kinds;
|
||||
|
||||
// When the World tab is active (logged in), show the world feed.
|
||||
// Disabled on kind-specific pages — the World tab is not shown there.
|
||||
const useWorldTab = activeTab === 'world' && !kinds;
|
||||
|
||||
// Is the world feed active?
|
||||
const isWorldActive = useWorldForLoggedOut || !!useWorldTab;
|
||||
// When the Agora tab is active, show the mixed Agora activity feed.
|
||||
// Disabled on kind-specific pages — the Agora tab is not shown there.
|
||||
const isAgoraActive = isHomeAgoraFeed;
|
||||
|
||||
// Standard feed query (used when logged in, or on kind-specific pages, or core tabs)
|
||||
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters;
|
||||
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world';
|
||||
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world' || activeTab === 'agora';
|
||||
type UseFeedTab = 'follows' | 'network' | 'global' | 'communities';
|
||||
const feedTabForQuery: UseFeedTab =
|
||||
activeTab === 'follows'
|
||||
@@ -146,35 +124,28 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
? (activeTab as UseFeedTab)
|
||||
: 'global';
|
||||
const standardFeedOptions = (kinds || tagFilters)
|
||||
? { kinds, tagFilters, enabled: !isHomeFollowingActive }
|
||||
: { enabled: !isHomeFollowingActive };
|
||||
? { kinds, tagFilters, enabled: !isHomeFollowingActive && !isAgoraActive }
|
||||
: { enabled: !isHomeFollowingActive && !isAgoraActive };
|
||||
const feedQuery = useFeed(
|
||||
isCoreFeedTab && !isWorldActive ? feedTabForQuery : 'global',
|
||||
isCoreFeedTab && !isAgoraActive ? feedTabForQuery : 'global',
|
||||
standardFeedOptions,
|
||||
);
|
||||
|
||||
const followingFeed = useFollowingFeed(isHomeFollowingActive);
|
||||
|
||||
// World feed: all country-tagged events with diversity cap + live streaming.
|
||||
const worldFeed = useWorldFeed(isWorldActive);
|
||||
const { flushStreamBuffer } = worldFeed;
|
||||
const agoraFeed = useAgoraFeed(isAgoraActive);
|
||||
|
||||
// For non-world tabs, use the standard feed query
|
||||
const queryKey = useMemo(
|
||||
() => isWorldActive
|
||||
? ['world-feed']
|
||||
: isHomeFollowingActive
|
||||
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
|
||||
: ['feed', activeTab],
|
||||
[isWorldActive, isHomeFollowingActive, activeTab],
|
||||
() => isAgoraActive
|
||||
? ['agora-feed']
|
||||
: isHomeFollowingActive
|
||||
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
|
||||
: ['feed', activeTab],
|
||||
[isAgoraActive, isHomeFollowingActive, activeTab],
|
||||
);
|
||||
|
||||
const handleRefresh = usePageRefresh(queryKey);
|
||||
const handleWorldRefresh = useCallback(async () => {
|
||||
flushStreamBuffer();
|
||||
await handleRefresh();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, [flushStreamBuffer, handleRefresh]);
|
||||
|
||||
const {
|
||||
data: rawData,
|
||||
@@ -186,16 +157,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
} = isHomeFollowingActive ? followingFeed : feedQuery;
|
||||
|
||||
// Unify pagination interface
|
||||
const fetchNextPage = isWorldActive ? worldFeed.fetchNextPage : fetchNextPageStandard;
|
||||
const hasNextPage = isWorldActive ? worldFeed.hasNextPage : hasNextPageStandard;
|
||||
const isFetchingNextPage = isWorldActive ? worldFeed.isFetchingNextPage : isFetchingNextPageStandard;
|
||||
const fetchNextPage = isAgoraActive ? agoraFeed.fetchNextPage : fetchNextPageStandard;
|
||||
const hasNextPage = isAgoraActive ? agoraFeed.hasNextPage : hasNextPageStandard;
|
||||
const isFetchingNextPage = isAgoraActive ? agoraFeed.isFetchingNextPage : isFetchingNextPageStandard;
|
||||
|
||||
// Auto-fetch page 2 as soon as page 1 arrives for smoother scrolling
|
||||
useEffect(() => {
|
||||
if (!isHomeFollowingActive && !isWorldActive && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
|
||||
if (!isHomeFollowingActive && !isAgoraActive && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [isHomeFollowingActive, isWorldActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
|
||||
}, [isHomeFollowingActive, isAgoraActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
|
||||
|
||||
// Intersection observer for infinite scroll
|
||||
const { ref: scrollRef, inView } = useInView({
|
||||
@@ -211,9 +182,8 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
|
||||
// Flatten, deduplicate, and filter muted content.
|
||||
const feedItems = useMemo(() => {
|
||||
if (isWorldActive) {
|
||||
// World feed: events are already filtered/deduped by useWorldFeed
|
||||
return worldFeed.events.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
|
||||
if (isAgoraActive) {
|
||||
return agoraFeed.events.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
|
||||
}
|
||||
|
||||
if (!rawData?.pages) return [];
|
||||
@@ -229,86 +199,55 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [isWorldActive, worldFeed.events, rawData?.pages, muteItems]);
|
||||
}, [isAgoraActive, agoraFeed.events, rawData?.pages, muteItems]);
|
||||
|
||||
// Show skeletons while loading.
|
||||
const showSkeleton = isWorldActive
|
||||
? worldFeed.isLoading
|
||||
: (isPending || (isLoading && !rawData));
|
||||
const showSkeleton = isAgoraActive
|
||||
? agoraFeed.isLoading
|
||||
: (isPending || (isLoading && !rawData));
|
||||
|
||||
const showSavedFeedTabs = user && !isKindSpecificPage && !tagFilters;
|
||||
const useGlobeBackdrop = feedId === 'home' && !kinds && !tagFilters && !header;
|
||||
const translucentCardClassName = useGlobeBackdrop
|
||||
? 'bg-transparent border-border/50 hover:bg-transparent'
|
||||
: undefined;
|
||||
const transparentFeedSurfaceClassName = useGlobeBackdrop ? 'bg-transparent' : undefined;
|
||||
|
||||
return (
|
||||
<main className="flex-1 min-w-0 min-h-dvh">
|
||||
{header}
|
||||
<main className={cn('flex-1 min-w-0 min-h-dvh', useGlobeBackdrop && 'relative isolate overflow-x-clip')}>
|
||||
{useGlobeBackdrop && <FeedGlobeBackground />}
|
||||
|
||||
{/* CTA (logged out, main feed only) */}
|
||||
{!user && !kinds && (
|
||||
<LandingHero
|
||||
onLoginClick={() => setLoginDialogOpen(true)}
|
||||
onSignupClick={startSignup}
|
||||
/>
|
||||
)}
|
||||
<div className={cn(useGlobeBackdrop && 'relative z-10')}>
|
||||
{header}
|
||||
|
||||
{!hideCompose && <ComposeBox compact hideBorder />}
|
||||
{/* CTA (logged out, main feed only) */}
|
||||
{!user && !kinds && (
|
||||
<LandingHero onJoinClick={() => setAuthDialogOpen(true)} />
|
||||
)}
|
||||
|
||||
{/* Tabs (logged in) */}
|
||||
{user && (
|
||||
<SubHeaderBar>
|
||||
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
|
||||
{!isKindSpecificPage && !tagFilters && (
|
||||
<TabButton label="Network" active={activeTab === 'network'} onClick={() => handleSetActiveTab('network')} />
|
||||
)}
|
||||
{!isKindSpecificPage && showWorldFeed && (
|
||||
<TabButton label="World" active={activeTab === 'world'} onClick={() => handleSetActiveTab('world')} />
|
||||
)}
|
||||
{!isKindSpecificPage && showCommunityFeed && (
|
||||
<TabButton label={communityLabel} active={activeTab === 'communities'} onClick={() => handleSetActiveTab('communities')} />
|
||||
)}
|
||||
{(isKindSpecificPage || showGlobalFeed) && (
|
||||
{!hideCompose && (
|
||||
<ComposeBox
|
||||
compact
|
||||
hideBorder
|
||||
className={transparentFeedSurfaceClassName}
|
||||
defaultTags={AGORA_DEFAULT_NOTE_TAGS}
|
||||
defaultExpanded
|
||||
placeholder="What's happening?"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabs are only kept for specialized feed pages. The home feed is Agora-only. */}
|
||||
{user && (isKindSpecificPage || tagFilters) && (
|
||||
<SubHeaderBar backgroundFillClassName={transparentFeedSurfaceClassName && 'fill-transparent'}>
|
||||
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
|
||||
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
|
||||
)}
|
||||
{showSavedFeedTabs && savedFeeds.map((feed) => (
|
||||
<TabButton
|
||||
key={feed.id}
|
||||
label={feed.label}
|
||||
active={activeTab === feed.id}
|
||||
onClick={() => handleSetActiveTab(feed.id)}
|
||||
/>
|
||||
))}
|
||||
</SubHeaderBar>
|
||||
)}
|
||||
</SubHeaderBar>
|
||||
)}
|
||||
|
||||
{/* Feed content — saved feed tab gets its own stream */}
|
||||
{activeSavedFeed ? (
|
||||
<SavedFeedContent feed={activeSavedFeed} />
|
||||
) : (
|
||||
<PullToRefresh onRefresh={isWorldActive ? handleWorldRefresh : handleRefresh}>
|
||||
{/* "X new posts" pill for World tab */}
|
||||
{isWorldActive && worldFeed.newPostCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky new-posts-pill z-10 flex justify-center pointer-events-none',
|
||||
'max-sidebar:transition-opacity max-sidebar:duration-300 max-sidebar:ease-in-out',
|
||||
navHidden && 'max-sidebar:opacity-0 max-sidebar:pointer-events-none',
|
||||
)}
|
||||
style={{ marginBottom: '-3rem' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
worldFeed.flushStreamBuffer();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className="pointer-events-auto px-4 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium shadow-lg hover:bg-primary/90 transition-colors animate-in fade-in slide-in-from-top-2 duration-300"
|
||||
>
|
||||
{worldFeed.newPostCount} new post{worldFeed.newPostCount !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{showSkeleton ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<NoteCardSkeleton key={i} />
|
||||
<NoteCardSkeleton key={i} className={translucentCardClassName} />
|
||||
))}
|
||||
</div>
|
||||
) : feedItems.length > 0 ? (
|
||||
@@ -318,7 +257,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
highlight={isWorldActive && worldFeed.flushedIds.has(item.event.id)}
|
||||
className={translucentCardClassName}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
@@ -331,159 +270,42 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isHomeFollowingActive && !emptyMessage ? (
|
||||
<FollowingEmptyState onExploreWorld={() => navigate('/world')} />
|
||||
) : activeTab === 'network' && !emptyMessage ? (
|
||||
<NetworkEmptyState onDiscoverPeople={() => navigate('/packs')} />
|
||||
) : (
|
||||
<FeedEmptyState
|
||||
message={
|
||||
emptyMessage ?? (
|
||||
activeTab === 'follows' || activeTab === 'network'
|
||||
activeTab === 'follows'
|
||||
? 'Your feed is empty. Follow some people to see their posts here.'
|
||||
: activeTab === 'world'
|
||||
? 'No world posts yet. Check back soon for global activity.'
|
||||
: activeTab === 'agora'
|
||||
? 'No Agora activity found. Check your relay connections or come back soon.'
|
||||
: 'No posts found. Check your relay connections or come back soon.'
|
||||
)
|
||||
}
|
||||
showDiscover={!emptyMessage && (activeTab === 'follows' || activeTab === 'network')}
|
||||
showDiscover={!emptyMessage && activeTab === 'follows'}
|
||||
onSwitchToGlobal={
|
||||
(activeTab === 'follows' || activeTab === 'network') && showGlobalFeed
|
||||
activeTab === 'follows'
|
||||
? () => handleSetActiveTab('global')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
)}
|
||||
|
||||
{/* Login/Signup dialogs (only needed on main feed) */}
|
||||
{!kinds && (
|
||||
<LoginDialog
|
||||
isOpen={loginDialogOpen}
|
||||
onClose={() => setLoginDialogOpen(false)}
|
||||
onLogin={() => setLoginDialogOpen(false)}
|
||||
onSignupClick={startSignup}
|
||||
/>
|
||||
)}
|
||||
{/* Auth dialog (only needed on main feed) */}
|
||||
{!kinds && (
|
||||
<AuthDialog
|
||||
isOpen={authDialogOpen}
|
||||
onClose={() => setAuthDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a saved search feed using useTabFeed (TanStack Query cached, infinite scroll). */
|
||||
function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
const { user } = useCurrentUser();
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
// Resolve variable placeholders ($follows etc.) the same way profile tabs do
|
||||
const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter(
|
||||
feed.filter,
|
||||
feed.vars ?? [],
|
||||
user?.pubkey ?? '',
|
||||
);
|
||||
|
||||
// Augment the resolved filter with protocol:nostr (NIP-50 Ditto extension)
|
||||
// to match the behavior of the core feeds and ensure latest native Nostr
|
||||
// posts are returned.
|
||||
const augmentedFilter = useMemo(() => {
|
||||
if (!resolvedFilter) return null;
|
||||
const existing = resolvedFilter.search ?? '';
|
||||
const search = existing.includes('protocol:nostr')
|
||||
? existing
|
||||
: existing
|
||||
? `${existing} protocol:nostr`
|
||||
: 'protocol:nostr';
|
||||
return { ...resolvedFilter, search };
|
||||
}, [resolvedFilter]);
|
||||
|
||||
const {
|
||||
data: rawData,
|
||||
isLoading: isFeedLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useTabFeed(augmentedFilter, `saved-${feed.id}`, !isResolving);
|
||||
|
||||
const isLoading = isResolving || isFeedLoading;
|
||||
|
||||
// Prefix key -- usePageRefresh does prefix matching, so this invalidates
|
||||
// the full ['tab-feed', tabKey, kindsKey, authorsKey, searchKey] used by useTabFeed.
|
||||
const queryKey = useMemo(
|
||||
() => ['tab-feed', `saved-${feed.id}`],
|
||||
[feed.id],
|
||||
);
|
||||
const handleRefresh = usePageRefresh(queryKey);
|
||||
|
||||
// Infinite scroll: fetch next page when sentinel is in view
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Flatten pages, deduplicate, and filter muted content
|
||||
const feedItems = useMemo(() => {
|
||||
if (!rawData?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
return rawData.pages
|
||||
.flatMap((page) => page.items)
|
||||
.filter((item) => {
|
||||
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
if (shouldHideFeedEvent(item.event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [rawData?.pages, muteItems]);
|
||||
|
||||
if (isLoading && feedItems.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<NoteCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (feedItems.length === 0) {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<FeedEmptyState message={`No posts found for "${feed.label}". Try adjusting your relay connections or check back later.`} />
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div>
|
||||
{feedItems.map((item) => (
|
||||
<NoteCard
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div ref={scrollRef} className="py-4">
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!hasNextPage && <div ref={scrollRef} className="py-2" />}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCardSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className={cn('px-4 py-3 border-b border-border', className)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
@@ -504,47 +326,3 @@ function NoteCardSkeleton() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FollowingEmptyState({ onExploreWorld }: { onExploreWorld: () => void }) {
|
||||
return (
|
||||
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<Globe2 className="size-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<h2 className="text-xl font-bold">No activity yet</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your Following feed is quiet right now. Visit World to discover more global activity.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full max-w-xs">
|
||||
<Button className="rounded-full" onClick={onExploreWorld}>
|
||||
<Globe2 className="size-4 mr-2" />
|
||||
Visit World
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkEmptyState({ onDiscoverPeople }: { onDiscoverPeople: () => void }) {
|
||||
return (
|
||||
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<Users className="size-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<h2 className="text-xl font-bold">No network activity yet</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Follow more people to fill your Network feed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full max-w-xs">
|
||||
<Button className="rounded-full" onClick={onDiscoverPeople}>
|
||||
<Users className="size-4 mr-2" />
|
||||
Discover people
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FeedCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Extra class names merged after the defaults. */
|
||||
className?: string;
|
||||
/** Children — typically a list of NoteCards, member rows, notification rows, etc. */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft rounded card surface used to wrap vertical feed lists (NoteCard
|
||||
* feeds, author lists, notification rows, etc.) so they sit inside a
|
||||
* GoFundMe-style canvas instead of running edge-to-edge like a Twitter
|
||||
* timeline.
|
||||
*
|
||||
* Rows inside are expected to supply their own per-row separator
|
||||
* (NoteCard self-applies `border-b border-border`). For pure skeleton
|
||||
* lists where rows don't self-border, pass `divide` on the className.
|
||||
*
|
||||
* `overflow-hidden` ensures the last row's bottom border tucks under
|
||||
* the card's rounded corner instead of poking out.
|
||||
*/
|
||||
export const FeedCard = forwardRef<HTMLDivElement, FeedCardProps>(
|
||||
function FeedCard({ className, children, ...rest }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mx-4 sm:mx-6 rounded-2xl bg-card border border-border/60 shadow-sm overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, useState } from 'react';
|
||||
import { Plus, Construction } from 'lucide-react';
|
||||
import { Construction } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LogoIcon } from '@/components/icons/LogoIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -41,26 +42,43 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderedIcon = icon ?? <Plus strokeWidth={4} size={16} />;
|
||||
const hasCustomIcon = icon !== undefined;
|
||||
const renderedIcon = icon;
|
||||
const logoButtonClassName = "relative size-20 text-primary transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm";
|
||||
const logoButtonStyle = { filter: 'drop-shadow(0 3px 10px hsl(var(--primary) / 0.28))' };
|
||||
|
||||
// ── Menu mode — anchor a Popover to the FAB itself ────────────────────────
|
||||
if (menu && menu.length > 0) {
|
||||
return (
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
className="relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
|
||||
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-primary rounded-full" />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
|
||||
{renderedIcon}
|
||||
</span>
|
||||
</button>
|
||||
{hasCustomIcon ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
className="relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
|
||||
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-primary rounded-full" />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
|
||||
{renderedIcon}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
className={logoButtonClassName}
|
||||
style={logoButtonStyle}
|
||||
>
|
||||
<span className="absolute inset-[-8%] rounded-full bg-primary/15 blur-xl" aria-hidden />
|
||||
<LogoIcon className="relative size-full" />
|
||||
</button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
@@ -108,10 +126,23 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }
|
||||
|
||||
return (
|
||||
<>
|
||||
<FabButton
|
||||
onClick={handleClick}
|
||||
icon={renderedIcon}
|
||||
/>
|
||||
{hasCustomIcon ? (
|
||||
<FabButton
|
||||
onClick={handleClick}
|
||||
icon={renderedIcon}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
aria-label="Add"
|
||||
className={logoButtonClassName}
|
||||
style={logoButtonStyle}
|
||||
>
|
||||
<span className="absolute inset-[-8%] rounded-full bg-primary/15 blur-xl" aria-hidden />
|
||||
<LogoIcon className="relative size-full" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Kind 1: Compose modal (lazy-loaded) */}
|
||||
{kind === 1 && composeOpen && (
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Section wrapper used by the long single-column form pages
|
||||
* (`CreateActionPage`, `CreateCampaignPage`). Each section is a titled
|
||||
* `<section>` with a small muted requirement badge so users can scan the
|
||||
* form at a glance for "what do I have to fill in?".
|
||||
*/
|
||||
export function FormSection({
|
||||
title,
|
||||
requirement,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
requirement: 'Required' | 'Recommended' | 'Optional';
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-2.5 rounded-xl p-3 sm:p-4">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
||||
{title}
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{requirement}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-2.5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Link, Outlet } from 'react-router-dom';
|
||||
|
||||
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
|
||||
import { TopNav } from '@/components/TopNav';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
CenterColumnContext,
|
||||
DrawerContext,
|
||||
LayoutStore,
|
||||
LayoutStoreContext,
|
||||
NavHiddenContext,
|
||||
useLayoutSnapshot,
|
||||
} from '@/contexts/LayoutContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Persistent app shell for the fundraising-platform overhaul.
|
||||
*
|
||||
* Replaces the previous Twitter-style three-column `MainLayout` with a
|
||||
* GoFundMe-style top-nav-only chrome. Routes render in a single full-width
|
||||
* content area below the {@link TopNav}.
|
||||
*
|
||||
* Compatibility surface:
|
||||
* - We still provide `LayoutStoreContext`, so pages that call
|
||||
* `useLayoutOptions(...)` keep working. Most options (FAB, sidebars,
|
||||
* mobile arc) are intentionally ignored here because the new shell has
|
||||
* no FAB and no sidebars. The store drives two width-related escape
|
||||
* hatches: `wrapperClassName` (extra classes on the center column) and
|
||||
* `noMaxWidth` (drops the default `max-w-3xl` cap). The `fullBleed`
|
||||
* preset expands to both, so edge-to-edge pages keep working.
|
||||
* - `CenterColumnContext` exposes the content `<div>` so legacy components
|
||||
* (e.g. nsite preview overlay) can still portal into it.
|
||||
* - `DrawerContext` and `NavHiddenContext` are kept as no-op providers so
|
||||
* pages that read them don't crash.
|
||||
*/
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 py-8 space-y-4">
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-72 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FundraiserLayoutInner() {
|
||||
const centerColumnRef = useRef<HTMLDivElement>(null);
|
||||
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
|
||||
const { noMaxWidth, wrapperClassName, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu } = useLayoutSnapshot();
|
||||
|
||||
// Mobile drawer is owned by TopNav now, so consumers of `useOpenDrawer`
|
||||
// become no-ops. Keeping the context shape avoids touching every page that
|
||||
// pulls the hook.
|
||||
const openDrawer = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<CenterColumnContext.Provider value={centerColumnEl}>
|
||||
<DrawerContext.Provider value={openDrawer}>
|
||||
<NavHiddenContext.Provider value={false}>
|
||||
<div className="min-h-dvh flex flex-col bg-background">
|
||||
<TopNav />
|
||||
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
centerColumnRef.current = el;
|
||||
setCenterColumnEl(el);
|
||||
}}
|
||||
className={cn(
|
||||
'flex-1 min-w-0 w-full mx-auto',
|
||||
// App-wide cap on the center column so pages like /help
|
||||
// don't stretch across widescreen monitors. Pages that
|
||||
// need a wider canvas opt out via `noMaxWidth: true` (or
|
||||
// the `fullBleed` preset), which expands to `!max-w-none`
|
||||
// through `wrapperClassName`.
|
||||
!noMaxWidth && 'max-w-3xl',
|
||||
wrapperClassName,
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
{showFAB && (
|
||||
<div className="fixed bottom-fab right-6 z-30 pointer-events-none sidebar:right-[max(1.5rem,calc((100vw-48rem)/2-7rem))]">
|
||||
<div className="pointer-events-auto">
|
||||
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} menu={fabMenu} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</NavHiddenContext.Provider>
|
||||
</DrawerContext.Provider>
|
||||
</CenterColumnContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-background mt-auto">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
|
||||
<nav className="flex items-center gap-5">
|
||||
<Link to="/help" className="hover:text-foreground motion-safe:transition-colors">Help</Link>
|
||||
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</Link>
|
||||
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</Link>
|
||||
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">Changelog</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function FundraiserLayout() {
|
||||
const store = useMemo(() => new LayoutStore(), []);
|
||||
return (
|
||||
<LayoutStoreContext.Provider value={store}>
|
||||
<FundraiserLayoutInner />
|
||||
</LayoutStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default FundraiserLayout;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
import { HOPE_PALETTE, type HopeHue } from '@/lib/hopePalette';
|
||||
|
||||
interface GuideHeroProps {
|
||||
/** Large hero headline. */
|
||||
title: string;
|
||||
/** Short subtitle under the headline. */
|
||||
subtitle: string;
|
||||
/** Rotating banner images. Pass at least one. */
|
||||
images: readonly string[];
|
||||
/**
|
||||
* Color palette to cycle through for the atmospheric tint. Defaults
|
||||
* to {@link HOPE_PALETTE} (warm). Pass {@link COOL_PALETTE} for the
|
||||
* blue/green Organize-style vibe.
|
||||
*/
|
||||
palette?: readonly HopeHue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact photo hero shared by the Donor Guide and Activist Guide pages.
|
||||
*
|
||||
* Same structural recipe as the Organize / Actions homepage heroes
|
||||
* ({@link HeroBanner} + {@link HeroAtmosphere} + scrims + overlay copy),
|
||||
* but tuned smaller because these are sub-pages, not primary destinations.
|
||||
* Also embeds a "Back to Help" link in the top-left as the page's
|
||||
* primary navigation out — so a separate sticky bar isn't needed.
|
||||
*/
|
||||
export function GuideHero({
|
||||
title,
|
||||
subtitle,
|
||||
images,
|
||||
palette = HOPE_PALETTE,
|
||||
}: GuideHeroProps) {
|
||||
// Cycle through the palette on a slow cadence so the photo never
|
||||
// feels static even when a single banner image is on screen.
|
||||
const [hueIndex, setHueIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
if (palette.length <= 1) return;
|
||||
const id = window.setInterval(() => {
|
||||
setHueIndex((i) => (i + 1) % palette.length);
|
||||
}, 9_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [palette]);
|
||||
|
||||
const activeHue = palette[hueIndex];
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-border bg-secondary/30">
|
||||
<HeroBanner images={images} />
|
||||
<HeroAtmosphere hue={activeHue} />
|
||||
|
||||
{/* Top + bottom scrims so the overlay text stays legible across
|
||||
every photo in the rotation. */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-48 sm:h-56 pointer-events-none bg-gradient-to-b from-black/75 via-black/45 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-32 sm:h-40 pointer-events-none bg-gradient-to-t from-black/60 via-black/25 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative max-w-3xl mx-auto px-4 sm:px-6 py-6 sm:py-8 min-h-[240px] sm:min-h-[280px] flex flex-col">
|
||||
{/* Back-to-Help action sits on its own row at the top so it
|
||||
doubles as both the navigation out and the breadcrumb. */}
|
||||
<div>
|
||||
<Link
|
||||
to="/help"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-black/30 hover:bg-black/45 backdrop-blur-sm border border-white/20 px-3 py-1.5 text-xs sm:text-sm font-medium text-white drop-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Back to Help
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Headline + subtitle anchored to the bottom of the hero so the
|
||||
photo gets room to breathe up top. */}
|
||||
<div className="flex-1 min-h-[40px]" aria-hidden="true" />
|
||||
<div className="space-y-2 max-w-2xl">
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-white/85 drop-shadow-[0_1px_6px_rgb(0_0_0/0.5)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,54 +8,7 @@ import {
|
||||
} from '@/components/ui/accordion';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getFAQCategories, type FAQCategory, type FAQItem } from '@/lib/helpContent';
|
||||
|
||||
// ── Inline markup renderer ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Very lightweight inline markup: **bold** and [text](url).
|
||||
* Returns an array of React nodes.
|
||||
*/
|
||||
function renderInlineMarkup(text: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
// Match **bold** or [text](url)
|
||||
const regex = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Push text before this match
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (match[1] !== undefined) {
|
||||
// **bold**
|
||||
nodes.push(<strong key={match.index} className="font-semibold text-foreground">{match[1]}</strong>);
|
||||
} else if (match[2] !== undefined && match[3] !== undefined) {
|
||||
// [text](url)
|
||||
nodes.push(
|
||||
<a
|
||||
key={match.index}
|
||||
href={match[3]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{match[2]}
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Trailing text
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
import { renderInlineMarkup } from '@/lib/helpMarkup';
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -93,6 +46,13 @@ export function HelpFAQSection({ categories, items, hideHeadings, className }: H
|
||||
const filteredCategories = useMemo(() => {
|
||||
let cats: FAQCategory[] = getFAQCategories(config.appName);
|
||||
|
||||
// Drop hidden categories from the default render. They still exist in
|
||||
// the underlying template so `HelpTip` can look up individual items by
|
||||
// ID, but they don't show up in the FAQ accordion.
|
||||
if (!categories && !items) {
|
||||
cats = cats.filter((c) => !c.hidden);
|
||||
}
|
||||
|
||||
// Filter to specific categories
|
||||
if (categories) {
|
||||
cats = cats.filter((c) => categories.includes(c.id));
|
||||
|
||||
@@ -4,42 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getFAQItem } from '@/lib/helpContent';
|
||||
|
||||
/**
|
||||
* Renders **bold** and [text](url) markup in FAQ answer strings.
|
||||
*/
|
||||
function renderInlineMarkup(text: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
const regex = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
if (match[1] !== undefined) {
|
||||
nodes.push(<strong key={match.index} className="font-semibold text-foreground">{match[1]}</strong>);
|
||||
} else if (match[2] !== undefined && match[3] !== undefined) {
|
||||
nodes.push(
|
||||
<a
|
||||
key={match.index}
|
||||
href={match[3]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{match[2]}
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
import { renderInlineMarkup } from '@/lib/helpMarkup';
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { hopeHueFor, type HopeHue } from '@/lib/hopePalette';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HeroAtmosphereProps {
|
||||
/**
|
||||
* Stable seed for the current campaign — typically the campaign's
|
||||
* `aTag`. The same seed always picks the same hue from
|
||||
* {@link HOPE_PALETTE}. Pass `null`/`undefined` when no campaign is
|
||||
* spotlit and the atmosphere will default to the first palette entry.
|
||||
*
|
||||
* Ignored when `hue` is provided. Optional only when `hue` is given;
|
||||
* the campaign hero still depends on seed-based selection.
|
||||
*/
|
||||
seed?: string | undefined | null;
|
||||
/**
|
||||
* Explicit hue override. When set, the atmosphere skips seed-based
|
||||
* palette selection and crossfades whenever this hue changes. Use this
|
||||
* when the page already rotates hues itself (e.g. the Organize hero
|
||||
* cycles a cool palette every few seconds) or when the seed-derived
|
||||
* warm palette is the wrong vibe.
|
||||
*/
|
||||
hue?: HopeHue;
|
||||
/** Crossfade duration in milliseconds. Defaults to the campaign hero timing. */
|
||||
fadeMs?: number;
|
||||
/** Extra classes for the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface AtmosphereLayer {
|
||||
/** Render-order id so React doesn't tear the layer down mid-fade. */
|
||||
id: number;
|
||||
hue: HopeHue;
|
||||
}
|
||||
|
||||
/** Has to match {@link CampaignHeroBackground}'s FADE_MS so the entire
|
||||
* hero transitions as a single moment instead of in two staggered steps. */
|
||||
const FADE_MS = 1500;
|
||||
|
||||
/**
|
||||
* Soft, hue-tinted "sunrise" atmosphere layer for the campaigns hero.
|
||||
*
|
||||
* Two layered gradients sit on top of the photo background:
|
||||
* - a left-to-right warm scrim that gives the headline area an emotional
|
||||
* color cast, and
|
||||
* - a large soft radial glow centered on the headline that reads as a
|
||||
* sunrise / dawn light pooling behind the text.
|
||||
*
|
||||
* The hue is derived from {@link hopeHueFor} so every campaign gets a
|
||||
* stable, slightly different warm color. When the active campaign
|
||||
* changes we mount a fresh layer with the new hue and crossfade it over
|
||||
* the old one, matching the timing of the photo crossfade so the whole
|
||||
* hero blooms together.
|
||||
*/
|
||||
export function HeroAtmosphere({ seed, hue: hueOverride, fadeMs = FADE_MS, className }: HeroAtmosphereProps) {
|
||||
const idRef = useRef(0);
|
||||
const [layers, setLayers] = useState<AtmosphereLayer[]>([]);
|
||||
const lastHueRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const hue = hueOverride ?? hopeHueFor(seed ?? null);
|
||||
if (hue.name === lastHueRef.current) return;
|
||||
lastHueRef.current = hue.name;
|
||||
|
||||
const id = ++idRef.current;
|
||||
setLayers((prev) => [...prev, { id, hue }]);
|
||||
|
||||
// Drop everything except the most recent layer once the crossfade is
|
||||
// safely past, so the DOM never accumulates stale gradients.
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLayers((prev) => prev.filter((l) => l.id === id));
|
||||
}, fadeMs + 50);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [seed, hueOverride, fadeMs]);
|
||||
|
||||
return (
|
||||
<div className={cn('absolute inset-0 pointer-events-none', className)} aria-hidden="true">
|
||||
{layers.map((layer, i) => {
|
||||
const isTop = i === layers.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
opacity: isTop ? 1 : 0,
|
||||
transition: `opacity ${fadeMs}ms ease-in-out`,
|
||||
}}
|
||||
>
|
||||
{/* Warm directional scrim — pulls the photo toward the active
|
||||
hue without flattening it. Anchored on the left so the
|
||||
headline area gets the strongest tint. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(115deg, ${layer.hue.scrim} 0%, transparent 60%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Big soft radial glow — reads as dawn light pooling behind
|
||||
the headline. mix-blend-screen so it lightens warmly
|
||||
instead of just adding a flat color. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(45rem 32rem at 20% 35%, ${layer.hue.glow} 0%, transparent 70%)`,
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Thin sliver of sunrise light along the top edge. Subtle —
|
||||
you should feel it more than see it. */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-1/3"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to bottom, ${layer.hue.rim} 0%, transparent 100%)`,
|
||||
mixBlendMode: 'screen',
|
||||
opacity: 0.55,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Default rotation: photos from past World Liberty Congress events, used
|
||||
* by the Organize / Communities hero. Each image lives in
|
||||
* `/public/hero/wlc-N.webp` and is referenced by absolute path so the
|
||||
* browser caches them across navigations and `<link rel="preload">` can
|
||||
* pick them up if we ever add it.
|
||||
*
|
||||
* Other pages can pass their own list via the `images` prop (e.g. the
|
||||
* Actions hero rotates through the action cover gallery).
|
||||
*/
|
||||
const DEFAULT_BANNER_IMAGES: readonly string[] = [
|
||||
'/hero/wlc-1.webp',
|
||||
'/hero/wlc-2.webp',
|
||||
'/hero/wlc-3.webp',
|
||||
];
|
||||
|
||||
interface HeroBannerProps {
|
||||
/**
|
||||
* Ordered list of image URLs to rotate through. Defaults to the
|
||||
* Organize hero's WLC photos. Pass at least one URL; if the list has
|
||||
* a single entry the banner renders it as a still image.
|
||||
*/
|
||||
images?: readonly string[];
|
||||
/** Optional className for the outer wrapper. */
|
||||
className?: string;
|
||||
/**
|
||||
* Time between crossfades, in ms. Defaults to 7s — long enough for
|
||||
* faces to register, short enough that the page never feels static.
|
||||
*/
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
interface Layer {
|
||||
/** Stable key so React doesn't tear the layer down mid-transition. */
|
||||
id: number;
|
||||
/** URL of the image rendered on this layer. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
const FADE_MS = 1500;
|
||||
|
||||
/**
|
||||
* Full-bleed crossfading banner of event photos. Modelled after
|
||||
* {@link CampaignHeroBackground}: each new image gets its own stacked
|
||||
* layer and we toggle opacity to crossfade. The previous layer unmounts
|
||||
* after the fade completes so the DOM never accumulates more than two
|
||||
* `<img>` elements.
|
||||
*
|
||||
* The component is self-driving — it advances through `images` on a
|
||||
* fixed interval and stops the timer when `prefers-reduced-motion` is
|
||||
* set, leaving the first image as a static banner.
|
||||
*/
|
||||
export function HeroBanner({
|
||||
images = DEFAULT_BANNER_IMAGES,
|
||||
className,
|
||||
intervalMs = 7_000,
|
||||
}: HeroBannerProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const idRef = useRef(0);
|
||||
const [layers, setLayers] = useState<Layer[]>(() =>
|
||||
images.length > 0 ? [{ id: 0, url: images[0] }] : [],
|
||||
);
|
||||
|
||||
// Honor the user's reduced-motion preference. We freeze the rotation
|
||||
// and let the first image act as a still banner.
|
||||
const reducedMotion = useRef(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
reducedMotion.current = mq.matches;
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
reducedMotion.current = e.matches;
|
||||
};
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Advance the index on a fixed interval. We deliberately keep this
|
||||
// separate from the layer effect below so swapping the interval (e.g.
|
||||
// for tests) doesn't force a full crossfade restart.
|
||||
useEffect(() => {
|
||||
if (images.length <= 1) return;
|
||||
const id = window.setInterval(() => {
|
||||
if (reducedMotion.current) return;
|
||||
setIndex((i) => (i + 1) % images.length);
|
||||
}, intervalMs);
|
||||
return () => window.clearInterval(id);
|
||||
}, [images, intervalMs]);
|
||||
|
||||
// Whenever the active index changes, push a new layer on top. Old
|
||||
// layers are reaped after the crossfade completes.
|
||||
useEffect(() => {
|
||||
if (images.length === 0) return;
|
||||
const url = images[index % images.length];
|
||||
const id = ++idRef.current;
|
||||
setLayers((prev) => [...prev, { id, url }]);
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLayers((prev) => prev.filter((l) => l.id === id));
|
||||
}, FADE_MS + 50);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [index, images]);
|
||||
|
||||
// Preload the next image during idle time so the next crossfade
|
||||
// doesn't blink. Cheap — the browser will dedupe with the eventual
|
||||
// <img> request once the layer mounts.
|
||||
useEffect(() => {
|
||||
if (images.length <= 1) return;
|
||||
const next = images[(index + 1) % images.length];
|
||||
const img = new Image();
|
||||
img.decoding = 'async';
|
||||
img.src = next;
|
||||
}, [index, images]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('absolute inset-0 overflow-hidden', className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{layers.map((layer, i) => {
|
||||
const isTop = i === layers.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
opacity: isTop ? 1 : 0,
|
||||
transition: `opacity ${FADE_MS}ms ease-in-out`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={layer.url}
|
||||
alt=""
|
||||
// First image eager so the hero never starts empty; the
|
||||
// rest can wait until they're scheduled to come in.
|
||||
loading={layer.id === 0 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
// Subtle slow pan — same keyframe used by the campaigns
|
||||
// hero — so each photo feels alive on its turn instead of
|
||||
// sitting frozen for 7 seconds.
|
||||
className="absolute inset-0 w-full h-full object-cover hero-pan-left"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, MapPin } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { encodeCampaignNaddr, getCampaignCountryLabel, type ParsedCampaign } from '@/lib/campaign';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
|
||||
|
||||
interface HeroCampaignSpotlightProps {
|
||||
/** Campaign to feature. `null` renders the empty placeholder. */
|
||||
campaign: ParsedCampaign | null;
|
||||
/** Show a skeleton while the parent is still loading featured campaigns. */
|
||||
isLoading?: boolean;
|
||||
/** Extra classes for the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner-overlay spotlight for the active campaign — title, summary,
|
||||
* author, location, and a "View campaign" CTA — rendered directly on the
|
||||
* hero photo (no card chrome). The hero photo IS the background, so this
|
||||
* component is purely a text overlay.
|
||||
*
|
||||
* Parent (`CampaignsPage`) drives the `campaign` prop, cycling on a timer
|
||||
* or pinning to whichever marker the user clicked on the globe.
|
||||
*/
|
||||
export function HeroCampaignSpotlight({
|
||||
campaign,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: HeroCampaignSpotlightProps) {
|
||||
// useAuthor must be called unconditionally to keep hook order stable —
|
||||
// when there's no campaign yet we pass an empty pubkey and ignore the
|
||||
// (no-op) result below. Same for donations + BTC price.
|
||||
const author = useAuthor(campaign?.pubkey ?? '');
|
||||
const { data: stats } = useCampaignDonations(campaign?.aTag);
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
|
||||
if (isLoading && !campaign) {
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
<Skeleton className="h-5 w-52 bg-white/20" />
|
||||
<Skeleton className="h-3 w-64 bg-white/20" />
|
||||
<Skeleton className="h-3 w-40 bg-white/20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!campaign) return null;
|
||||
|
||||
const naddr = encodeCampaignNaddr(campaign);
|
||||
const meta = author.data?.metadata;
|
||||
const authorName = meta?.display_name || meta?.name || genUserName(campaign.pubkey);
|
||||
const authorPicture = sanitizeUrl(meta?.picture);
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Compact text block over the photo — always white regardless of
|
||||
// theme since the hero is always a dark-scrimed photo.
|
||||
'space-y-1.5 text-white hero-text-shadow-soft',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="text-base font-semibold leading-snug line-clamp-1">
|
||||
{campaign.title}
|
||||
</p>
|
||||
|
||||
{campaign.summary && (
|
||||
<p className="text-xs text-white/80 line-clamp-2 max-w-xs">
|
||||
{campaign.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Progress / goal. Hand-rolled instead of using <CampaignProgress>
|
||||
so we can tune the bar for legibility on top of a photo: dark
|
||||
translucent track, glowing primary fill. When the campaign has no
|
||||
goal tag, the bar is omitted entirely and we only show the raised
|
||||
total. */}
|
||||
{(() => {
|
||||
const raised = stats?.totalSats ?? 0;
|
||||
const goal = campaign.goalSats;
|
||||
const hasGoal = !!goal && goal > 0;
|
||||
const pct = hasGoal ? Math.min(100, Math.round((raised / goal!) * 100)) : 0;
|
||||
return (
|
||||
<div className="space-y-1.5 pt-1 max-w-xs">
|
||||
{hasGoal && (
|
||||
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-black/40 ring-1 ring-white/15">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-primary shadow-[0_0_8px_hsl(var(--primary)/0.7)] motion-safe:transition-[width] motion-safe:duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-baseline justify-between gap-2 text-[11px] [text-shadow:none]">
|
||||
<span className="font-semibold text-white">
|
||||
{formatCampaignAmount(raised, btcPrice)}
|
||||
{!hasGoal && <span className="ml-1 font-normal text-white/70">raised</span>}
|
||||
</span>
|
||||
{hasGoal && (
|
||||
<span className="text-white/70">
|
||||
of {formatCampaignAmount(goal!, btcPrice)} goal
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-white/75 pt-0.5">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Avatar className="size-4 ring-1 ring-white/40">
|
||||
{authorPicture && <AvatarImage src={authorPicture} alt="" />}
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{authorName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{authorName}</span>
|
||||
</span>
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="size-3" />
|
||||
<span className="truncate max-w-[16ch]">{countryLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to={`/${naddr}`}
|
||||
className="inline-flex items-center gap-1 font-medium text-primary hover:text-primary/80 focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
View
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { LAND_RINGS } from '@/lib/landPolygons';
|
||||
import { HOPE_PALETTE, type HopeHue } from '@/lib/hopePalette';
|
||||
|
||||
/** Geographic point used by the globe projection. */
|
||||
interface GeoPoint {
|
||||
/** Latitude in degrees, [-90, 90]. */
|
||||
lat: number;
|
||||
/** Longitude in degrees, [-180, 180]. */
|
||||
lng: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual variant for a globe marker. Each kind gets its own glyph + halo
|
||||
* so the three "threads" of Discover — campaigns, communities, and
|
||||
* country activity — read distinctly without needing legend chrome.
|
||||
*/
|
||||
export type GlobeMarkerKind = 'campaign' | 'community' | 'country-pulse';
|
||||
|
||||
interface CampaignMarker extends GeoPoint {
|
||||
/** Stable key for the marker (e.g. the campaign aTag). */
|
||||
key: string;
|
||||
/** Tooltip / accessible label shown on hover. */
|
||||
label?: string;
|
||||
/**
|
||||
* Visual style of this marker. Defaults to `'campaign'` so existing
|
||||
* callers (the campaigns hero) keep their heart markers unchanged.
|
||||
*/
|
||||
kind?: GlobeMarkerKind;
|
||||
}
|
||||
|
||||
interface HeroGlobeProps {
|
||||
/** Markers to plot on top of the globe — one per geo-located campaign. */
|
||||
markers?: CampaignMarker[];
|
||||
/**
|
||||
* Marker the user has selected. The selected marker gets a stronger glow
|
||||
* and a slightly larger heart so it reads as the "live" one.
|
||||
*/
|
||||
selectedKey?: string | null;
|
||||
/** Fires when the user clicks a marker. */
|
||||
onMarkerClick?: (key: string) => void;
|
||||
/**
|
||||
* Active hopeful hue. Drives the outer halo color and the back-lit
|
||||
* limb tint so the globe agrees with the surrounding {@link HeroAtmosphere}.
|
||||
*/
|
||||
hue?: HopeHue;
|
||||
/** Optional className applied to the outer container. */
|
||||
className?: string;
|
||||
/** Optional inline style applied to the outer container (e.g. fluid width via `clamp()`). */
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/** Pre-parsed land rings as arrays of {lat, lng} points. */
|
||||
const LANDMASSES: readonly GeoPoint[][] = LAND_RINGS.map((flat) => {
|
||||
const out: GeoPoint[] = [];
|
||||
for (let i = 0; i < flat.length; i += 2) {
|
||||
out.push({ lng: flat[i], lat: flat[i + 1] });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const RADIUS = 285;
|
||||
const CENTER = 300;
|
||||
/** Seconds per full revolution. Slow on purpose so the motion is ambient. */
|
||||
const ROTATION_PERIOD_SECONDS = 140;
|
||||
|
||||
/**
|
||||
* Orthographic projection: turns a (lat, lng) pair into 2D screen
|
||||
* coordinates plus a `z` depth value. Points with `z <= 0` are on the
|
||||
* back hemisphere and should be hidden (or drawn with low opacity).
|
||||
*/
|
||||
function project(lat: number, lng: number, rotationDeg: number) {
|
||||
const phi = (lat * Math.PI) / 180;
|
||||
// Subtract rotation so the globe appears to spin west-to-east.
|
||||
const lambda = ((lng - rotationDeg) * Math.PI) / 180;
|
||||
const cosPhi = Math.cos(phi);
|
||||
const x = cosPhi * Math.sin(lambda);
|
||||
const y = Math.sin(phi);
|
||||
const z = cosPhi * Math.cos(lambda);
|
||||
return {
|
||||
x: CENTER + x * RADIUS,
|
||||
// Negate so positive latitudes render upward in SVG.
|
||||
y: CENTER - y * RADIUS,
|
||||
z,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slowly-rotating SVG globe rendered with pure SVG (no WebGL, no canvas).
|
||||
*
|
||||
* Visuals are intentionally warm and hand-drawn rather than satellite/HUD:
|
||||
* - a soft cream sphere lit from the upper-left,
|
||||
* - sandy-amber landmasses (real Natural Earth continent shapes,
|
||||
* pre-simplified to ~1.5k vertices), and
|
||||
* - small glowing marker dots for active campaigns.
|
||||
*
|
||||
* Rotation is driven by `requestAnimationFrame` and applied imperatively via
|
||||
* refs so the component never re-renders during animation. Respects
|
||||
* `prefers-reduced-motion` by holding at a static angle.
|
||||
*/
|
||||
export function HeroGlobe({
|
||||
markers = [],
|
||||
selectedKey = null,
|
||||
onMarkerClick,
|
||||
hue = HOPE_PALETTE[0],
|
||||
className,
|
||||
style,
|
||||
}: HeroGlobeProps) {
|
||||
const landRef = useRef<SVGGElement | null>(null);
|
||||
const markersRef = useRef<SVGGElement | null>(null);
|
||||
|
||||
// Stable per-ring point counts so the animation loop knows how many polygon
|
||||
// elements to update without re-reading the DOM each frame.
|
||||
const ringSizes = useMemo(() => LANDMASSES.map((r) => r.length), []);
|
||||
|
||||
// Live refs so the rAF loop can read the latest markers / selection
|
||||
// without retriggering the effect — otherwise every spotlight tick
|
||||
// would tear down the loop and snap rotation back to 0°.
|
||||
const markersRefValue = useRef(markers);
|
||||
const selectedKeyRef = useRef(selectedKey);
|
||||
useEffect(() => {
|
||||
markersRefValue.current = markers;
|
||||
selectedKeyRef.current = selectedKey;
|
||||
}, [markers, selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
let rafId = 0;
|
||||
let start: number | null = null;
|
||||
|
||||
const tick = (timestamp: number) => {
|
||||
if (start === null) start = timestamp;
|
||||
const elapsedSeconds = (timestamp - start) / 1000;
|
||||
const rotation = prefersReducedMotion
|
||||
? 25 // Hold at a flattering static angle.
|
||||
: (elapsedSeconds / ROTATION_PERIOD_SECONDS) * 360;
|
||||
|
||||
// --- Landmass polygons ---
|
||||
//
|
||||
// For each ring we walk vertex-by-vertex projecting through the
|
||||
// orthographic camera. Vertices on the *front* of the sphere
|
||||
// (z > 0) are kept as-is. Vertices on the *back* (z < 0) would
|
||||
// otherwise project on top of front-side land — orthographic
|
||||
// projection collapses depth — so we drop them.
|
||||
//
|
||||
// Where a ring crosses the visible limb (front ↔ back) we emit an
|
||||
// interpolated point on the limb itself, so polygons that wrap
|
||||
// around the side of the globe close cleanly along the sphere's
|
||||
// outline instead of cutting across the disc interior.
|
||||
//
|
||||
// We also fade rings out over a narrow band near the limb so they
|
||||
// don't pop on/off when crossing z = 0. Anything with maxZ below
|
||||
// FADE_OUT is considered fully hidden; rings between FADE_OUT and
|
||||
// FADE_IN ease in/out.
|
||||
const FADE_OUT = 0.0;
|
||||
const FADE_IN = 0.08;
|
||||
const landEl = landRef.current;
|
||||
if (landEl) {
|
||||
const polygons = landEl.children;
|
||||
for (let i = 0; i < LANDMASSES.length; i++) {
|
||||
const ring = LANDMASSES[i];
|
||||
const polygon = polygons[i] as SVGPolygonElement | undefined;
|
||||
if (!polygon) continue;
|
||||
|
||||
// First pass: project every vertex, remembering z so we can
|
||||
// detect front/back transitions cheaply.
|
||||
const n = ring.length;
|
||||
const xs = new Array<number>(n);
|
||||
const ys = new Array<number>(n);
|
||||
const zs = new Array<number>(n);
|
||||
let maxZ = -1;
|
||||
for (let j = 0; j < n; j++) {
|
||||
const p = project(ring[j].lat, ring[j].lng, rotation);
|
||||
xs[j] = p.x;
|
||||
ys[j] = p.y;
|
||||
zs[j] = p.z;
|
||||
if (p.z > maxZ) maxZ = p.z;
|
||||
}
|
||||
if (maxZ <= FADE_OUT) {
|
||||
polygon.setAttribute('opacity', '0');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Second pass: emit only the visible portion. For each edge we
|
||||
// include the endpoint when it's in front, and any limb-crossing
|
||||
// we step over gets an interpolated point on the sphere edge.
|
||||
const parts: string[] = [];
|
||||
for (let j = 0; j < n; j++) {
|
||||
const k = (j + 1) % n;
|
||||
const zj = zs[j];
|
||||
const zk = zs[k];
|
||||
if (zj > 0) parts.push(`${xs[j].toFixed(1)},${ys[j].toFixed(1)}`);
|
||||
if ((zj > 0) !== (zk > 0)) {
|
||||
// Find the parameter t in [0,1] along this edge where z=0.
|
||||
const t = zj / (zj - zk);
|
||||
const ex = xs[j] + (xs[k] - xs[j]) * t;
|
||||
const ey = ys[j] + (ys[k] - ys[j]) * t;
|
||||
// Project the limb point onto the actual sphere edge so it
|
||||
// never lands inside the disc.
|
||||
const dx = ex - CENTER;
|
||||
const dy = ey - CENTER;
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
const lx = CENTER + (dx / d) * RADIUS;
|
||||
const ly = CENTER + (dy / d) * RADIUS;
|
||||
parts.push(`${lx.toFixed(1)},${ly.toFixed(1)}`);
|
||||
}
|
||||
}
|
||||
if (parts.length < 3) {
|
||||
polygon.setAttribute('opacity', '0');
|
||||
continue;
|
||||
}
|
||||
polygon.setAttribute('points', parts.join(' '));
|
||||
// Smooth fade as rings come around the limb. `fade` clamps to
|
||||
// [0,1] over the narrow FADE_OUT→FADE_IN band, then we keep
|
||||
// adding the small depth-based dimming used before.
|
||||
const fade = Math.min(1, Math.max(0, (maxZ - FADE_OUT) / (FADE_IN - FADE_OUT)));
|
||||
polygon.setAttribute('opacity', (fade * Math.min(1, 0.55 + maxZ * 0.55)).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Campaign markers ---
|
||||
const markersEl = markersRef.current;
|
||||
const liveMarkers = markersRefValue.current;
|
||||
const liveSelectedKey = selectedKeyRef.current;
|
||||
if (markersEl) {
|
||||
const groups = markersEl.children;
|
||||
for (let i = 0; i < liveMarkers.length; i++) {
|
||||
const m = liveMarkers[i];
|
||||
const group = groups[i] as SVGGElement | undefined;
|
||||
if (!group) continue;
|
||||
const p = project(m.lat, m.lng, rotation);
|
||||
if (p.z <= 0) {
|
||||
group.setAttribute('opacity', '0');
|
||||
// Pull off-canvas so backside markers don't intercept clicks.
|
||||
group.setAttribute('transform', 'translate(-1000 -1000)');
|
||||
continue;
|
||||
}
|
||||
// Selected marker scales up subtly to read as "you are here".
|
||||
const scale = m.key === liveSelectedKey ? 1.35 : 1;
|
||||
group.setAttribute(
|
||||
'transform',
|
||||
`translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) scale(${scale})`,
|
||||
);
|
||||
group.setAttribute('opacity', (0.55 + p.z * 0.45).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
// `markers` and `selectedKey` are read inside `tick` via refs above,
|
||||
// so we deliberately omit them from this dep list to keep the
|
||||
// rotation loop alive across spotlight cycles.
|
||||
}, [ringSizes]);
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{/* Wrapper so the outer halo can sit behind the SVG. The halo is a
|
||||
plain div (not part of the SVG) so its blur extends past the
|
||||
sphere without needing a giant viewBox, and so we can drive it
|
||||
with a CSS keyframe animation independent of the rotation. */}
|
||||
<div className="relative size-full">
|
||||
{/* Outer atmospheric halo. Scaled larger than the wrapper so light
|
||||
spills out into the photo, blurred for softness, and tinted
|
||||
with the active campaign's hopeful hue. Breathes slowly via
|
||||
the .hero-globe-halo-breath class defined in index.css. */}
|
||||
<div
|
||||
className="hero-globe-halo-breath absolute inset-[-15%] pointer-events-none"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(closest-side, ${hue.glow} 0%, ${hue.rim} 30%, transparent 70%)`,
|
||||
filter: 'blur(40px)',
|
||||
// background-image isn't actually transitionable across
|
||||
// gradient stops in CSS, but keeping the declaration here
|
||||
// documents that the hue swap is driven by React re-renders
|
||||
// synced to the HeroAtmosphere crossfade.
|
||||
}}
|
||||
/>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 600 600"
|
||||
className="relative size-full"
|
||||
role="img"
|
||||
aria-label="Globe showing locations of active fundraising campaigns"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
{/* Sphere base: warm dawn gold lit from the upper-left, fading
|
||||
into a deeper honey shadow on the lower-right. The whole
|
||||
sphere is meant to read as "lit from within" — like the
|
||||
moment before sunrise — not as a slab of dirt. */}
|
||||
<radialGradient id="hero-globe-base" cx="32%" cy="28%" r="78%">
|
||||
<stop offset="0%" stopColor="hsl(46 100% 96% / 0.92)" />
|
||||
<stop offset="40%" stopColor="hsl(38 90% 82% / 0.82)" />
|
||||
<stop offset="100%" stopColor="hsl(28 65% 60% / 0.72)" />
|
||||
</radialGradient>
|
||||
{/* Back-lit limb light. Reads as light pooling on the inside of
|
||||
the sphere edge — Earthrise rather than satellite. Tinted
|
||||
with the active hopeful hue, kept narrow + low-opacity so it
|
||||
feels like atmosphere, not a neon ring. */}
|
||||
<radialGradient id="hero-globe-rim" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="86%" stopColor={hue.rim} stopOpacity="0" />
|
||||
<stop offset="97%" stopColor={hue.rim} stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor={hue.glow} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
{/* Soft highlight in the upper-left to sell the sphere shape. */}
|
||||
<radialGradient id="hero-globe-highlight" cx="30%" cy="25%" r="35%">
|
||||
<stop offset="0%" stopColor="hsl(50 100% 98% / 0.58)" />
|
||||
<stop offset="100%" stopColor="hsl(50 100% 98% / 0)" />
|
||||
</radialGradient>
|
||||
{/* Marker glow halo. Soft, warm, no pulsing. */}
|
||||
<radialGradient id="hero-marker-glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.55" />
|
||||
<stop offset="70%" stopColor="hsl(var(--primary))" stopOpacity="0.12" />
|
||||
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
{/* Stronger halo used for the selected marker so it visibly leads
|
||||
the eye to whatever the spotlight card is currently showing. */}
|
||||
<radialGradient id="hero-marker-glow-active" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
|
||||
<stop offset="55%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
{/* Clip everything to the sphere so polygons straddling the
|
||||
terminator don't leak outside the circle. */}
|
||||
<clipPath id="hero-globe-clip">
|
||||
<circle cx={CENTER} cy={CENTER} r={RADIUS} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
{/* Base sphere with light shading. */}
|
||||
<circle cx={CENTER} cy={CENTER} r={RADIUS} fill="url(#hero-globe-base)" />
|
||||
|
||||
{/* Landmasses, clipped to the sphere. */}
|
||||
<g clipPath="url(#hero-globe-clip)">
|
||||
<g
|
||||
ref={landRef}
|
||||
fill="hsl(30 55% 52%)"
|
||||
stroke="hsl(28 50% 40% / 0.25)"
|
||||
strokeWidth="0.3"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{LANDMASSES.map((_, i) => (
|
||||
<polygon key={i} opacity={0} />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Warm highlight + rim shading sit above the land so the sphere
|
||||
still reads as a lit ball, not a flat map. */}
|
||||
<circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="url(#hero-globe-highlight)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="url(#hero-globe-rim)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* Campaign markers — a small heart glyph with a warm glow halo.
|
||||
Each marker is a button: clicking selects the campaign, which
|
||||
the parent uses to populate the spotlight card.
|
||||
|
||||
On the Discover page the same `<g>` slots are reused for
|
||||
community and country-pulse markers, distinguished by `m.kind`
|
||||
and rendered with a softer glyph + halo so campaigns stay the
|
||||
visual lead. */}
|
||||
<g ref={markersRef}>
|
||||
{markers.map((m) => {
|
||||
const isSelected = m.key === selectedKey;
|
||||
const kind: GlobeMarkerKind = m.kind ?? 'campaign';
|
||||
return (
|
||||
<g
|
||||
key={m.key}
|
||||
opacity={0}
|
||||
transform="translate(-1000 -1000)"
|
||||
role={onMarkerClick ? 'button' : undefined}
|
||||
tabIndex={onMarkerClick ? 0 : undefined}
|
||||
aria-label={m.label ?? 'View campaign'}
|
||||
aria-pressed={onMarkerClick ? isSelected : undefined}
|
||||
onClick={onMarkerClick ? () => onMarkerClick(m.key) : undefined}
|
||||
onKeyDown={
|
||||
onMarkerClick
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onMarkerClick(m.key);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
cursor: onMarkerClick ? 'pointer' : undefined,
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{kind === 'campaign' ? (
|
||||
<>
|
||||
{/* Glow halo (stronger for the active marker). */}
|
||||
<circle
|
||||
r={isSelected ? 16 : 12}
|
||||
fill={`url(#hero-marker-glow${isSelected ? '-active' : ''})`}
|
||||
/>
|
||||
{/* Heart glyph. Path is centered at the origin (~14×12 units)
|
||||
so the parent <g>'s translate+scale lands it on the globe. */}
|
||||
<path
|
||||
d="M0,3.5 C-3.5,1 -7,-1.5 -7,-4.5 C-7,-7 -5,-8.5 -3,-8.5 C-1.5,-8.5 -0.5,-7.5 0,-6.5 C0.5,-7.5 1.5,-8.5 3,-8.5 C5,-8.5 7,-7 7,-4.5 C7,-1.5 3.5,1 0,3.5 Z"
|
||||
fill="hsl(var(--primary))"
|
||||
stroke="hsl(40 100% 98%)"
|
||||
strokeWidth="0.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Tiny inner highlight to make the heart pop on the warm
|
||||
landmass without needing a heavy outline. */}
|
||||
<ellipse cx={-2.5} cy={-5.5} rx={1.5} ry={1} fill="hsl(40 100% 98% / 0.55)" />
|
||||
</>
|
||||
) : kind === 'community' ? (
|
||||
<>
|
||||
{/* Community: a softly-glowing ring. Reads as a circle of
|
||||
people, gathered. Smaller than the heart so campaigns
|
||||
stay the dominant signal. */}
|
||||
<circle r={10} fill="url(#hero-marker-glow)" />
|
||||
<circle
|
||||
r={4.2}
|
||||
fill="hsl(40 100% 96% / 0.92)"
|
||||
stroke="hsl(28 65% 45% / 0.55)"
|
||||
strokeWidth="0.7"
|
||||
/>
|
||||
<circle r={1.4} fill="hsl(28 70% 50%)" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Country pulse: tiny warm sun-dot, no halo button feel.
|
||||
These are decorative — they trace where the world is
|
||||
currently posting without inviting interaction. */}
|
||||
<circle r={6} fill="url(#hero-marker-glow)" opacity={0.65} />
|
||||
<circle r={1.8} fill="hsl(38 100% 70%)" />
|
||||
</>
|
||||
)}
|
||||
{/* Transparent hit target — much easier to click/tap than the
|
||||
tiny visible glyph, especially on touch. */}
|
||||
<circle
|
||||
r={14}
|
||||
fill="transparent"
|
||||
style={{ cursor: onMarkerClick ? 'pointer' : 'default' }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
import { useInitialSync } from "@/hooks/useInitialSync";
|
||||
|
||||
/**
|
||||
* Non-rendering component that runs the initial sync side effects
|
||||
* (seeding relay list, blossom servers, encrypted settings, mute list
|
||||
* into the query cache and app config) when a user logs in.
|
||||
*
|
||||
* Mounted at the top of the React tree so it runs in parallel with the
|
||||
* rest of the app — it does NOT block render. NostrSync continues to
|
||||
* keep settings up to date in the background after the initial pass.
|
||||
*/
|
||||
export function InitialSyncRunner() {
|
||||
useInitialSync();
|
||||
return null;
|
||||
}
|
||||
@@ -4,11 +4,10 @@ import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface LandingHeroProps {
|
||||
onLoginClick: () => void;
|
||||
onSignupClick: () => void;
|
||||
onJoinClick: () => void;
|
||||
}
|
||||
|
||||
export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
|
||||
export function LandingHero({ onJoinClick }: LandingHeroProps) {
|
||||
return (
|
||||
<div className="landing-hero">
|
||||
{/* ── Hero Header ── */}
|
||||
@@ -27,11 +26,8 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-center landing-hero-fade" style={{ animationDelay: '160ms' }}>
|
||||
<Button onClick={onSignupClick} className="rounded-full px-6" size="sm">
|
||||
Sign up
|
||||
</Button>
|
||||
<Button onClick={onLoginClick} variant="outline" className="rounded-full px-6" size="sm">
|
||||
Log in
|
||||
<Button onClick={onJoinClick} className="rounded-full px-6" size="sm">
|
||||
Join
|
||||
</Button>
|
||||
<Button variant="outline" className="rounded-full px-6" size="sm" asChild>
|
||||
<Link to="/help">FAQ</Link>
|
||||
|
||||
@@ -13,9 +13,8 @@ import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
|
||||
import { SidebarNavList } from '@/components/SidebarNavItem';
|
||||
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
|
||||
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import AuthDialog from '@/components/auth/AuthDialog';
|
||||
import { FollowQRDialog } from '@/components/FollowQRDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
@@ -75,7 +74,6 @@ export function LeftSidebar() {
|
||||
const hasUnread = useHasUnreadNotifications();
|
||||
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const { startSignup } = useOnboarding();
|
||||
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
|
||||
const [followQROpen, setFollowQROpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -340,7 +338,7 @@ export function LeftSidebar() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LoginDialog isOpen={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} onLogin={() => setLoginDialogOpen(false)} onSignupClick={startSignup} />
|
||||
<AuthDialog isOpen={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} />
|
||||
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
export interface LightningTriggerOptions {
|
||||
/** Number of clustered strikes to fire. */
|
||||
strikes?: number;
|
||||
}
|
||||
|
||||
export interface LightningEffectHandle {
|
||||
triggerLightning: (options?: LightningTriggerOptions) => void;
|
||||
}
|
||||
|
||||
interface LightningEffectProps {
|
||||
/** Manual is one-shot only; weather auto-triggers intermittent strikes. */
|
||||
mode?: 'manual' | 'weather';
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
from: Point;
|
||||
to: Point;
|
||||
}
|
||||
|
||||
interface Strike {
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
segments: Segment[];
|
||||
}
|
||||
|
||||
const MAX_SEGMENTS = 80;
|
||||
|
||||
function randomBetween(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
}
|
||||
|
||||
function addDisplacedSegments(
|
||||
segments: Segment[],
|
||||
from: Point,
|
||||
to: Point,
|
||||
depth: number,
|
||||
offset: number,
|
||||
): void {
|
||||
if (segments.length >= MAX_SEGMENTS) return;
|
||||
if (depth <= 0) {
|
||||
segments.push({ from, to });
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
const normalX = -dy / length;
|
||||
const normalY = dx / length;
|
||||
const midpoint = {
|
||||
x: (from.x + to.x) / 2 + normalX * randomBetween(-offset, offset),
|
||||
y: (from.y + to.y) / 2 + normalY * randomBetween(-offset, offset),
|
||||
};
|
||||
|
||||
addDisplacedSegments(segments, from, midpoint, depth - 1, offset * 0.52);
|
||||
addDisplacedSegments(segments, midpoint, to, depth - 1, offset * 0.52);
|
||||
|
||||
if (segments.length < MAX_SEGMENTS && Math.random() < 0.36) {
|
||||
const angle = Math.atan2(dy, dx) + randomBetween(-0.95, 0.95);
|
||||
const branchLength = length * randomBetween(0.25, 0.55);
|
||||
const branchEnd = {
|
||||
x: midpoint.x + Math.cos(angle) * branchLength,
|
||||
y: midpoint.y + Math.sin(angle) * branchLength,
|
||||
};
|
||||
addDisplacedSegments(segments, midpoint, branchEnd, depth - 1, offset * 0.42);
|
||||
}
|
||||
}
|
||||
|
||||
function createStrike(width: number, height: number): Strike {
|
||||
const start = {
|
||||
x: randomBetween(width * 0.08, width * 0.92),
|
||||
y: randomBetween(-height * 0.04, height * 0.14),
|
||||
};
|
||||
const end = {
|
||||
x: start.x + randomBetween(-width * 0.35, width * 0.35),
|
||||
y: randomBetween(height * 0.72, height * 1.05),
|
||||
};
|
||||
const segments: Segment[] = [];
|
||||
const length = Math.hypot(end.x - start.x, end.y - start.y);
|
||||
addDisplacedSegments(segments, start, end, 4, length * 0.15);
|
||||
|
||||
return {
|
||||
startedAt: performance.now(),
|
||||
duration: randomBetween(320, 480),
|
||||
segments,
|
||||
};
|
||||
}
|
||||
|
||||
function opacityFor(age: number, duration: number): number {
|
||||
if (age < 60) return age / 60;
|
||||
if (age < 140) return 1;
|
||||
return Math.max(0, 1 - (age - 140) / (duration - 140));
|
||||
}
|
||||
|
||||
export const LightningEffect = forwardRef<LightningEffectHandle, LightningEffectProps>(
|
||||
function LightningEffect({ mode = 'manual' }, ref) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rafRef = useRef(0);
|
||||
const strikesRef = useRef<Strike[]>([]);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const weatherTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const resize = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.floor(window.innerWidth * dpr);
|
||||
canvas.height = Math.floor(window.innerHeight * dpr);
|
||||
canvas.style.width = `${window.innerWidth}px`;
|
||||
canvas.style.height = `${window.innerHeight}px`;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}, []);
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
|
||||
|
||||
const now = performance.now();
|
||||
strikesRef.current = strikesRef.current.filter((strike) => now - strike.startedAt <= strike.duration);
|
||||
|
||||
for (const strike of strikesRef.current) {
|
||||
const age = now - strike.startedAt;
|
||||
const opacity = opacityFor(age, strike.duration);
|
||||
|
||||
if (age < 45) {
|
||||
ctx.fillStyle = `rgba(255,255,255,${0.03 * opacity})`;
|
||||
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
|
||||
}
|
||||
|
||||
const passes = [
|
||||
{ width: 8, alpha: 0.15, shadow: 0 },
|
||||
{ width: 3, alpha: 0.4, shadow: 0 },
|
||||
{ width: 1, alpha: 1, shadow: 12 },
|
||||
];
|
||||
|
||||
for (const pass of passes) {
|
||||
ctx.save();
|
||||
ctx.lineWidth = pass.width;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = pass.width === 1
|
||||
? `rgba(255,255,255,${opacity})`
|
||||
: `rgba(192,232,255,${pass.alpha * opacity})`;
|
||||
ctx.shadowBlur = pass.shadow;
|
||||
ctx.shadowColor = '#88ccff';
|
||||
|
||||
ctx.beginPath();
|
||||
for (const segment of strike.segments) {
|
||||
ctx.moveTo(segment.from.x, segment.from.y);
|
||||
ctx.lineTo(segment.to.x, segment.to.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
if (strikesRef.current.length > 0) {
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
} else {
|
||||
rafRef.current = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startStrike = useCallback(() => {
|
||||
if (prefersReducedMotion()) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
strikesRef.current.push(createStrike(window.innerWidth, window.innerHeight));
|
||||
if (!rafRef.current) rafRef.current = requestAnimationFrame(draw);
|
||||
}, [draw]);
|
||||
|
||||
const clearScheduledTimers = useCallback(() => {
|
||||
for (const timer of timersRef.current) clearTimeout(timer);
|
||||
timersRef.current = [];
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
triggerLightning: (options) => {
|
||||
const count = Math.max(1, Math.min(options?.strikes ?? 1, 5));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const timer = setTimeout(startStrike, i * randomBetween(70, 130));
|
||||
timersRef.current.push(timer);
|
||||
}
|
||||
},
|
||||
}), [startStrike]);
|
||||
|
||||
useEffect(() => {
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
clearScheduledTimers();
|
||||
if (weatherTimerRef.current) clearTimeout(weatherTimerRef.current);
|
||||
};
|
||||
}, [clearScheduledTimers, resize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'weather') return;
|
||||
|
||||
const schedule = () => {
|
||||
weatherTimerRef.current = setTimeout(() => {
|
||||
startStrike();
|
||||
schedule();
|
||||
}, randomBetween(1500, 4000));
|
||||
};
|
||||
schedule();
|
||||
return () => {
|
||||
if (weatherTimerRef.current) clearTimeout(weatherTimerRef.current);
|
||||
weatherTimerRef.current = null;
|
||||
};
|
||||
}, [mode, startStrike]);
|
||||
|
||||
return <canvas ref={canvasRef} className="pointer-events-none fixed inset-0 z-[300]" aria-hidden="true" />;
|
||||
},
|
||||
);
|
||||
@@ -40,6 +40,11 @@ export function LinkFooter({ onNavigate }: LinkFooterProps) {
|
||||
Privacy
|
||||
</Link>
|
||||
|
||||
<Link to="/safety" className={chipClass} onClick={onNavigate}>
|
||||
<Shield className={iconClass} />
|
||||
Safety
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href="https://gitlab.com/soapbox-pub/agora-3"
|
||||
className={chipClass}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Outlet } from 'react-router-dom';
|
||||
import { LeftSidebar } from '@/components/LeftSidebar';
|
||||
import { MobileTopBar } from '@/components/MobileTopBar';
|
||||
import { MobileDrawer } from '@/components/MobileDrawer';
|
||||
import { MobileBottomNav } from '@/components/MobileBottomNav';
|
||||
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
|
||||
import { CursorFireEffect } from '@/components/CursorFireEffect';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -51,7 +50,7 @@ function PageSkeleton() {
|
||||
|
||||
/** Inner component that reads layout options from the context store. */
|
||||
function MainLayoutInner() {
|
||||
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
|
||||
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar } = useLayoutSnapshot();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||
const centerColumnRef = useRef<HTMLDivElement>(null);
|
||||
@@ -109,16 +108,13 @@ function MainLayoutInner() {
|
||||
(unset) falls back to the default. We distinguish these because
|
||||
`??` would otherwise treat `null` the same as unset and render
|
||||
the default — which silently breaks pages that intend to be
|
||||
full-bleed (e.g. /world, /messages). */}
|
||||
full-bleed (e.g. /world). */}
|
||||
{rightSidebar === undefined
|
||||
? <Suspense fallback={<div className="w-[300px] shrink-0 hidden xl:block" />}><WidgetSidebar /></Suspense>
|
||||
: rightSidebar}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom nav - only on small screens, slides out on scroll */}
|
||||
{!hideBottomNav && <MobileBottomNav />}
|
||||
|
||||
{/* Mobile FAB — fixed to viewport, hidden on desktop where the
|
||||
in-column sticky FAB (above) takes over. Mirrors bottom nav
|
||||
hide/show transition on scroll. */}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Shield, ShieldOff } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MembersOnlyToggleProps {
|
||||
/** Additional classes for the trigger button. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shield-icon toggle that controls the "members only" filter for community
|
||||
* surfaces. When active, community feeds only show content authored by
|
||||
* validated members. When inactive (default), the feed shows every event
|
||||
* scoped to the community regardless of author.
|
||||
*
|
||||
* Per the flat-communities spec, members-only is a MAY feature — the
|
||||
* protocol makes no recommendation, so the toggle is an opt-in UX choice.
|
||||
*
|
||||
* The preference is persisted in localStorage via `useMembersOnlyFilter` and
|
||||
* is global across community surfaces (Activities feed, per-community
|
||||
* Comments tab, etc.).
|
||||
*/
|
||||
export function MembersOnlyToggle({ className }: MembersOnlyToggleProps) {
|
||||
const { membersOnly, toggle } = useMembersOnlyFilter();
|
||||
|
||||
const label = membersOnly ? 'Showing members only' : 'Showing everyone';
|
||||
const hint = membersOnly
|
||||
? 'Click to show posts from anyone scoped to this community.'
|
||||
: 'Click to limit posts to validated community members.';
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-pressed={membersOnly}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'p-2 rounded-full transition-colors',
|
||||
membersOnly
|
||||
? 'text-primary hover:bg-primary/10'
|
||||
: 'text-muted-foreground hover:bg-secondary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{membersOnly
|
||||
? <Shield className="size-5" />
|
||||
: <ShieldOff className="size-5" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[220px] text-center">
|
||||
<p className="text-xs font-medium">{label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{hint}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, Earth, Search, Users } from 'lucide-react';
|
||||
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -8,9 +7,7 @@ import { selectionChanged } from '@/lib/haptics';
|
||||
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutSnapshot } from '@/contexts/LayoutContext';
|
||||
import { getSidebarItem } from '@/lib/sidebarItems';
|
||||
import { ArcBackground, ARC_UP_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import { MobileSearchSheet } from '@/components/MobileSearchSheet';
|
||||
|
||||
@@ -55,16 +52,11 @@ function NavItem({ icon: Icon, label, active, badge, onClick, to, size = 'md' }:
|
||||
|
||||
export function MobileBottomNav() {
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useCurrentUser();
|
||||
const hasUnread = useHasUnreadNotifications();
|
||||
const { scrollContainer, noArcs } = useLayoutSnapshot();
|
||||
const { hidden } = useScrollDirection(scrollContainer);
|
||||
|
||||
const { config } = useAppContext();
|
||||
const homeItem = getSidebarItem(config.homePage);
|
||||
const homePath = homeItem?.path ?? '/';
|
||||
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
const handleSearchClick = useCallback((e: React.MouseEvent) => {
|
||||
@@ -73,21 +65,19 @@ export function MobileBottomNav() {
|
||||
setSearchOpen((v) => !v);
|
||||
}, []);
|
||||
|
||||
const handleFeedClick = useCallback((e: React.MouseEvent) => {
|
||||
const handleWalletClick = useCallback((e: React.MouseEvent) => {
|
||||
selectionChanged();
|
||||
setSearchOpen(false);
|
||||
if (location.pathname === '/' || location.pathname === homePath) {
|
||||
if (location.pathname === '/wallet') {
|
||||
e.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
void queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['ditto-curated-feed'] });
|
||||
}
|
||||
}, [location.pathname, homePath, queryClient]);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Hide the nav when search sheet is open so it doesn't compete for space
|
||||
const isHidden = hidden || searchOpen;
|
||||
|
||||
const isOnFeed = location.pathname === '/' || location.pathname === homePath;
|
||||
const isOnWallet = location.pathname === '/wallet';
|
||||
const isOnCommunities = location.pathname === '/communities' || location.pathname.startsWith('/communities/');
|
||||
const isOnWorld = location.pathname === '/world' || location.pathname.startsWith('/world/');
|
||||
const isOnNotifications = location.pathname === '/notifications';
|
||||
@@ -98,7 +88,7 @@ export function MobileBottomNav() {
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 right-0 z-40 sidebar:hidden will-change-transform',
|
||||
'fixed bottom-0 left-0 right-0 z-40 will-change-transform',
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
)}
|
||||
style={isHidden ? hiddenStyle : undefined}
|
||||
@@ -117,10 +107,10 @@ export function MobileBottomNav() {
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Communities */}
|
||||
{/* Organizations */}
|
||||
<NavItem
|
||||
icon={Users}
|
||||
label="Communities"
|
||||
label="Organize"
|
||||
active={isOnCommunities}
|
||||
to="/communities"
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
@@ -151,11 +141,11 @@ export function MobileBottomNav() {
|
||||
|
||||
</div>
|
||||
|
||||
{/* Apex Feed button — Agora bolt mark cradled in the V notch, with label below. */}
|
||||
{/* Apex Wallet button — Agora bolt mark cradled in the V notch. */}
|
||||
<Link
|
||||
to={homePath}
|
||||
onClick={handleFeedClick}
|
||||
aria-label={homeItem?.label ?? 'Feed'}
|
||||
to="/wallet"
|
||||
onClick={handleWalletClick}
|
||||
aria-label="Wallet"
|
||||
className={cn(
|
||||
'absolute left-1/2 -translate-x-1/2 z-10 -top-6',
|
||||
'flex items-center',
|
||||
@@ -165,7 +155,7 @@ export function MobileBottomNav() {
|
||||
<AgoraBoltIcon
|
||||
className={cn(
|
||||
'size-16 drop-shadow-md',
|
||||
isOnFeed && 'drop-shadow-[0_0_8px_hsl(var(--primary)/0.6)]',
|
||||
isOnWallet && 'drop-shadow-[0_0_8px_hsl(var(--primary)/0.6)]',
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
@@ -9,9 +9,8 @@ import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { LinkFooter } from '@/components/LinkFooter';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import AuthDialog from '@/components/auth/AuthDialog';
|
||||
import { FollowQRDialog } from '@/components/FollowQRDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -51,7 +50,6 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
const [accountExpanded, setAccountExpanded] = useState(false);
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const [followQROpen, setFollowQROpen] = useState(false);
|
||||
const { startSignup } = useOnboarding();
|
||||
const { theme, customTheme, themes } = useTheme();
|
||||
|
||||
// NIP-38 status
|
||||
@@ -369,11 +367,9 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<LoginDialog
|
||||
<AuthDialog
|
||||
isOpen={loginDialogOpen}
|
||||
onClose={() => setLoginDialogOpen(false)}
|
||||
onLogin={() => setLoginDialogOpen(false)}
|
||||
onSignupClick={startSignup}
|
||||
/>
|
||||
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
|
||||
</>
|
||||
|
||||
@@ -392,38 +392,6 @@ export function NostrSync() {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (encryptedSettings.messaging) {
|
||||
type MessagingSettings = NonNullable<typeof current.messaging>;
|
||||
const currentMessaging: MessagingSettings = current.messaging ?? {};
|
||||
const remoteMessaging = encryptedSettings.messaging;
|
||||
const scalarKeys: Array<Exclude<keyof MessagingSettings, "discoveryRelays">> = [
|
||||
"enabled",
|
||||
"relayMode",
|
||||
"protocolMode",
|
||||
"renderInlineMedia",
|
||||
"soundEnabled",
|
||||
"soundId",
|
||||
"devMode",
|
||||
];
|
||||
|
||||
const scalarChanged = scalarKeys.some((key) => {
|
||||
const incoming = remoteMessaging[key];
|
||||
return incoming !== undefined && incoming !== currentMessaging[key];
|
||||
});
|
||||
|
||||
const discoveryRelaysChanged =
|
||||
remoteMessaging.discoveryRelays !== undefined &&
|
||||
JSON.stringify(remoteMessaging.discoveryRelays) !==
|
||||
JSON.stringify(currentMessaging.discoveryRelays);
|
||||
|
||||
const messagingChanged = scalarChanged || discoveryRelaysChanged;
|
||||
|
||||
if (messagingChanged) {
|
||||
updates.messaging = { ...currentMessaging, ...remoteMessaging };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the same reference if nothing changed to prevent re-render
|
||||
return changed ? updates : current;
|
||||
});
|
||||
|
||||
+164
-82
@@ -92,7 +92,10 @@ import { useProfileUrl } from "@/hooks/useProfileUrl";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useEventStats } from "@/hooks/useTrending";
|
||||
import { canZap } from "@/lib/canZap";
|
||||
import { extractZapAmount, extractZapSender, extractZapMessage } from "@/hooks/useEventInteractions";
|
||||
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";
|
||||
@@ -243,6 +246,10 @@ 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. */
|
||||
commentContextPrefix?: string;
|
||||
/** Event used for actions/navigation when the displayed card is synthetic. */
|
||||
actionEvent?: NostrEvent;
|
||||
}
|
||||
|
||||
/** Gets a tag value by name. */
|
||||
@@ -306,6 +313,58 @@ 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,
|
||||
@@ -316,19 +375,28 @@ export const NoteCard = memo(function NoteCard({
|
||||
threadedLast,
|
||||
highlight,
|
||||
hideKindHeader,
|
||||
commentContextPrefix,
|
||||
actionEvent,
|
||||
}: NoteCardProps) {
|
||||
const actionTarget = actionEvent ?? event;
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const zapSenderPubkey = useMemo(() => event.kind === 9735 ? extractZapSender(event) : '', [event]);
|
||||
const zapSender = useAuthor(zapSenderPubkey || undefined);
|
||||
const zapSenderMeta = zapSender.data?.metadata;
|
||||
const zapSenderName = getDisplayName(zapSenderMeta, zapSenderPubkey);
|
||||
const zapSenderUrl = useProfileUrl(zapSenderPubkey, zapSenderMeta);
|
||||
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 nip05 = metadata?.nip05;
|
||||
const { data: nip05Verified, isPending: nip05Pending } = useNip05Verify(
|
||||
@@ -336,14 +404,17 @@ export const NoteCard = memo(function NoteCard({
|
||||
event.pubkey,
|
||||
);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const encodedId = useMemo(() => encodeEventId(event), [event]);
|
||||
const { data: stats } = useEventStats(event.id, event);
|
||||
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);
|
||||
|
||||
// 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(metadata) && !hasGoalZapSplits(event);
|
||||
const canZapAuthor = user && canZap(actionMetadata) && !hasGoalZapSplits(actionTarget);
|
||||
|
||||
const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost(
|
||||
`/${encodedId}`,
|
||||
@@ -428,7 +499,7 @@ 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;
|
||||
const isZap = event.kind === 9735 || event.kind === 8333;
|
||||
const isProfile = event.kind === 0;
|
||||
const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite;
|
||||
const isTextNote =
|
||||
@@ -553,7 +624,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
const contentBlock = (
|
||||
<CommunityContentWarning event={event}>
|
||||
{/* Reply context (kind 1) or comment context (kind 1111) — shown above content */}
|
||||
{isComment && <CommentContext event={event} />}
|
||||
{isComment && <CommentContext event={event} prefix={commentContextPrefix} />}
|
||||
{isReply && (
|
||||
<ReplyContext
|
||||
pubkeys={replyToPubkeys}
|
||||
@@ -745,53 +816,59 @@ export const NoteCard = memo(function NoteCard({
|
||||
|
||||
// ── Shared action buttons (used in all layouts) ──
|
||||
const actionButtons = (
|
||||
<div className="flex items-center gap-5 mt-3 -ml-2">
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mt-3">
|
||||
<button
|
||||
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Reply"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setReplyOpen(true);
|
||||
}}
|
||||
>
|
||||
<MessageCircle className="size-5" />
|
||||
{stats?.replies ? (
|
||||
<span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span>
|
||||
<MessageCircle className="size-[18px]" />
|
||||
{stats?.replies ? (
|
||||
<span className="tabular-nums">{formatNumber(stats.replies)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
<RepostMenu event={actionTarget}>
|
||||
{(isReposted: boolean) => (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium transition-colors",
|
||||
isReposted
|
||||
? "text-accent hover:text-accent/80 hover:bg-accent/10"
|
||||
: "text-muted-foreground hover:text-accent hover:bg-accent/10",
|
||||
)}
|
||||
title={isReposted ? "Undo repost" : "Repost"}
|
||||
>
|
||||
<RepostIcon className="size-[18px]" />
|
||||
{stats?.reposts || stats?.quotes ? (
|
||||
<span className="tabular-nums">
|
||||
{formatNumber((stats?.reposts ?? 0) + (stats?.quotes ?? 0))}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
</RepostMenu>
|
||||
|
||||
<RepostMenu event={event}>
|
||||
{(isReposted: boolean) => (
|
||||
<button
|
||||
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? "text-accent hover:text-accent/80 hover:bg-accent/10" : "text-muted-foreground hover:text-accent hover:bg-accent/10"}`}
|
||||
title={isReposted ? "Undo repost" : "Repost"}
|
||||
>
|
||||
<RepostIcon className="size-5" />
|
||||
{stats?.reposts || stats?.quotes ? (
|
||||
<span className="text-sm tabular-nums">
|
||||
{formatNumber((stats?.reposts ?? 0) + (stats?.quotes ?? 0))}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
</RepostMenu>
|
||||
|
||||
<ReactionButton
|
||||
eventId={event.id}
|
||||
eventPubkey={event.pubkey}
|
||||
eventKind={event.kind}
|
||||
<ReactionButton
|
||||
eventId={actionTarget.id}
|
||||
eventPubkey={actionTarget.pubkey}
|
||||
eventKind={actionTarget.kind}
|
||||
reactionCount={stats?.reactions}
|
||||
variant="chip"
|
||||
/>
|
||||
|
||||
{canZapAuthor && (
|
||||
<ZapDialog target={event}>
|
||||
<ZapDialog target={actionTarget}>
|
||||
<button
|
||||
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
|
||||
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="Zap"
|
||||
>
|
||||
<Zap className="size-5" />
|
||||
<Zap className="size-[18px]" />
|
||||
{stats?.zapAmount ? (
|
||||
<span className="text-sm tabular-nums">
|
||||
<span className="tabular-nums">
|
||||
{formatNumber(stats.zapAmount)}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -799,8 +876,10 @@ export const NoteCard = memo(function NoteCard({
|
||||
</ZapDialog>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
|
||||
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
|
||||
title="Share"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -810,18 +889,18 @@ export const NoteCard = memo(function NoteCard({
|
||||
if (result === "copied") toast({ title: "Link copied to clipboard" });
|
||||
}}
|
||||
>
|
||||
<Share2 className="size-5" />
|
||||
<Share2 className="size-[18px]" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="More"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMoreMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
<MoreHorizontal className="size-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -852,8 +931,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
{!compact && (
|
||||
<>
|
||||
{actionButtons}
|
||||
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
||||
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
|
||||
<NoteMoreMenu event={actionTarget} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
||||
<ReplyComposeModal event={actionTarget} open={replyOpen} onOpenChange={setReplyOpen} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -876,12 +955,12 @@ export const NoteCard = memo(function NoteCard({
|
||||
<>
|
||||
{actionButtons}
|
||||
<NoteMoreMenu
|
||||
event={event}
|
||||
event={actionTarget}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
<ReplyComposeModal
|
||||
event={event}
|
||||
event={actionTarget}
|
||||
open={replyOpen}
|
||||
onOpenChange={setReplyOpen}
|
||||
/>
|
||||
@@ -931,33 +1010,36 @@ export const NoteCard = memo(function NoteCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Zap receipt layout (kind 9735) ──
|
||||
// ── 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 = Math.floor(extractZapAmount(event) / 1000);
|
||||
const zapMessage = extractZapMessage(event);
|
||||
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
|
||||
const zapAmountSats = getZapAmountSats(event);
|
||||
const zapMessage = (event.kind === 8333 ? event.content : extractZapMessage(event)).trim();
|
||||
const usdLabel = btcPrice ? satsToUSD(zapAmountSats, btcPrice) : undefined;
|
||||
const satsLabel = `${formatNumber(zapAmountSats)} ${zapAmountSats === 1 ? 'sat' : 'sats'}`;
|
||||
const amountText = usdLabel ?? satsLabel;
|
||||
const donationPrefix = zapAmountSats > 0 ? `Donated ${amountText} to` : "Donated to";
|
||||
const zapCommentEvent = buildZapCommentEvent(
|
||||
event,
|
||||
zapRequestTags,
|
||||
zapSenderPubkey || event.pubkey,
|
||||
zapMessage,
|
||||
);
|
||||
|
||||
return (
|
||||
<ActivityCard
|
||||
icon={
|
||||
<div className={cn("flex items-center justify-center rounded-full bg-amber-500/10 shrink-0", iconSize)}>
|
||||
<Zap className="size-5 text-amber-500 fill-amber-500" />
|
||||
</div>
|
||||
}
|
||||
actorRow={
|
||||
<ActorRow pubkey={zapSenderPubkey} profileUrl={zapSenderUrl} picture={zapSenderMeta?.picture}
|
||||
displayName={zapSenderName} authorEvent={zapSender.data?.event} isLoading={zapSender.isLoading} label="zapped" timestampLabel={timeAgo(event.created_at)}
|
||||
extra={zapAmountSats > 0 ? (
|
||||
<span className="text-sm font-semibold text-amber-500 shrink-0">
|
||||
{formatNumber(zapAmountSats)} {zapAmountSats === 1 ? 'sat' : 'sats'}
|
||||
</span>
|
||||
) : undefined}
|
||||
/>
|
||||
}
|
||||
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
|
||||
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
|
||||
>
|
||||
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">“{zapMessage}”</p>}
|
||||
</ActivityCard>
|
||||
<NoteCard
|
||||
event={zapCommentEvent}
|
||||
actionEvent={event}
|
||||
className={className}
|
||||
compact={compact}
|
||||
threaded={threaded}
|
||||
threadedLineClassName={threadedLineClassName}
|
||||
threadedLast={threadedLast}
|
||||
highlight={highlight}
|
||||
hideKindHeader
|
||||
commentContextPrefix={donationPrefix}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1055,12 +1137,12 @@ export const NoteCard = memo(function NoteCard({
|
||||
{contentBlock}
|
||||
{actionButtons}
|
||||
<NoteMoreMenu
|
||||
event={event}
|
||||
event={actionTarget}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
<ReplyComposeModal
|
||||
event={event}
|
||||
event={actionTarget}
|
||||
open={replyOpen}
|
||||
onOpenChange={setReplyOpen}
|
||||
/>
|
||||
@@ -1149,12 +1231,12 @@ export const NoteCard = memo(function NoteCard({
|
||||
<>
|
||||
{actionButtons}
|
||||
<NoteMoreMenu
|
||||
event={event}
|
||||
event={actionTarget}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
<ReplyComposeModal
|
||||
event={event}
|
||||
event={actionTarget}
|
||||
open={replyOpen}
|
||||
onOpenChange={setReplyOpen}
|
||||
/>
|
||||
@@ -1728,8 +1810,8 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
},
|
||||
34550: {
|
||||
icon: Users,
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
|
||||
noun: "community",
|
||||
action: (event) => publishedAtAction(event, { created: "created an", updated: "updated an", fallback: "shared an" }),
|
||||
noun: "organization",
|
||||
nounRoute: "/communities",
|
||||
},
|
||||
30009: {
|
||||
@@ -1816,9 +1898,9 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
},
|
||||
36639: {
|
||||
icon: Megaphone,
|
||||
action: (event) => publishedAtAction(event, { created: "posted an", updated: "updated an", fallback: "posted an" }),
|
||||
noun: "action",
|
||||
nounRoute: "/actions",
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
|
||||
noun: "pledge",
|
||||
nounRoute: "/pledges",
|
||||
},
|
||||
39089: {
|
||||
icon: PartyPopper,
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('NoteContent', () => {
|
||||
expect(nostrHashtag).toHaveAttribute('href', '/t/nostr');
|
||||
});
|
||||
|
||||
it('generates deterministic names for users without metadata and styles them differently', async () => {
|
||||
it('falls back to @Anonymous for users without metadata and styles them differently', async () => {
|
||||
// Use a valid npub for testing
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
@@ -146,17 +146,17 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
// The mention should be rendered with a deterministic name
|
||||
// The mention should be rendered with the Anonymous fallback
|
||||
const mention = await screen.findByRole('link');
|
||||
expect(mention).toBeInTheDocument();
|
||||
|
||||
// Should have muted styling for generated names (muted-foreground instead of primary)
|
||||
|
||||
// Should have muted styling for fallback names (muted-foreground instead of primary)
|
||||
expect(mention).toHaveClass('text-muted-foreground');
|
||||
expect(mention).not.toHaveClass('text-primary');
|
||||
|
||||
// The text should start with @ and contain a generated name (not a truncated npub)
|
||||
|
||||
// The text should be the Anonymous fallback (not a truncated npub)
|
||||
const linkText = mention.textContent;
|
||||
expect(linkText).not.toMatch(/^@npub1/); // Should not be a truncated npub
|
||||
expect(linkText).toEqual("@Swift Falcon");
|
||||
expect(linkText).toEqual('@Anonymous');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Check,
|
||||
Radio,
|
||||
ShieldBan,
|
||||
Ban,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -206,7 +205,6 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
// These states live here (not in NoteMoreMenuContent) so they persist after the menu closes
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [banContentOpen, setBanContentOpen] = useState(false);
|
||||
const [banMemberOpen, setBanMemberOpen] = useState(false);
|
||||
const [addToListOpen, setAddToListOpen] = useState(false);
|
||||
const [eventJsonOpen, setEventJsonOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
@@ -262,10 +260,6 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setBanContentOpen(true), 150);
|
||||
}}
|
||||
onBanMember={() => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setBanMemberOpen(true), 150);
|
||||
}}
|
||||
onAddToList={() => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setAddToListOpen(true), 150);
|
||||
@@ -293,23 +287,13 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
)}
|
||||
|
||||
{communityContext?.canBan && (
|
||||
<>
|
||||
<BanConfirmDialog
|
||||
mode="content"
|
||||
eventId={event.id}
|
||||
targetPubkey={event.pubkey}
|
||||
communityATag={communityContext.communityATag}
|
||||
open={banContentOpen}
|
||||
onOpenChange={setBanContentOpen}
|
||||
/>
|
||||
<BanConfirmDialog
|
||||
mode="member"
|
||||
targetPubkey={event.pubkey}
|
||||
communityATag={communityContext.communityATag}
|
||||
open={banMemberOpen}
|
||||
onOpenChange={setBanMemberOpen}
|
||||
/>
|
||||
</>
|
||||
<BanConfirmDialog
|
||||
eventId={event.id}
|
||||
targetPubkey={event.pubkey}
|
||||
communityATag={communityContext.communityATag}
|
||||
open={banContentOpen}
|
||||
onOpenChange={setBanContentOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddToListDialog
|
||||
@@ -357,13 +341,12 @@ interface NoteMoreMenuContentProps extends NoteMoreMenuProps {
|
||||
communityContext?: CommunityMenuContext;
|
||||
onReport: () => void;
|
||||
onBanContent: () => void;
|
||||
onBanMember: () => void;
|
||||
onAddToList: () => void;
|
||||
onViewEventJson: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onBanMember, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
|
||||
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
const { isBookmarked, toggleBookmark } = useBookmarks();
|
||||
@@ -520,11 +503,11 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
<span className="text-muted-foreground shrink-0">·</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-muted-foreground line-clamp-3 max-h-[4.5em] overflow-hidden">
|
||||
<div className="mt-0.5 text-sm text-muted-foreground line-clamp-3 overflow-wrap-anywhere">
|
||||
{/^[A-Za-z0-9+/=_-]{20,}$/.test(event.content.trim()) ? (
|
||||
<span className="italic">Encrypted content</span>
|
||||
) : (
|
||||
<NoteContent event={event} className="text-sm leading-relaxed" disableEmbeds />
|
||||
<NoteContent event={event} className="text-sm leading-snug whitespace-normal" disableEmbeds disableNoteEmbeds as="span" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -592,26 +575,18 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
|
||||
{!isOwnPost && (
|
||||
<MenuItem
|
||||
icon={<Flag className="size-5" />}
|
||||
label={communityContext ? 'Report post to community' : `Report @${displayName}`}
|
||||
label={communityContext ? 'Report post to organization' : `Report @${displayName}`}
|
||||
onClick={onReport}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
{!isOwnPost && communityContext?.canBan && (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={<ShieldBan className="size-5" />}
|
||||
label="Remove from community"
|
||||
onClick={onBanContent}
|
||||
destructive
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Ban className="size-5" />}
|
||||
label={`Ban @${displayName} from community`}
|
||||
onClick={onBanMember}
|
||||
destructive
|
||||
/>
|
||||
</>
|
||||
<MenuItem
|
||||
icon={<ShieldBan className="size-5" />}
|
||||
label="Remove from organization"
|
||||
onClick={onBanContent}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
{isOwnPost && (
|
||||
<MenuItem
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
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 { 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',
|
||||
};
|
||||
|
||||
const FEE_SPEED_ORDER: OnchainFeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
|
||||
|
||||
/**
|
||||
* Given the raw mempool fee rates (sat/vB), return a deduplicated list of
|
||||
* speed tiers. When multiple tiers share the same rate (common when the
|
||||
* mempool is empty and everything collapses to 1 sat/vB), we keep only the
|
||||
* fastest-labeled tier for that rate. This prevents rows like "~10 min 2
|
||||
* sat/vB / ~30 min 2 sat/vB / ~1 hour 2 sat/vB" in the UI.
|
||||
*/
|
||||
function getRateForSpeed(rates: { fastestFee: number; halfHourFee: number; hourFee: number; economyFee: number }, speed: OnchainFeeSpeed): number {
|
||||
switch (speed) {
|
||||
case 'fastest': return rates.fastestFee;
|
||||
case 'halfHour': return rates.halfHourFee;
|
||||
case 'hour': return rates.hourFee;
|
||||
case 'economy': return rates.economyFee;
|
||||
}
|
||||
}
|
||||
|
||||
function getUniqueFeeSpeeds(
|
||||
rates: { fastestFee: number; halfHourFee: number; hourFee: number; economyFee: number } | undefined,
|
||||
): OnchainFeeSpeed[] {
|
||||
if (!rates) return FEE_SPEED_ORDER;
|
||||
const seen = new Set<number>();
|
||||
const result: OnchainFeeSpeed[] = [];
|
||||
for (const speed of FEE_SPEED_ORDER) {
|
||||
const rate = getRateForSpeed(rates, speed);
|
||||
if (!seen.has(rate)) {
|
||||
seen.add(rate);
|
||||
result.push(speed);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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 { esploraBaseUrl } = 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);
|
||||
const [editingAmount, setEditingAmount] = useState(false);
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 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', esploraBaseUrl],
|
||||
queryFn: () => fetchBtcPrice(esploraBaseUrl),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: utxos } = useQuery({
|
||||
queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress],
|
||||
queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl),
|
||||
enabled: !!senderAddress && capability !== 'unsupported',
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: feeRates } = useQuery({
|
||||
queryKey: ['bitcoin-fee-rates', esploraBaseUrl],
|
||||
queryFn: () => getFeeRates(esploraBaseUrl),
|
||||
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 getRateForSpeed(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 = getUniqueFeeSpeeds(feeRates);
|
||||
const threshold = amountSats * 0.4;
|
||||
|
||||
let target: OnchainFeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
|
||||
for (const speed of uniqueSpeeds) {
|
||||
const rate = getRateForSpeed(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(() => getUniqueFeeSpeeds(feeRates), [feeRates]);
|
||||
|
||||
// Clicking the big amount flips it into edit mode. Auto-focus and
|
||||
// select-all so typing overwrites the current value.
|
||||
useEffect(() => {
|
||||
if (editingAmount) {
|
||||
amountInputRef.current?.focus();
|
||||
amountInputRef.current?.select();
|
||||
}
|
||||
}, [editingAmount]);
|
||||
|
||||
const commitAmountEdit = useCallback(() => {
|
||||
setEditingAmount(false);
|
||||
// Normalize empty string to 0 so the display doesn't show "$" alone.
|
||||
if (typeof usdAmount === 'string' && usdAmount.trim() === '') {
|
||||
setUsdAmount(0);
|
||||
}
|
||||
}, [usdAmount]);
|
||||
|
||||
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">
|
||||
{/* Amount — big number on top, editable by clicking. */}
|
||||
<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 : 0}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preset buttons sit under the big number. */}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={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"
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* 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)}`
|
||||
: '…'}
|
||||
<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 ? getRateForSpeed(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
interface OrganizationContextChipProps {
|
||||
/** The org `A` tag coordinate currently attached to this draft (or empty). */
|
||||
aTag: string;
|
||||
/**
|
||||
* The org entry resolved from the `?org=` query parameter when the
|
||||
* current user is authorized to publish under it (founder or moderator).
|
||||
* `null` when the param is missing, malformed, or points at an org the
|
||||
* user can't publish under.
|
||||
*/
|
||||
authorizedOrg: { community: { aTag: string; name: string } } | null;
|
||||
/** The raw `?org=` value from the URL (used to decide which message to show). */
|
||||
param: string | null;
|
||||
/** The decoded `?org=` result. `null` when the value didn't parse. */
|
||||
paramDecoded: { aTag: string } | null;
|
||||
/** True while `useManageableOrganizations` is still resolving. */
|
||||
manageableLoading: boolean;
|
||||
/**
|
||||
* When true, the chip is rendered for an *edit* flow — show whatever
|
||||
* org the existing event is already attached to (no permission checks
|
||||
* here, because we may not have the user's manageable orgs cached).
|
||||
*/
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small inline indicator surfaced under the create form's title when
|
||||
* the create flow was initiated from inside an organization. The chip
|
||||
* is deliberately uncontrolled — there's no UI to clear, change, or
|
||||
* attach an org from the create page. The user attaches by entering
|
||||
* the create flow from inside the org's page, and detaches by entering
|
||||
* it from outside.
|
||||
*
|
||||
* Shared between CreateCampaignPage and CreateActionPage.
|
||||
*/
|
||||
export function OrganizationContextChip({
|
||||
aTag,
|
||||
authorizedOrg,
|
||||
param,
|
||||
paramDecoded,
|
||||
manageableLoading,
|
||||
isEditMode = false,
|
||||
}: OrganizationContextChipProps) {
|
||||
// Edit mode: surface the org the event is already attached to. No
|
||||
// permission check here — the underlying publish flow re-resolves the
|
||||
// user's authority before emitting the tags.
|
||||
if (isEditMode) {
|
||||
if (!aTag) return null;
|
||||
return (
|
||||
<div className="mt-3 ml-9 flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/10 px-4 py-3 text-primary shadow-sm">
|
||||
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Users className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide">Attached to organization</p>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{authorizedOrg?.community.name ?? 'Organization'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Updates will stay connected to this organization's official activity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create mode, no `?org=` in the URL: personal publication. Render
|
||||
// nothing — the absence of the chip is the indicator.
|
||||
if (!param) return null;
|
||||
|
||||
// `?org=` present but malformed.
|
||||
if (!paramDecoded) {
|
||||
return (
|
||||
<p className="mt-2 ml-9 text-xs text-muted-foreground">
|
||||
Couldn't read the organization in the link. Publishing under your account.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// `?org=` present and valid, but we haven't resolved the user's
|
||||
// authorization yet. Don't claim "publishing under" until we know.
|
||||
if (manageableLoading) {
|
||||
return (
|
||||
<div className="mt-3 ml-9 rounded-xl border border-border bg-card px-4 py-3 text-sm text-muted-foreground">
|
||||
Checking organization permissions…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// `?org=` present and valid, but the current user isn't a founder or
|
||||
// moderator of that org. Drop silently so a stale link can't forge
|
||||
// an org-tagged event.
|
||||
if (!authorizedOrg) {
|
||||
return (
|
||||
<p className="mt-2 ml-9 text-xs text-muted-foreground">
|
||||
You aren't a founder or moderator of that organization. Publishing under your account.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 ml-9 flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/10 px-4 py-3 text-primary shadow-sm">
|
||||
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Users className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide">Publishing as organization</p>
|
||||
<p className="truncate text-sm font-semibold text-foreground">{authorizedOrg.community.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This will appear as official organization activity instead of only under your profile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { Loader2, Search, PartyPopper } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import { useSearchPeopleLists, type PeopleListSearchResult } from '@/hooks/useSearchPeopleLists';
|
||||
import { parseAuthorEvent } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
function isHexPubkey(value: string): boolean {
|
||||
return /^[0-9a-f]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
function makeFallbackProfile(pubkey: string): SearchProfile {
|
||||
return {
|
||||
pubkey,
|
||||
metadata: {},
|
||||
event: {
|
||||
id: '',
|
||||
pubkey,
|
||||
created_at: 0,
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: '{}',
|
||||
sig: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function profileFromEvent(event: NostrEvent): SearchProfile {
|
||||
const parsed = parseAuthorEvent(event);
|
||||
return { pubkey: event.pubkey, metadata: parsed.metadata ?? {}, event };
|
||||
}
|
||||
|
||||
/** Inline type-ahead person search. */
|
||||
export function PersonSearch({
|
||||
onAdd,
|
||||
onAddMany,
|
||||
excludePubkeys,
|
||||
}: {
|
||||
onAdd: (profile: SearchProfile) => void;
|
||||
onAddMany: (profiles: SearchProfile[], sourceTitle?: string) => void;
|
||||
excludePubkeys: string[];
|
||||
}) {
|
||||
const { nostr } = useNostr();
|
||||
const { toast } = useToast();
|
||||
const [query, setQuery] = useState('');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [isAddingPack, setIsAddingPack] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: profiles, isFetching } = useSearchProfiles(query);
|
||||
const { data: peopleLists, isFetching: isFetchingPeopleLists } = useSearchPeopleLists(query);
|
||||
|
||||
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
|
||||
const filteredProfiles = useMemo(
|
||||
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
|
||||
[profiles, excludeSet],
|
||||
);
|
||||
const filteredPeopleLists = useMemo(
|
||||
() => (peopleLists ?? []).filter((pack) => pack.pubkeys.some((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey.toLowerCase()))),
|
||||
[peopleLists, excludeSet],
|
||||
);
|
||||
const hasResults = filteredProfiles.length > 0 || filteredPeopleLists.length > 0;
|
||||
const isSearching = isFetching || isFetchingPeopleLists || isAddingPack;
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim().length > 0 && hasResults) {
|
||||
setDropdownOpen(true);
|
||||
} else if (query.trim().length === 0) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}, [hasResults, query]);
|
||||
|
||||
const handleSelect = useCallback((profile: SearchProfile) => {
|
||||
onAdd(profile);
|
||||
setQuery('');
|
||||
setDropdownOpen(false);
|
||||
inputRef.current?.focus();
|
||||
}, [onAdd]);
|
||||
|
||||
const handleSelectPeopleList = useCallback(async (pack: PeopleListSearchResult) => {
|
||||
const eligiblePubkeys = Array.from(new Set(
|
||||
pack.pubkeys
|
||||
.map((pubkey) => pubkey.toLowerCase())
|
||||
.filter((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey)),
|
||||
));
|
||||
|
||||
if (eligiblePubkeys.length === 0) {
|
||||
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (eligiblePubkeys.length > 20 && !window.confirm(`Add ${eligiblePubkeys.length} people from ${pack.title}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingPack(true);
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [0], authors: eligiblePubkeys, limit: eligiblePubkeys.length }],
|
||||
{ signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
|
||||
const latestByPubkey = new Map<string, NostrEvent>();
|
||||
for (const event of events) {
|
||||
const existing = latestByPubkey.get(event.pubkey);
|
||||
if (!existing || event.created_at > existing.created_at) latestByPubkey.set(event.pubkey, event);
|
||||
}
|
||||
|
||||
const profilesToAdd = eligiblePubkeys.map((pubkey) => {
|
||||
const event = latestByPubkey.get(pubkey);
|
||||
return event ? profileFromEvent(event) : makeFallbackProfile(pubkey);
|
||||
});
|
||||
|
||||
onAddMany(profilesToAdd, pack.title);
|
||||
setQuery('');
|
||||
setDropdownOpen(false);
|
||||
inputRef.current?.focus();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to load follow pack members',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsAddingPack(false);
|
||||
}
|
||||
}, [excludeSet, nostr, onAddMany, toast]);
|
||||
|
||||
return (
|
||||
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
|
||||
{isSearching && query.trim() && (
|
||||
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (query.trim().length > 0 && hasResults) {
|
||||
setDropdownOpen(true);
|
||||
}
|
||||
}}
|
||||
placeholder="Search people..."
|
||||
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
|
||||
>
|
||||
{hasResults ? (
|
||||
<div className="max-h-[200px] overflow-y-auto py-1">
|
||||
{filteredProfiles.map((profile) => (
|
||||
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
|
||||
))}
|
||||
{filteredPeopleLists.map((pack) => (
|
||||
<PeopleListSearchResultItem key={`${pack.event.kind}:${pack.event.pubkey}:${pack.event.tags.find(([name]) => name === 'd')?.[1] ?? pack.event.id}`} pack={pack} onClick={handleSelectPeopleList} />
|
||||
))}
|
||||
</div>
|
||||
) : query.trim().length >= 2 && !isSearching ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
No people or follow packs found
|
||||
</div>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/** A follow pack / follow set search result row. */
|
||||
function PeopleListSearchResultItem({ pack, onClick }: { pack: PeopleListSearchResult; onClick: (pack: PeopleListSearchResult) => void }) {
|
||||
return (
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
|
||||
onClick={() => onClick(pack)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="size-8 shrink-0 rounded-full bg-primary/10 text-primary flex items-center justify-center">
|
||||
<PartyPopper className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{pack.title}</span>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
Follow pack · {pack.pubkeys.length} people
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A profile search result row. */
|
||||
function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
|
||||
const { metadata, pubkey } = profile;
|
||||
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
|
||||
const avatarUrl = sanitizeUrl(metadata.picture);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
|
||||
onClick={() => onClick(profile)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Avatar className="size-8 shrink-0">
|
||||
<AvatarImage src={avatarUrl} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">
|
||||
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
|
||||
</span>
|
||||
{metadata.nip05 && (
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ 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';
|
||||
|
||||
interface PostActionBarProps {
|
||||
event: NostrEvent;
|
||||
@@ -22,6 +23,10 @@ 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;
|
||||
/** Extra classes on the outer wrapper div. */
|
||||
className?: string;
|
||||
}
|
||||
@@ -31,6 +36,7 @@ export function PostActionBar({
|
||||
replyLabel = 'Reply',
|
||||
onReply,
|
||||
onMore,
|
||||
hideZap = false,
|
||||
className,
|
||||
}: PostActionBarProps) {
|
||||
const { toast } = useToast();
|
||||
@@ -38,7 +44,7 @@ export function PostActionBar({
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
// TODO: Enable zapping split-recipient NIP-75 goals once zap split payments are supported.
|
||||
const canZapAuthor = user && canZap(metadata) && !hasGoalZapSplits(event);
|
||||
const canZapAuthor = !hideZap && user && canZap(metadata) && !hasGoalZapSplits(event);
|
||||
|
||||
const { data: stats } = useEventStats(event.id, event);
|
||||
const repostTotal = (stats?.reposts ?? 0) + (stats?.quotes ?? 0);
|
||||
@@ -59,30 +65,48 @@ export function PostActionBar({
|
||||
}, [event, toast]);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-between py-1 border-t border-b border-border${className ? ` ${className}` : ''}`}>
|
||||
<div
|
||||
className={cn(
|
||||
// Soft chip-style action row. Buttons cluster to the left
|
||||
// (engagement) with share/more pushed right. No heavy
|
||||
// top/bottom border band — pages can add their own separator
|
||||
// via `className` if they need one.
|
||||
'flex flex-wrap items-center gap-1 sm:gap-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Reply / Comments */}
|
||||
<button
|
||||
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title={replyLabel}
|
||||
onClick={onReply}
|
||||
>
|
||||
<MessageCircle className="size-5" />
|
||||
<MessageCircle className="size-[18px]" />
|
||||
{stats?.replies ? (
|
||||
<span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span>
|
||||
) : null}
|
||||
<span className="tabular-nums">{formatNumber(stats.replies)}</span>
|
||||
) : (
|
||||
<span className="hidden sm:inline">{replyLabel}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Repost */}
|
||||
<RepostMenu event={event}>
|
||||
{(isReposted: boolean) => (
|
||||
<button
|
||||
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? 'text-accent hover:text-accent/80 hover:bg-accent/10' : 'text-muted-foreground hover:text-accent hover:bg-accent/10'}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium transition-colors',
|
||||
isReposted
|
||||
? 'text-accent hover:text-accent/80 hover:bg-accent/10'
|
||||
: 'text-muted-foreground hover:text-accent hover:bg-accent/10',
|
||||
)}
|
||||
title={isReposted ? 'Undo repost' : 'Repost'}
|
||||
>
|
||||
<RepostIcon className="size-5" />
|
||||
<RepostIcon className="size-[18px]" />
|
||||
{repostTotal > 0 ? (
|
||||
<span className="text-sm tabular-nums">{formatNumber(repostTotal)}</span>
|
||||
) : null}
|
||||
<span className="tabular-nums">{formatNumber(repostTotal)}</span>
|
||||
) : (
|
||||
<span className="hidden sm:inline">Repost</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</RepostMenu>
|
||||
@@ -93,39 +117,45 @@ export function PostActionBar({
|
||||
eventPubkey={event.pubkey}
|
||||
eventKind={event.kind}
|
||||
reactionCount={stats?.reactions}
|
||||
variant="chip"
|
||||
/>
|
||||
|
||||
{/* Zap */}
|
||||
{canZapAuthor && (
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
|
||||
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="Zap"
|
||||
>
|
||||
<Zap className="size-5" />
|
||||
<Zap className="size-[18px]" />
|
||||
{stats?.zapAmount ? (
|
||||
<span className="text-sm tabular-nums">{formatNumber(stats.zapAmount)}</span>
|
||||
) : null}
|
||||
<span className="tabular-nums">{formatNumber(stats.zapAmount)}</span>
|
||||
) : (
|
||||
<span className="hidden sm:inline">Zap</span>
|
||||
)}
|
||||
</button>
|
||||
</ZapDialog>
|
||||
)}
|
||||
|
||||
{/* Spacer pushes share/more to the right */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Share */}
|
||||
<button
|
||||
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
|
||||
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
|
||||
title="Share"
|
||||
onClick={handleShare}
|
||||
>
|
||||
<Share2 className="size-5" />
|
||||
<Share2 className="size-[18px]" />
|
||||
</button>
|
||||
|
||||
{/* More */}
|
||||
<button
|
||||
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="More"
|
||||
onClick={onMore}
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
<MoreHorizontal className="size-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,14 @@ interface ReactionButtonProps {
|
||||
className?: string;
|
||||
/** Show a filled heart icon instead of outline. */
|
||||
filledHeart?: boolean;
|
||||
/**
|
||||
* Visual variant.
|
||||
* - `pill` (default): compact icon-pill matching the legacy NoteCard
|
||||
* action bar.
|
||||
* - `chip`: rounded chip with label fallback when there's no count,
|
||||
* matching the GoFundMe-style PostActionBar / NoteCard action row.
|
||||
*/
|
||||
variant?: 'pill' | 'chip';
|
||||
}
|
||||
|
||||
export function ReactionButton({
|
||||
@@ -36,6 +44,7 @@ export function ReactionButton({
|
||||
reactionCount = 0,
|
||||
className,
|
||||
filledHeart = false,
|
||||
variant = 'pill',
|
||||
}: ReactionButtonProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
@@ -129,8 +138,10 @@ export function ReactionButton({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 p-2 rounded-full transition-colors focus:outline-none',
|
||||
'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
|
||||
'transition-colors focus:outline-none',
|
||||
variant === 'chip'
|
||||
? 'inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10'
|
||||
: 'flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
|
||||
className,
|
||||
hasReacted && 'text-pink-500',
|
||||
)}
|
||||
@@ -189,13 +200,23 @@ export function ReactionButton({
|
||||
{filledHeart ? (
|
||||
<Heart className="size-6" fill={hasReacted ? 'currentColor' : 'none'} />
|
||||
) : hasReacted && userReaction ? (
|
||||
<RenderResolvedEmoji emoji={userReaction} className="h-5 w-5 object-contain leading-none translate-y-px" />
|
||||
<RenderResolvedEmoji
|
||||
emoji={userReaction}
|
||||
className={cn(
|
||||
'object-contain leading-none translate-y-px',
|
||||
variant === 'chip' ? 'h-[18px] w-[18px]' : 'h-5 w-5',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Heart className="size-5" />
|
||||
)}
|
||||
{reactionCount > 0 && (
|
||||
<span className={cn('text-sm tabular-nums', hasReacted && 'text-pink-500')}>{formatNumber(reactionCount)}</span>
|
||||
<Heart className={variant === 'chip' ? 'size-[18px]' : 'size-5'} />
|
||||
)}
|
||||
{reactionCount > 0 ? (
|
||||
<span className={cn('tabular-nums', variant === 'chip' ? '' : 'text-sm', hasReacted && 'text-pink-500')}>
|
||||
{formatNumber(reactionCount)}
|
||||
</span>
|
||||
) : variant === 'chip' ? (
|
||||
<span className="hidden sm:inline">React</span>
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -13,6 +13,8 @@ import { ComposeBox, type ExternalReplyRoot } from '@/components/ComposeBox';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const AGORA_DEFAULT_NOTE_TAGS = [['t', 'agora']];
|
||||
|
||||
interface ReplyComposeModalProps {
|
||||
/** The event being replied to, a URL for commenting on web content, or a NIP-73 identifier (e.g. `bitcoin:tx:...`, `isbn:...`). When `null`, the modal acts as a "New post" composer. */
|
||||
event?: NostrEvent | ExternalReplyRoot | null;
|
||||
@@ -160,6 +162,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
|
||||
onHasPreviewableContentChange={setHasPreviewableContent}
|
||||
initialContent={initialContent}
|
||||
initialMode={initialMode}
|
||||
defaultTags={!isReply && !isQuote && initialMode !== 'poll' ? AGORA_DEFAULT_NOTE_TAGS : undefined}
|
||||
/>
|
||||
</div>
|
||||
</PortalContainerProvider>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user