Compare commits
455 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53828741e1 | |||
| b5f4e6febb | |||
| 5a04b071f1 | |||
| 7cdeead7b2 | |||
| 93108bc00e | |||
| 35b84c76dc | |||
| 6671908e2e | |||
| 0c2c42d039 | |||
| 4f32fee37a | |||
| e5dc8fd50b | |||
| ca55030c68 | |||
| 69a688706e | |||
| 2f8c8762e3 | |||
| 7f7db43910 | |||
| 4bb44ff210 | |||
| 6ccdeefdee | |||
| fed462dad5 | |||
| 31e1b58012 | |||
| df5c08ef27 | |||
| 747b95c125 | |||
| bbda106f7b | |||
| 7d8e2d1192 | |||
| e2a9277489 | |||
| 357e18e063 | |||
| 0b193b823f | |||
| 6d15204b47 | |||
| c983d406c9 | |||
| 553edf761e | |||
| 3adfe5d89a | |||
| 05332e31c9 | |||
| 30f6058228 | |||
| 03003e4541 | |||
| 91eb2fcee2 | |||
| e3f2941294 | |||
| 53a7c01a9e | |||
| 8620bb2bc7 | |||
| 7cdcea1586 | |||
| e1c66f3bba | |||
| 3a703a261e | |||
| 4fb67e3b1c | |||
| 3d9f760156 | |||
| 93c22dec2e | |||
| fd3446a2f5 | |||
| 58fd4c41c2 | |||
| ea3a1ff5bd | |||
| 93e9f7ca97 | |||
| 6b7bdb9322 | |||
| 4312a7c6f6 | |||
| 6812b3dd74 | |||
| ea825505cc | |||
| 0c5eae3ceb | |||
| 3ec8d1b9f9 | |||
| 121991f3e5 | |||
| 63e2a7d1a8 | |||
| 6e7fcb8732 | |||
| d5a54f6844 | |||
| 69f7ec9176 | |||
| d92ec350e4 | |||
| 671e3f14fe | |||
| 4dbf8b00ec | |||
| 0622efc781 | |||
| edf9f77060 | |||
| f762a8b0d7 | |||
| ee79b789a7 | |||
| 6a55092f2c | |||
| 59f1b07a03 | |||
| 1dbac90108 | |||
| c774405dc3 | |||
| c738b60c7b | |||
| 70e78b7e5f | |||
| 4e9c6b37d3 | |||
| c09775473a | |||
| af483d9989 | |||
| 7ea0f0977d | |||
| b10335efc1 | |||
| 9fd585ebdd | |||
| c2fee23582 | |||
| 0a7388ac2f | |||
| fed1bb9ce0 | |||
| b864a73573 | |||
| d52d9e25a5 | |||
| 7506ed7dec | |||
| 4188e926a4 | |||
| f4688137bc | |||
| 7ee35644e3 | |||
| b83d35fc75 | |||
| f08e3d6226 | |||
| 75337cc5bf | |||
| d1c53df4d4 | |||
| 059f75dbc5 | |||
| 6693f2c153 | |||
| 8436c7b787 | |||
| 710aa08818 | |||
| 935c121bab | |||
| d66eaf6aa4 | |||
| 774305f799 | |||
| 95a1e966bc | |||
| b6f90a03c4 | |||
| 51d50e3b33 | |||
| b53cb20d61 | |||
| 2b3a2e7daf | |||
| 5c4cf3011e | |||
| 81f1fd5d1f | |||
| 5d872e9a95 | |||
| 8efd7c7128 | |||
| 48744aa13d | |||
| b7d33577f1 | |||
| ffb9c93ee6 | |||
| 97ec528b50 | |||
| 4dd913d3ca | |||
| e41e8396d7 | |||
| cf10654ea6 | |||
| 0cf5614502 | |||
| 62517cc062 | |||
| b32ae751a2 | |||
| ad2e9a2ee9 | |||
| 0f85584294 | |||
| 1a53f3047d | |||
| dc959f6360 | |||
| c6ca9b8042 | |||
| f0724f705f | |||
| 48794fa3b4 | |||
| ce4a53b61e | |||
| 68ed98c7b5 | |||
| 24c4fe0dc7 | |||
| fe1061f81b | |||
| 881ddf3c81 | |||
| 2e5a262864 | |||
| 421d4f366e | |||
| 7777271df1 | |||
| 840769af21 | |||
| bfc8d1ab07 | |||
| f1000f1838 | |||
| 772a2de236 | |||
| 5920523b57 | |||
| ee8e4f0bcb | |||
| 4aa358d685 | |||
| f811245f90 | |||
| b0561a5503 | |||
| 522c265041 | |||
| 25ef304e42 | |||
| 4cd2aadba2 | |||
| e57a3029f5 | |||
| 7590af5a76 | |||
| 605e7f599f | |||
| f5bc774aba | |||
| 3b125592d1 | |||
| e318ca0550 | |||
| af36a9c7d5 | |||
| d7729b705e | |||
| e04342668b | |||
| 590e592cf0 | |||
| 80b56b3318 | |||
| 7dc1afc5a1 | |||
| a42522dda2 | |||
| 3ade1e8126 | |||
| 279c8b914c | |||
| 9a5d3e56fe | |||
| fe43906cf1 | |||
| 9313e9b1d7 | |||
| 0d1d782437 | |||
| 7f93dcb3af | |||
| 9ca70dfcc2 | |||
| 3b9eef908f | |||
| 948e6b70b6 | |||
| afe2bf1c28 | |||
| ae3daef072 | |||
| da6cab8784 | |||
| 4067904e09 | |||
| 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 | |||
| 523235e043 | |||
| 14ca8999ad | |||
| 9dcc183044 | |||
| 7a519ba341 | |||
| 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 | |||
| c1f942210a | |||
| 302b756c54 | |||
| d239948757 | |||
| c8a126ffd6 | |||
| ba19a1045e | |||
| d53988aa0d | |||
| 0cbaffb77f | |||
| 24a7ae014d | |||
| 1e6ce6fb30 | |||
| c4cfc0bd2c | |||
| d4595d55bf | |||
| 15f10cd58a | |||
| d5bf21c853 | |||
| d7996c49db | |||
| 8ea55d2c53 | |||
| a38a80fdec | |||
| cd97f854c0 | |||
| 3ae03c3d17 | |||
| 966b71f0d8 | |||
| 2d1b270e8a | |||
| 0ac1db2085 | |||
| 39b2f79e38 | |||
| a70caae2da | |||
| 5cfd9a049f | |||
| b5f2d9bebb | |||
| d3fadeca09 | |||
| 343085684e | |||
| 7b66c795fe | |||
| 6d4d8ee9fb | |||
| b772be3139 | |||
| fd6b6b41bc | |||
| bc0b8f83d4 | |||
| 9dad7c2488 | |||
| baf91fef89 | |||
| 77752f8b65 | |||
| 5084c99367 | |||
| 4dbd52e914 | |||
| 9d6f2cefec | |||
| f92e013347 | |||
| c833021eea | |||
| cbffd73953 | |||
| 7701ce006d | |||
| 9a94d2b639 | |||
| 98077d4738 | |||
| 9cedada01d | |||
| b01e7e8fe5 | |||
| aeb73e941b | |||
| 9fed3bc0b7 | |||
| 6555253224 | |||
| 4ad6feac5d |
@@ -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="*"
|
||||
@@ -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) |
|
||||
| 33863 | Campaign | Self-authored fundraising campaign with a single Bitcoin wallet endpoint (`bc1...` or `sp1...`) |
|
||||
| 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,54 @@
|
||||
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
|
||||
| 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 | 33863, 1985, 39089 | Homepage curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster |
|
||||
|
||||
### Agora Content Marker
|
||||
|
||||
Every event Agora publishes that represents a first-class Agora object carries the single-letter tag `["t", "agora"]`. This marker enables the Agora activity feed to filter strictly server-side via the relay-indexed `#t` filter (multi-letter tags like the NIP-89 `client` tag are not indexed by relays and are therefore unsuitable for this purpose).
|
||||
|
||||
#### Tagged kinds
|
||||
|
||||
| Kind | Object | Where tagged |
|
||||
|-------|---------------------|---------------------------------------------------------------|
|
||||
| 1 | Note (top-level, reply, quote) | `ComposeBox` default for top-level kind 1 publishes |
|
||||
| 1111 | NIP-22 comment | `usePostComment` (all comments authored in Agora) |
|
||||
| 8333 | Onchain zap | `useOnchainZap`, `useDonateCampaign`, `SendBitcoinDialog` |
|
||||
| 9041 | Zap goal | `CreateGoalDialog` |
|
||||
| 33863 | Campaign | `CreateCampaignPage` |
|
||||
| 31922 | Date calendar event | `CreateEventPage`, `CreateCommunityEventDialog` |
|
||||
| 31923 | Time calendar event | `CreateEventPage`, `CreateCommunityEventDialog` |
|
||||
| 34550 | Community | `CreateCommunityPage` |
|
||||
| 36639 | Pledge | `CreateActionPage` |
|
||||
|
||||
The tag is added at publish time via the `withAgoraTag` helper in `src/lib/agoraNoteTags.ts`, which dedupes against any user-supplied `t:agora` tag.
|
||||
|
||||
#### Untagged kinds (intentional)
|
||||
|
||||
Reactions, reposts, follow lists, profile metadata, lists, settings, badges, vanish requests, encrypted DMs, and live chat are user-state or response events rather than first-class Agora content. Tagging them would pollute `#agora` hashtag surfaces without adding value to the activity feed.
|
||||
|
||||
Untagged on purpose: 0, 3, 6, 7, 8, 16, 62, 1311, 30009, 10000-series, 30078, and any NIP-04 / NIP-44 encrypted kind.
|
||||
|
||||
#### Querying
|
||||
|
||||
The Agora activity feed combines a `t:agora`-strict layer with an intentionally cross-client world layer:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "kinds": [33863, 36639, 34550, 8333], "#t": ["agora", "Agora"] },
|
||||
{ "kinds": [1111], "#t": ["agora", "Agora"], "#K": ["33863", "36639", "34550"] },
|
||||
{ "kinds": [1111, 1068], "#k": ["iso3166", "geo"] },
|
||||
{ "kinds": [1], "#t": ["agora", "Agora"] }
|
||||
]
|
||||
```
|
||||
|
||||
The first two filters surface only Agora-created content. The third surfaces all country/geo-rooted comments and polls regardless of origin — the world layer is intentionally cross-client. The fourth captures any kind 1 note carrying `#agora` (including hashtags users type themselves), which preserves viral / opt-in discovery.
|
||||
|
||||
Clients filter both case variants (`agora` and `Agora`) because Nostr `t` tags are conventionally lowercase but some clients normalize hashtags to title case.
|
||||
|
||||
#### Backward compatibility
|
||||
|
||||
Events published before this marker was adopted do not carry `t:agora` and therefore do not appear in the Agora activity feed. They remain reachable by direct link and via kind-specific directories (e.g. the moderator-curated `/campaigns/all`). Authors who wish to surface a legacy event in the feed can republish it (any edit through the Agora UI will add the marker automatically).
|
||||
|
||||
### Community Chat
|
||||
|
||||
@@ -72,6 +119,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 +136,45 @@ Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) a
|
||||
}
|
||||
```
|
||||
|
||||
Multi-recipient zap (one transaction paying multiple recipients — community splits):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 8333,
|
||||
"pubkey": "<sender-pubkey>",
|
||||
"content": "Great community!",
|
||||
"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", "34550:<community-author>:<community-d-tag>"],
|
||||
["K", "34550"],
|
||||
["alt", "Donation: 75000 sats across 3 recipients"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Campaign donation (one transaction paying a single campaign wallet — see Kind 33863 below):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 8333,
|
||||
"pubkey": "<donor-pubkey>",
|
||||
"content": "Keep up the good work.",
|
||||
"tags": [
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["amount", "<sats-paid-to-campaign-wallet>"],
|
||||
["a", "33863:<campaign-author>:<campaign-d-tag>"],
|
||||
["K", "33863"],
|
||||
["alt", "Donation to Save the Last Bookstore: 25000 sats"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Campaign donation receipts MUST NOT include `p` tags — campaigns no longer have Nostr-identity recipients, only a `w` wallet endpoint. Verification matches tx outputs against the campaign's declared `w` address rather than derived Taproot addresses (see *Verification* and Kind 33863 below).
|
||||
|
||||
### 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 +184,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,23 +227,37 @@ 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):**
|
||||
|
||||
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. To verify:
|
||||
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. Verification has two modes depending on the event shape:
|
||||
|
||||
*Identity-recipient mode* (the event has `p` tags — profile zaps, event zaps, community splits):
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
*Campaign-wallet mode* (the event has an `a` tag pointing at a kind 33863 campaign and no `p` tags):
|
||||
|
||||
1. Extract the txid from the `i` tag and the campaign coordinate from the `a` tag.
|
||||
2. Fetch the campaign event and read its `w` tag to get the campaign's declared bech32(m) wallet address. Reject the receipt if `w` is missing, malformed, or starts with `sp1` (silent-payment campaigns do not publish receipts; see Kind 33863).
|
||||
3. Fetch the transaction from a Bitcoin data source.
|
||||
4. Sum the values of all outputs in the transaction that pay the campaign's `w` address. This is the **verified amount**.
|
||||
|
||||
In both modes:
|
||||
|
||||
5. If the verified amount is 0, 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 +277,329 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
|
||||
|
||||
---
|
||||
|
||||
## Standard NIPs: Direct Messaging
|
||||
## Kind 33863: Campaign
|
||||
|
||||
This application implements encrypted direct messaging using two standard Nostr protocols:
|
||||
### Summary
|
||||
|
||||
### NIP-04 (Legacy Encrypted DMs)
|
||||
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional deadline, optional country) and exactly one Bitcoin wallet endpoint declared in a `w` tag. The wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode is inferred from the prefix — the client renders the corresponding QR code and adjusts the donation-progress UI accordingly.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Kind | 4 |
|
||||
| Spec | [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md) |
|
||||
The author of the event is also the beneficiary. Campaigns are never authored on behalf of someone else; the event creator owns the wallet declared in `w` and receives the donations. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate.
|
||||
|
||||
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, banner, goal, deadline, and wallet 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": 33863,
|
||||
"pubkey": "<creator-pubkey>",
|
||||
"content": "<markdown story>",
|
||||
"tags": [
|
||||
["d", "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) |
|
||||
["title", "Save the Last Bookstore"],
|
||||
["summary", "Help our 40-year-old neighborhood bookstore make rent through winter."],
|
||||
["banner", "https://blossom.example/abc123.jpg"],
|
||||
["imeta",
|
||||
"url https://blossom.example/abc123.jpg",
|
||||
"m image/jpeg",
|
||||
"x abc123def456...",
|
||||
"dim 1600x900",
|
||||
"blurhash LKO2?U%2Tw=w]~RBVZRi};RPxuwH",
|
||||
"alt Storefront of the Last Bookstore at dusk"
|
||||
],
|
||||
["alt", "Fundraising campaign: Save the Last Bookstore"],
|
||||
|
||||
Modern private direct messages using the Gift Wrap protocol. Messages are triple-layered:
|
||||
["w", "bc1p7w2k3xq9...xyz"],
|
||||
|
||||
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
|
||||
["goal", "25000"],
|
||||
["deadline", "1735689600"],
|
||||
|
||||
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.
|
||||
["i", "iso3166-1:US"],
|
||||
["k", "iso3166-1"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Protocol Configuration
|
||||
A silent-payment campaign is identical except the `w` tag carries an `sp1…` code:
|
||||
|
||||
Users can configure their preferred send protocol via Settings > Messages:
|
||||
```json
|
||||
["w", "sp1qq...verylongsilentpaymentcode..."]
|
||||
```
|
||||
|
||||
- **NIP-17 only** (default) — maximum privacy, only modern clients can read
|
||||
- **NIP-04 + NIP-17** — sends via both protocols for compatibility with legacy clients
|
||||
### Content
|
||||
|
||||
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).
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|-----------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `d` | Yes | Campaign slug, unique per author. Forms the addressable coordinate `33863:<pubkey>:<d>`. |
|
||||
| `title` | Yes | Display title of the campaign (plain text, max ~200 chars). |
|
||||
| `w` | Yes | Bitcoin wallet endpoint. The 2nd element is a single bech32(m) string: a mainnet on-chain address starting with `bc1q` (P2WPKH/P2WSH) or `bc1p` (P2TR), **or** a silent-payment code starting with `sp1` per BIP-352. Exactly one `w` tag per campaign. |
|
||||
| `summary` | Recommended | Short one-paragraph tagline shown in feed cards and previews. |
|
||||
| `banner` | Recommended | HTTPS URL of the wide banner image. Clients MUST sanitize the URL (see `sanitizeUrl()` in `nostr-security`) before rendering, and SHOULD pair the URL with a NIP-92 `imeta` tag for dimensions, blurhash, MIME type, and SHA-256. |
|
||||
| `imeta` | Recommended | NIP-92 media metadata for the banner. The first `url <value>` pair MUST match the `banner` URL; clients SHOULD ignore an `imeta` whose URL does not match. |
|
||||
| `goal` | Optional | Fundraising goal in **integer US Dollars** (no unit suffix, no decimals). Clients MAY display an estimated sat-equivalent at view time using a live exchange rate. |
|
||||
| `deadline`| Optional | Unix timestamp (seconds) at which the campaign closes for new donations. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
|
||||
| `i` | Recommended | NIP-73 country identifier. SHOULD be `iso3166-1:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166-1:VE`). |
|
||||
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166-1`. |
|
||||
| `alt` | Recommended | NIP-31 human-readable fallback. |
|
||||
|
||||
### Wallet Modes
|
||||
|
||||
The prefix of the `w` value selects one of two donation modes. Clients MUST detect the mode from the prefix; the event carries no other mode discriminator.
|
||||
|
||||
| Prefix | Mode | Description |
|
||||
|---------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `bc1q…` / `bc1p…` | On-chain | Public mainnet bech32(m) address. Donations are traceable; clients show a progress bar, total raised, and donation list. |
|
||||
| `sp1…` | Silent payment | BIP-352 silent-payment code. Donations are **unlinkable by design**. Clients MUST hide all aggregate totals and progress UI (see below). |
|
||||
|
||||
Other prefixes (`tb1…`, `bcrt1…`, `tsp1…`, lightning invoices, etc.) MUST be rejected at parse time; the campaign does not render.
|
||||
|
||||
Clients SHOULD validate the bech32(m) checksum of the `w` value, not just its prefix.
|
||||
|
||||
### Client Behavior by Mode
|
||||
|
||||
| UI element | On-chain (`bc1`) | Silent payment (`sp1`) |
|
||||
|-----------------------------|-----------------------------------------------------------------|-------------------------------------------------------|
|
||||
| QR code | bech32(m) address QR (or BIP-21 `bitcoin:` URI) | SP code QR (BIP-352 / BIP-21 SP extension) |
|
||||
| "Raised X" / progress bar | Shown, computed from verified kind 8333 receipts | **Hidden.** Replaced with a "Private campaign — totals are not public" notice. |
|
||||
| Donor / recent-donation list| Shown | **Hidden.** |
|
||||
| Goal display | Shown as USD target with optional sat-equivalent estimate | Shown as USD target; no progress computation |
|
||||
| Donation receipt published | Donor's client publishes a kind 8333 receipt (see below) | **No receipt published.** Publishing one would defeat SP unlinkability and is forbidden. |
|
||||
|
||||
For silent-payment campaigns, clients MUST NOT attempt to scan the chain, MUST NOT publish receipts, and MUST NOT display any aggregate that could leak donation activity. The only signal the public sees is the campaign event itself.
|
||||
|
||||
### Donation Flow — On-chain (`bc1`)
|
||||
|
||||
1. Donor opens the campaign and chooses an amount.
|
||||
2. Donor's client constructs and broadcasts a Bitcoin transaction paying the campaign's `w` address.
|
||||
3. After broadcast, the donor's client publishes a single kind 8333 receipt:
|
||||
|
||||
```json
|
||||
[
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["amount", "<sats-paid-to-campaign-wallet>"],
|
||||
["a", "33863:<campaign-author-pubkey>:<campaign-d-tag>"],
|
||||
["K", "33863"],
|
||||
["alt", "Donation to <campaign-title>: <total-amount> sats"]
|
||||
]
|
||||
```
|
||||
|
||||
The receipt MUST NOT carry `p` tags — campaigns are not Nostr-identity recipients. The `amount` tag is the sum of tx outputs paying the campaign's `w` address (excluding the donor's change output).
|
||||
|
||||
4. The receipt is published **after** the tx is broadcast; the txid is already final at that point. A receipt-publish failure does not roll back the donation — the on-chain transaction stands.
|
||||
|
||||
### Donation Flow — Silent Payment (`sp1`)
|
||||
|
||||
1. Donor opens the campaign and chooses an amount.
|
||||
2. Donor's client uses the campaign's SP code to derive a fresh, one-time Taproot output script per BIP-352.
|
||||
3. Donor broadcasts a Bitcoin transaction paying that derived output.
|
||||
4. **No Nostr event is published.** The campaign owner discovers the donation by scanning the chain locally with their SP private key.
|
||||
|
||||
Silent-payment unlinkability is the entire point of this mode. Clients MUST NOT publish receipts, MUST NOT advertise the donation in any other Nostr event (replies, mentions, etc.) on the donor's behalf, and MUST NOT correlate the donor's pubkey with the campaign in any persisted client telemetry.
|
||||
|
||||
### Querying
|
||||
|
||||
**List campaigns (newest first):**
|
||||
|
||||
```json
|
||||
{ "kinds": [33863], "limit": 50 }
|
||||
```
|
||||
|
||||
**Fetch a specific campaign:**
|
||||
|
||||
```json
|
||||
{ "kinds": [33863], "authors": ["<creator-pubkey>"], "#d": ["<slug>"], "limit": 1 }
|
||||
```
|
||||
|
||||
**Aggregate donations for an on-chain campaign:**
|
||||
|
||||
```json
|
||||
{ "kinds": [8333], "#a": ["33863:<creator-pubkey>:<slug>"], "limit": 500 }
|
||||
```
|
||||
|
||||
Clients MUST verify each kind 8333 event on-chain before counting it toward the campaign total, per the *Campaign-wallet mode* verification rules in the kind 8333 section.
|
||||
|
||||
**Filter by country:**
|
||||
|
||||
```json
|
||||
{ "kinds": [33863], "#i": ["iso3166-1:VE"], "limit": 50 }
|
||||
```
|
||||
|
||||
**Fetch pinned event comments:**
|
||||
|
||||
Event owners MAY pin important comments or activity feed events with a NIP-78 app-specific data event (`kind: 30078`) authored by the root event owner. The `d` tag is scoped to the root event coordinate. Agora uses this for campaigns (`33863`), pledges (`36639`), organizations (`34550`), and calendar events (`31922` / `31923`).
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"pubkey": "<root-event-author-pubkey>",
|
||||
"content": "{\"pinnedEvents\":[\"<event-id-2>\",\"<event-id-1>\"]}",
|
||||
"tags": [
|
||||
["d", "agora-pinned-comments:<kind>:<root-event-author-pubkey>:<d-tag>"],
|
||||
["a", "<kind>:<root-event-author-pubkey>:<d-tag>"],
|
||||
["k", "<kind>"],
|
||||
["alt", "Pinned event comments"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients SHOULD query the pin list with:
|
||||
|
||||
```json
|
||||
{ "kinds": [30078], "authors": ["<root-event-author-pubkey>"], "#d": ["agora-pinned-comments:<kind>:<root-event-author-pubkey>:<d-tag>"], "limit": 1 }
|
||||
```
|
||||
|
||||
The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned event removes it. Clients SHOULD ignore pin lists not authored by the root event owner.
|
||||
|
||||
### Client Behavior
|
||||
|
||||
- **Wallet validity:** clients MUST reject events whose `w` tag is missing, present more than once, or whose value does not pass bech32(m) checksum validation for one of the supported prefixes. Invalid campaigns do not render.
|
||||
- **Editability:** the creator MAY republish the same `(33863, pubkey, d)` triple to update any field, including the `w` wallet endpoint. Clients SHOULD keep `published_at` from the first publish on subsequent edits (NIP-23 convention).
|
||||
- **Closing a campaign:** there is no `status` tag. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate. Clients SHOULD honor the deletion by removing the campaign from discovery feeds. Historical kind 8333 receipts MAY still be rendered against the (now-deleted) campaign coordinate so donors can find their past donations.
|
||||
- **No category, no topics:** kind 33863 events MUST NOT carry `t` tags or NIP-32 category labels in any `agora.*` namespace. Campaigns are individual stories; discovery happens via search (NIP-50 against title/summary/content), country (`#i`), and moderator curation (below).
|
||||
- **Migration:** kind 33863 has no relationship to any earlier campaign kind. Clients MUST NOT read, merge, or migrate events of any other kind into the kind 33863 namespace.
|
||||
|
||||
### Agora Moderation Labels
|
||||
|
||||
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), and which kind 34550 organizations appear in the Featured shelf on `/communities`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
|
||||
|
||||
Campaigns and organizations share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the two streams is the kind prefix on the `a` tag of each label:
|
||||
|
||||
- `33863:<author-pubkey>:<d>` — campaign (kind 33863, see "Open Campaigns" above).
|
||||
- `34550:<author-pubkey>:<d>` — organization (kind 34550, NIP-72 community definition).
|
||||
|
||||
A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. Mixing the two streams would let a moderator's `featured` label on a campaign appear to feature an unrelated organization with the same `d` tag, or vice versa.
|
||||
|
||||
#### 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 are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** use only two — `hide` and `featured` — because every Agora-tagged organization is publicly visible by default; there is no approval gate for orgs. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 coordinates, and clients MUST ignore any such labels they receive.
|
||||
|
||||
| Axis | Values | Surfaces | Meaning |
|
||||
|----------|---------------------------|----------------|-------------------------------------------------------------------------|
|
||||
| approval | `approved`, `unapproved` | campaigns only | `approved` allows the campaign on its discovery surfaces. `unapproved` retracts a previous approval. |
|
||||
| hide | `hidden`, `unhidden` | both | `hidden` suppresses the campaign/organization everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
|
||||
| featured | `featured`, `unfeatured` | both | `featured` places the campaign in the hand-picked Featured row on `/`, or the organization in the Featured shelf on `/communities`. `unfeatured` retracts. |
|
||||
|
||||
Surfacing rules (hide always wins):
|
||||
|
||||
**Campaigns**
|
||||
|
||||
- **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.
|
||||
|
||||
**Organizations**
|
||||
|
||||
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first.
|
||||
- **"My organizations" shelf on `/communities`** — intentionally ignores all moderation labels. A user's own founded, moderated, or followed organizations always render regardless of label state.
|
||||
- **Moderator-only "Needs review"** — iff `t:agora` AND not featured AND not hidden. Surfaces orgs minted through Agora's create flow that haven't been triaged into Featured or Hidden yet.
|
||||
- **Moderator-only "Hidden"** — iff hidden.
|
||||
- **Hide enforcement on other organization discovery surfaces** — clients SHOULD suppress `hidden` organizations from any future "All organizations" / browse surface for non-moderators. Moderators MAY see hidden organizations with a "Hidden" treatment so they can unhide.
|
||||
|
||||
#### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "approved", "agora.moderation"],
|
||||
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
|
||||
["alt", "Campaign moderation: approved"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
An organization label has the same shape with a kind 34550 `a` tag:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "featured", "agora.moderation"],
|
||||
["a", "34550:<author-pubkey>:<organization-d-tag>"],
|
||||
["alt", "Organization 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 target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization).
|
||||
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured` or `Organization moderation: featured`) so non-Agora clients can read it.
|
||||
|
||||
#### 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. Clients MUST pin `authors:` on their label REQ to the pack `p` tags; events from non-pack authors MUST be ignored. This means:
|
||||
|
||||
- Self-approval is impossible unless the pack author has added you.
|
||||
- A moderator removed from the pack immediately loses moderation authority — campaigns/organizations 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.
|
||||
|
||||
The same moderator set governs both campaign and organization labels. Carving out per-surface moderator subsets is out of scope; clients that need that distinction would have to introduce a second follow pack and a second label namespace.
|
||||
|
||||
#### 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 `(coord, axis)`, latest-`created_at`-wins, filtering to the relevant kind prefix (`33863:` for campaigns or `34550:` for organizations). Then fetch the targeted events themselves — one filter per author (bundled in a single REQ) keyed by their d-tags.
|
||||
|
||||
#### Client Behavior
|
||||
|
||||
- Clients SHOULD render approve/hide/feature controls only for users whose pubkey appears in the pack.
|
||||
- Clients MAY display "Hidden" badges on hidden campaigns/organizations 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.
|
||||
- Organization authors are not shown an equivalent "pending" surface today — organizations are visible at their NIP-19 route regardless of moderation, and the only moderation surface is the Featured shelf.
|
||||
|
||||
---
|
||||
|
||||
@@ -290,22 +699,19 @@ After resolution (assuming `$follows` = `["pk1", "pk2"]`):
|
||||
|
||||
---
|
||||
|
||||
## Kind 36639: Activist Action
|
||||
## Kind 36639: Pledge
|
||||
|
||||
### Summary
|
||||
|
||||
Addressable event kind for publishing **activist actions** (called "challenges" internally for backwards compatibility). An action is a country-scoped task — take a photo, make art, gather information, or take direct action — with 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
|
||||
|
||||
Anyone can publish a kind 36639 event, but clients SHOULD only display actions whose author is either:
|
||||
Pledges are user-generated. Anyone can publish a kind 36639 event, and Agora displays valid pledges without platform-admin or country-organizer author filtering.
|
||||
|
||||
1. A platform-level admin (see `src/lib/admins.ts`), or
|
||||
2. An organizer for the action's country (see kind 30078 `agora-organizers`).
|
||||
|
||||
This authorization model is identical to the per-country pin model — see Kind 30078 in this document for the storage shape.
|
||||
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
|
||||
|
||||
@@ -316,14 +722,17 @@ This authorization model is identical to the per-country pin model — see Kind
|
||||
"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,30 +743,36 @@ This authorization model is identical to the per-country pin model — see Kind
|
||||
|------------------|----------|----------------------------------------------------------------------------------------------------------|
|
||||
| `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). |
|
||||
| `i` | Yes | 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. |
|
||||
| `t` | Yes | Discovery tag. Canonical write value is `agora-action`. Read aliases: `pathos-challenge`, `agora-challenge`. |
|
||||
| `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 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 }
|
||||
@@ -374,7 +789,17 @@ Per country:
|
||||
}
|
||||
```
|
||||
|
||||
After fetching, clients MUST filter the results down to events whose author is either an admin or an organizer for the event's country.
|
||||
Per community:
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [36639],
|
||||
"#A": ["34550:<community-pubkey>:<community-d-tag>"],
|
||||
"limit": 50
|
||||
}
|
||||
```
|
||||
|
||||
Country and community scopes are independent. A future action MAY include both `i` and `A`/`K`/`P` tags when both scopes are useful.
|
||||
|
||||
---
|
||||
|
||||
@@ -492,66 +917,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.
|
||||
@@ -1119,4 +1484,3 @@ Albums are represented as kind 34139 playlist events with a `["t", "album"]` tag
|
||||
- Albums display release date and label information when available
|
||||
- Track ordering follows the order of `a` tags in the event
|
||||
- The same detail view, playback, and commenting features apply to both albums and playlists
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
package pub.ditto.app;
|
||||
package spot.agora.app;
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Agora" />
|
||||
<meta property="og:description" content="Power to the people." />
|
||||
<meta property="og:image" content="https://agora.spot/og-image.png" />
|
||||
<meta property="og:image" content="https://agora.spot/og-image.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
@@ -21,7 +21,7 @@
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Agora" />
|
||||
<meta name="twitter:description" content="Power to the people." />
|
||||
<meta name="twitter:image" content="https://agora.spot/og-image.png" />
|
||||
<meta name="twitter:image" content="https://agora.spot/og-image.jpg" />
|
||||
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"name": "agora",
|
||||
"version": "2.8.0",
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
@@ -34,6 +34,7 @@
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/bungee-shade": "^5.2.7",
|
||||
"@fontsource/caveat": "^5.2.8",
|
||||
"@fontsource/cherry-bomb-one": "^5.2.7",
|
||||
@@ -95,19 +96,22 @@
|
||||
"@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/base": "^1.1.1",
|
||||
"@scure/bip32": "^2.2.0",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@scure/btc-signer": "^2.2.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"d3-celestial": "^0.7.35",
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
@@ -123,7 +127,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",
|
||||
@@ -144,6 +147,7 @@
|
||||
"smol-toml": "^1.6.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"topojson-client": "^3.1.0",
|
||||
"uri-templates": "^0.2.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
@@ -166,6 +170,8 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@types/topojson-specification": "^1.0.5",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"@webxdc/types": "^2.1.2",
|
||||
@@ -355,16 +361,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@breeztech/breez-sdk-spark": {
|
||||
"version": "0.13.2-dev1",
|
||||
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.13.2-dev1.tgz",
|
||||
"integrity": "sha512-W7udRIz+ehjqzCFGCmzJ6fYhSPZ6AGsXyO/X3upOmbJdHXw2DtIVaRYz5sxHLlmIHre8MYAbNUFS3nRqMMVfVQ==",
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.10.0.tgz",
|
||||
"integrity": "sha512-eBsh0oX2B8uGuWfCMmtH3SNXmSkED5du/CiWQKh1Ei1r0LsO6jlVnUmh94j7R5W4siIi7M6CC7ywll3FQ47rYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"pg": "^8.18.0"
|
||||
"better-sqlite3": "^12.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/android": {
|
||||
@@ -1457,6 +1462,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bebas-neue": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.2.7.tgz",
|
||||
"integrity": "sha512-DsmBrmq55d9BCU0mt4DT4RZDdH8vhWRKEUOfbuNB1EEjMuwbtFvM8N+3gIlkYSFbsb10P8Q19BV5OdpMu2h0fA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bungee-shade": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bungee-shade/-/bungee-shade-5.2.7.tgz",
|
||||
@@ -6171,39 +6185,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",
|
||||
@@ -6217,55 +6198,40 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
|
||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
|
||||
"integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.1.0",
|
||||
"@noble/hashes": "~1.3.1",
|
||||
"@scure/base": "~1.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"@noble/curves": "2.2.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
|
||||
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
|
||||
@@ -6288,6 +6254,42 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/btc-signer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz",
|
||||
"integrity": "sha512-ZXZ08sZqSZKEcOuEQnxTF66ouHtl6+UA6U/QfQM06K9WiOlEkXF4LviZCaSgkdiFh9cyMt9+xdup7JtEv3p0fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~2.2.0",
|
||||
"@noble/hashes": "~2.2.0",
|
||||
"@scure/base": "~2.2.0",
|
||||
"micro-packed": "~0.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/btc-signer/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/btc-signer/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/browser-utils": {
|
||||
"version": "10.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.42.0.tgz",
|
||||
@@ -6626,6 +6628,15 @@
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
@@ -6719,7 +6730,6 @@
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
@@ -6837,6 +6847,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/topojson-client": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*",
|
||||
"@types/topojson-specification": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/topojson-specification": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
|
||||
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -7720,6 +7751,7 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7897,30 +7929,6 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
@@ -8401,6 +8409,18 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
@@ -9334,19 +9354,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",
|
||||
@@ -9657,7 +9664,8 @@
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
@@ -10900,6 +10908,30 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/micro-packed": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.9.0.tgz",
|
||||
"integrity": "sha512-gFdaWTxEXOwtSOcpxulO4AuXVtp3HWIRmB8eq8+3m1Zku0ubgva0UGpi03YhcvsTJasHngG9gTIUK5kHNKdesg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@scure/base": "~2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/micro-packed/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||
@@ -11660,15 +11692,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",
|
||||
@@ -11774,6 +11797,32 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@scure/bip32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
|
||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.1.0",
|
||||
"@noble/hashes": "~1.3.1",
|
||||
"@scure/base": "~1.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@scure/bip39": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
|
||||
@@ -12019,102 +12068,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
"pg-protocol": "^1.13.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -12352,49 +12305,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/powershell-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
|
||||
@@ -14100,7 +14010,7 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
@@ -14565,6 +14475,26 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "2"
|
||||
},
|
||||
"bin": {
|
||||
"topo2geo": "bin/topo2geo",
|
||||
"topomerge": "bin/topomerge",
|
||||
"topoquantize": "bin/topoquantize"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
@@ -16797,16 +16727,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
@@ -41,6 +41,7 @@
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/bungee-shade": "^5.2.7",
|
||||
"@fontsource/caveat": "^5.2.8",
|
||||
"@fontsource/cherry-bomb-one": "^5.2.7",
|
||||
@@ -102,19 +103,22 @@
|
||||
"@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/base": "^1.1.1",
|
||||
"@scure/bip32": "^2.2.0",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@scure/btc-signer": "^2.2.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"d3-celestial": "^0.7.35",
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
@@ -130,7 +134,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",
|
||||
@@ -151,6 +154,7 @@
|
||||
"smol-toml": "^1.6.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"topojson-client": "^3.1.0",
|
||||
"uri-templates": "^0.2.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
@@ -173,6 +177,8 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@types/topojson-specification": "^1.0.5",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"@webxdc/types": "^2.1.2",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 32 KiB |
@@ -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 |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 39 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
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -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(),
|
||||
),
|
||||
);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB |
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +41,8 @@ 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",
|
||||
useAppRelays: true,
|
||||
useUserRelays: false,
|
||||
@@ -60,48 +56,48 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeReactions: false,
|
||||
feedIncludeZaps: false,
|
||||
feedIncludeZaps: true,
|
||||
feedIncludeArticles: true,
|
||||
showArticles: true,
|
||||
showHighlights: true,
|
||||
feedIncludeHighlights: false,
|
||||
feedIncludeHighlights: true,
|
||||
showEvents: true,
|
||||
feedIncludeEvents: true,
|
||||
showVines: true,
|
||||
showVines: false,
|
||||
showPolls: true,
|
||||
showTreasures: true,
|
||||
showTreasureGeocaches: true,
|
||||
showTreasureFoundLogs: true,
|
||||
showColors: true,
|
||||
showTreasures: false,
|
||||
showTreasureGeocaches: false,
|
||||
showTreasureFoundLogs: false,
|
||||
showColors: false,
|
||||
showPeopleLists: true,
|
||||
feedIncludeVines: true,
|
||||
feedIncludeVines: false,
|
||||
feedIncludePolls: true,
|
||||
feedIncludeTreasureGeocaches: true,
|
||||
feedIncludeTreasureFoundLogs: true,
|
||||
feedIncludeColors: true,
|
||||
feedIncludeTreasureGeocaches: false,
|
||||
feedIncludeTreasureFoundLogs: false,
|
||||
feedIncludeColors: false,
|
||||
feedIncludePeopleLists: true,
|
||||
showDecks: true,
|
||||
feedIncludeDecks: true,
|
||||
showWebxdc: true,
|
||||
feedIncludeWebxdc: true,
|
||||
showDecks: false,
|
||||
feedIncludeDecks: false,
|
||||
showWebxdc: false,
|
||||
feedIncludeWebxdc: false,
|
||||
showPhotos: true,
|
||||
feedIncludePhotos: true,
|
||||
showVideos: true,
|
||||
feedIncludeNormalVideos: true,
|
||||
feedIncludeShortVideos: true,
|
||||
feedIncludeVoiceMessages: true,
|
||||
showEmojiPacks: true,
|
||||
feedIncludeEmojiPacks: true,
|
||||
showCustomEmojis: true,
|
||||
showUserStatuses: true,
|
||||
showMusic: true,
|
||||
feedIncludeMusicTracks: true,
|
||||
feedIncludeMusicPlaylists: true,
|
||||
showPodcasts: true,
|
||||
feedIncludePodcastEpisodes: true,
|
||||
feedIncludePodcastTrailers: true,
|
||||
showDevelopment: true,
|
||||
feedIncludeDevelopment: true,
|
||||
showEmojiPacks: false,
|
||||
feedIncludeEmojiPacks: false,
|
||||
showCustomEmojis: false,
|
||||
showUserStatuses: false,
|
||||
showMusic: false,
|
||||
feedIncludeMusicTracks: false,
|
||||
feedIncludeMusicPlaylists: false,
|
||||
showPodcasts: false,
|
||||
feedIncludePodcastEpisodes: false,
|
||||
feedIncludePodcastTrailers: false,
|
||||
showDevelopment: false,
|
||||
feedIncludeDevelopment: false,
|
||||
showCommunities: true,
|
||||
feedIncludeCommunities: true,
|
||||
showBadges: true,
|
||||
@@ -112,10 +108,10 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeProfileBadges: true,
|
||||
feedIncludeBadgeAwards: true,
|
||||
feedIncludeVanish: true,
|
||||
showBirdstar: true,
|
||||
feedIncludeBirdDetections: true,
|
||||
feedIncludeBirdex: true,
|
||||
feedIncludeConstellations: true,
|
||||
showBirdstar: false,
|
||||
feedIncludeBirdDetections: false,
|
||||
feedIncludeBirdex: false,
|
||||
feedIncludeConstellations: false,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
@@ -150,23 +146,21 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
esploraBaseUrl: 'https://mempool.space/api',
|
||||
esploraApis: [
|
||||
'https://mempool.space/api',
|
||||
'https://mempool.emzy.de/api',
|
||||
'https://blockstream.info/api',
|
||||
],
|
||||
blockbookBaseUrl: 'https://btc.trezor.io',
|
||||
bip352IndexerUrl: 'https://silentpayments.dev/blindbit/mainnet',
|
||||
sidebarWidgets: [
|
||||
{ id: 'trends' },
|
||||
{ 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 +204,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>
|
||||
|
||||
@@ -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 33863.) 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,7 +45,8 @@ 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 ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
|
||||
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
|
||||
@@ -49,12 +55,12 @@ const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").the
|
||||
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 +82,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 PlanetoraPage = lazy(() => import("./pages/PlanetoraPage").then(m => ({ default: m.PlanetoraPage })));
|
||||
const ReceivePage = lazy(() => import("./pages/ReceivePage").then(m => ({ default: m.ReceivePage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
@@ -153,11 +162,16 @@ export function AppRouter() {
|
||||
<Routes>
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
<Route path="/receive" element={<ReceivePage />} />
|
||||
|
||||
{/* 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 path="/planetora" element={<PlanetoraPage />} />
|
||||
<Route element={<FundraiserLayout />}>
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<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 />} />
|
||||
@@ -170,21 +184,19 @@ export function AppRouter() {
|
||||
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
|
||||
<Route path="/settings/profile" element={<ProfileSettings />} />
|
||||
<Route path="/settings/feed" element={<ContentSettingsPage />} />
|
||||
<Route path="/settings/content" element={<ContentPage />} />
|
||||
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
|
||||
<Route
|
||||
path="/settings/notifications"
|
||||
element={<NotificationSettings />}
|
||||
/>
|
||||
<Route path="/settings/messaging" element={<MessagingSettingsPage />} />
|
||||
<Route
|
||||
path="/settings/advanced"
|
||||
element={<AdvancedSettingsPage />}
|
||||
/>
|
||||
<Route path="/settings/magic" element={<MagicSettingsPage />} />
|
||||
<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 +280,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 +292,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 +309,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 />} />
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Camera, Clock, DollarSign, Info, Megaphone, Palette } from 'lucide-react';
|
||||
|
||||
import { parseAction, type Action } from '@/hooks/useActions';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { countryCodeToFlag, getGeoDisplayName } from '@/lib/countries';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
||||
import { formatSats, satsToUSDWhole } from '@/lib/bitcoin';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ACTION_ICONS = {
|
||||
photo: Camera,
|
||||
art: Palette,
|
||||
info: Info,
|
||||
action: Megaphone,
|
||||
} as const;
|
||||
|
||||
function actionNaddr(action: Action): string {
|
||||
return nip19.naddrEncode({
|
||||
kind: 36639,
|
||||
pubkey: action.pubkey,
|
||||
identifier: action.id,
|
||||
});
|
||||
}
|
||||
|
||||
export function ActionContent({ event, compact = true }: { event: NostrEvent; compact?: boolean }) {
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const action = parseAction(event);
|
||||
if (!action) return null;
|
||||
|
||||
const Icon = ACTION_ICONS[action.type];
|
||||
const now = Date.now() / 1000;
|
||||
const startTime = action.startTime ?? action.createdAt;
|
||||
const isUpcoming = startTime > now;
|
||||
const isExpired = !!action.deadline && action.deadline <= now;
|
||||
const coverImage = action.image ?? DEFAULT_COVER_IMAGE;
|
||||
const href = `/${actionNaddr(action)}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(
|
||||
'mt-2 block overflow-hidden rounded-xl border border-border bg-card transition-colors hover:bg-muted/30',
|
||||
isExpired && 'opacity-75',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={cn('relative overflow-hidden bg-muted', compact ? 'h-36' : 'h-56')}>
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={action.title}
|
||||
className={cn('h-full w-full object-cover transition-transform duration-300 hover:scale-[1.02]', isExpired && 'grayscale')}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent" />
|
||||
{action.countryCode && (
|
||||
<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" />
|
||||
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">
|
||||
<Clock className="size-3" /> Expired
|
||||
</span>
|
||||
) : isUpcoming ? (
|
||||
<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">
|
||||
<Clock className="size-3" /> Starts {format(startTime * 1000, 'MMM d')}
|
||||
</span>
|
||||
) : action.deadline ? (
|
||||
<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">
|
||||
<Clock className="size-3" /> Due {format(action.deadline * 1000, 'MMM d')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Megaphone className="mt-0.5 size-5 shrink-0 text-primary" />
|
||||
<h3 className="line-clamp-2 text-base font-bold leading-tight">{action.title}</h3>
|
||||
</div>
|
||||
{action.description.trim() && (
|
||||
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<DollarSign className="size-4 shrink-0 text-primary" />
|
||||
<span className="font-semibold">
|
||||
{btcPrice ? satsToUSDWhole(action.bounty, btcPrice) : `${formatSats(action.bounty)} sats`}
|
||||
</span>
|
||||
{btcPrice && <span className="text-xs text-muted-foreground">~{formatSats(action.bounty)} sats</span>}
|
||||
{action.countryCode && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{getGeoDisplayName(action.countryCode)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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,10 +64,11 @@ 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>]`
|
||||
// immediately (removes banned content 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.
|
||||
// tags. Predicate-match any feed whose aTagsKey contains this
|
||||
// communityATag so the banned post disappears immediately.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] }),
|
||||
queryClient.invalidateQueries({
|
||||
@@ -101,22 +79,25 @@ export function BanConfirmDialog({
|
||||
&& aTagsKey.split(',').includes(communityATag);
|
||||
},
|
||||
}),
|
||||
// Also refresh the organization-activity feed shown on the org
|
||||
// detail page (used by the pledge/campaign shelves).
|
||||
queryClient.invalidateQueries({ queryKey: ['organization-activity', 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 +127,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,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,51 @@
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
|
||||
/**
|
||||
* Informational notice for BIP-352 silent-payment receive endpoints
|
||||
* (sp1…). Surfaces the "private but experimental" trade-off the user
|
||||
* accepts when they choose silent payments instead of a regular
|
||||
* on-chain address.
|
||||
*
|
||||
* Visual treatment mirrors `BitcoinPublicDisclaimer` with `tone="soft"`:
|
||||
* `role="note"`, amber tint, no icon, no checkbox. The lead sentence
|
||||
* carries the headline, and "Learn more" opens a popover with the full
|
||||
* explanation.
|
||||
*/
|
||||
export function BitcoinPrivateDisclaimer() {
|
||||
return (
|
||||
<Alert
|
||||
role="note"
|
||||
className="border-amber-500/30 bg-amber-500/10 text-foreground"
|
||||
>
|
||||
{/* No icon — the shadcn Alert reserves left padding for an icon via
|
||||
`[&>svg~*]:pl-7`, so omitting it reclaims the indent. */}
|
||||
<AlertDescription className="text-xs">
|
||||
<p>
|
||||
Experimental. Donations are private, but bugs may occur.{' '}
|
||||
<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">
|
||||
Your private wallet hides the real address of your wallet
|
||||
and your donors on the Bitcoin network. Funds are always
|
||||
fully recoverable, but bugs in the wallet may cause it to
|
||||
show an incorrect balance, and it may require long wait
|
||||
times to synchronize.
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { ReactNode } from 'react';
|
||||
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;
|
||||
/**
|
||||
* Override the popover body. When set, replaces the entire "Bitcoin
|
||||
* is a public ledger…" paragraph (including the cash-out advice). Use
|
||||
* when the calling surface has a meaningfully different audience —
|
||||
* e.g. a campaign *creator* configuring a receive address, vs. the
|
||||
* sender flow this component was originally written for.
|
||||
*/
|
||||
popoverText?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Privacy disclaimer for on-chain Bitcoin payments. Bitcoin is a public
|
||||
* ledger and the transaction can be traced back to the sender forever.
|
||||
* Used wherever the user initiates an on-chain payment — wallet sends to
|
||||
* raw addresses, campaign donations (BIP-21 panels, in-app PSBT
|
||||
* donations, external-wallet fallbacks).
|
||||
*/
|
||||
export function BitcoinPublicDisclaimer({
|
||||
acknowledged,
|
||||
onAcknowledgedChange,
|
||||
leadText = 'Money you send is public and can be traced back to you.',
|
||||
tone = 'destructive',
|
||||
includeCashOutAdvice = true,
|
||||
popoverText,
|
||||
}: 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
|
||||
// Use the project's foreground token (not raw amber-900) so
|
||||
// the text always contrasts against the page in both light
|
||||
// and dark themes. The faint amber tint keeps the
|
||||
// "informational notice" cue without leaning on hard-coded
|
||||
// amber text that disappears on the wrong backdrop.
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-foreground'
|
||||
: 'border-destructive/50 bg-destructive/5 text-destructive dark:border-destructive',
|
||||
)}
|
||||
>
|
||||
{/* Icon only on the destructive variant. The shadcn Alert reserves
|
||||
left padding for an icon via `[&>svg~*]:pl-7`, so omitting the
|
||||
icon also reclaims the indent. */}
|
||||
{!isSoft && <AlertTriangle className="size-4 text-destructive" />}
|
||||
<AlertDescription className="text-xs">
|
||||
<p>
|
||||
{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">
|
||||
{popoverText ?? (
|
||||
<>
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
MapPin,
|
||||
Clock,
|
||||
Users,
|
||||
@@ -18,10 +18,12 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { PostActionBar } from '@/components/PostActionBar';
|
||||
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
|
||||
import { RSVPAvatars } from '@/components/RSVPAvatars';
|
||||
@@ -33,9 +35,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
|
||||
import { useMyRSVP } from '@/hooks/useMyRSVP';
|
||||
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
|
||||
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -126,6 +130,26 @@ function formatDetailDate(event: NostrEvent): string {
|
||||
return startStr;
|
||||
}
|
||||
|
||||
function formatCalendarHeroDate(event: NostrEvent): string | null {
|
||||
const startRaw = getTag(event.tags, 'start');
|
||||
if (!startRaw) return null;
|
||||
|
||||
if (event.kind === 31922) {
|
||||
const [year, month, day] = startRaw.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (isNaN(date.getTime())) return startRaw;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
const timestamp = Number(startRaw);
|
||||
if (!Number.isFinite(timestamp)) return startRaw;
|
||||
const startTzid = getTag(event.tags, 'start_tzid');
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
...(startTzid ? { timeZone: startTzid } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const ROLE_ORDER = ['host', 'speaker', 'moderator', 'participant'];
|
||||
function roleSort(a: string, b: string): number {
|
||||
const ai = ROLE_ORDER.indexOf(a.toLowerCase());
|
||||
@@ -162,6 +186,15 @@ function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: str
|
||||
);
|
||||
}
|
||||
|
||||
function EventDetailRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-xl bg-muted/40 px-3 py-3">
|
||||
<div className="mt-0.5 text-primary shrink-0">{icon}</div>
|
||||
<div className="min-w-0 text-sm leading-relaxed">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
@@ -170,7 +203,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const title = getTag(event.tags, 'title') ?? 'Untitled Event';
|
||||
const image = getTag(event.tags, 'image');
|
||||
const image = sanitizeUrl(getTag(event.tags, 'image'));
|
||||
const locationRaw = getTag(event.tags, 'location');
|
||||
const location = locationRaw ? parseLocation(locationRaw) : undefined;
|
||||
const summary = getTag(event.tags, 'summary');
|
||||
@@ -179,6 +212,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
|
||||
const eventCoord = useMemo(() => getEventCoord(event), [event]);
|
||||
const dateStr = useMemo(() => formatDetailDate(event), [event]);
|
||||
const heroDate = useMemo(() => formatCalendarHeroDate(event), [event]);
|
||||
|
||||
// Participants grouped by role
|
||||
const participantsByRole = useMemo(() => {
|
||||
@@ -200,6 +234,12 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
const myRsvp = useMyRSVP(eventCoord);
|
||||
const publishRSVP = usePublishRSVP();
|
||||
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
|
||||
const {
|
||||
pinnedEvents,
|
||||
isPinned,
|
||||
canManagePins,
|
||||
togglePin,
|
||||
} = usePinnedEventComments(eventCoord, event.pubkey);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -225,6 +265,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
.map((comment) => buildNode(comment));
|
||||
}, [commentsData]);
|
||||
|
||||
const pinnedNodes = useMemo(
|
||||
() => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
|
||||
[pinnedEvents],
|
||||
);
|
||||
|
||||
const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => {
|
||||
if (status === myRsvp.status) return;
|
||||
try {
|
||||
@@ -240,202 +285,339 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
|
||||
|
||||
const showRSVP = !!user;
|
||||
const attendingCount = rsvps.accepted.length;
|
||||
const interestedCount = rsvps.tentative.length;
|
||||
const rsvpStatusLabel = myRsvp.status === 'accepted'
|
||||
? 'You are going'
|
||||
: myRsvp.status === 'tentative'
|
||||
? 'You are interested'
|
||||
: myRsvp.status === 'declined'
|
||||
? "You can't go"
|
||||
: 'Choose your RSVP';
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto pb-16">
|
||||
{/* ── Standard top bar ── */}
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold flex-1">Event Details</h1>
|
||||
{canEdit && (
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
onClick={() => setEditOpen(true)}
|
||||
aria-label="Edit event"
|
||||
>
|
||||
<Pencil className="size-5" />
|
||||
</button>
|
||||
const eventDetailsCard = (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hosted by</div>
|
||||
<PersonRow pubkey={event.pubkey} size="sm" />
|
||||
</div>
|
||||
|
||||
{(event.content || summary) && (
|
||||
<div className="space-y-2 border-t border-border/60 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Description</div>
|
||||
{event.content ? (
|
||||
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} disableEmbeds disableNoteEmbeds />
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Cover image ── */}
|
||||
{image ? (
|
||||
<div className="aspect-[2/1] w-full overflow-hidden">
|
||||
<img src={image} alt={title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-[3/1] w-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<CalendarDays className="size-20 text-primary/20" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div className="px-5 mt-5 space-y-5">
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl font-bold leading-tight tracking-tight">{title}</h2>
|
||||
{/* Organizer row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<PersonRow pubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date & Location — sidebar-style pills */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
|
||||
<Clock className="size-5 text-primary shrink-0" />
|
||||
<span className="text-sm">{dateStr}</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<EventDetailRow icon={<Clock className="size-5" />}>
|
||||
{dateStr}
|
||||
</EventDetailRow>
|
||||
{location && (
|
||||
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
|
||||
<MapPin className="size-5 text-primary shrink-0" />
|
||||
<span className="text-sm">{location}</span>
|
||||
</div>
|
||||
<EventDetailRow icon={<MapPin className="size-5" />}>
|
||||
{location}
|
||||
</EventDetailRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hashtags */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hashtags.map((tag) => (
|
||||
<Link key={tag} to={`/t/${tag}`}>
|
||||
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
|
||||
#{tag}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
{showRSVP && (
|
||||
<div className="space-y-3 border-t border-border/60 pt-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">RSVP</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">{rsvpStatusLabel}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('rounded-full px-2', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
|
||||
onClick={() => handleRSVP('accepted')}
|
||||
>
|
||||
<Check className="size-3.5 mr-1" />
|
||||
Going
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('rounded-full px-2', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
|
||||
onClick={() => handleRSVP('tentative')}
|
||||
>
|
||||
<Star className="size-3.5 mr-1" />
|
||||
Interested
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('rounded-full px-2', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
|
||||
onClick={() => handleRSVP('declined')}
|
||||
>
|
||||
<XIcon className="size-3.5 mr-1" />
|
||||
Can't Go
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{(event.content || summary) && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">About</h2>
|
||||
{event.content ? (
|
||||
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} />
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
|
||||
{rsvps.total > 0 && (
|
||||
<div className="space-y-3 border-t border-border/60 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Attendees</div>
|
||||
<div className="space-y-3">
|
||||
{([
|
||||
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
|
||||
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
|
||||
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
|
||||
] as const).map(([label, pks, cls]) => pks.length > 0 && (
|
||||
<div key={label} className="space-y-2">
|
||||
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
|
||||
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
<div className="space-y-2 border-t border-border/60 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Links</div>
|
||||
<div className="space-y-1">
|
||||
{links.map((url) => (
|
||||
<button
|
||||
key={url}
|
||||
type="button"
|
||||
onClick={() => void openUrl(url)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<LinkIcon className="size-4 text-primary shrink-0" />
|
||||
<span className="truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalLink className="size-3.5 text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const participantsCard = participantsByRole.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Participants</div>
|
||||
<div className="space-y-2">
|
||||
{participantsByRole.map(([role, pubkeys]) =>
|
||||
pubkeys.map((pk) => <PersonRow key={`${role}-${pk}`} pubkey={pk} label={role} size="sm" />),
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
|
||||
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/30 via-primary/15 to-secondary">
|
||||
{image ? (
|
||||
<img src={image} alt="" className="absolute inset-0 size-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<CalendarDays className="size-16 sm:size-20 text-primary/40" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-black/45" />
|
||||
|
||||
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between gap-3 px-4 pt-4">
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
className="p-2.5 -ml-2 rounded-full text-white/90 hover:bg-white/15 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 motion-safe:transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeft className="size-6 drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]" />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="rounded-full bg-transparent text-white/90 shadow-none hover:bg-white/15 hover:text-white focus-visible:ring-white/80"
|
||||
>
|
||||
<Pencil className="size-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 space-y-2 p-5 sm:p-6 [text-shadow:0_1px_4px_rgba(0,0,0,0.75),0_2px_10px_rgba(0,0,0,0.45)]">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold leading-tight tracking-tight text-white">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs sm:text-sm font-medium text-white/85">
|
||||
{heroDate && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CalendarDays className="size-3.5 sm:size-4" />
|
||||
{heroDate}
|
||||
</span>
|
||||
)}
|
||||
{location && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-3.5 sm:size-4" />
|
||||
{location}
|
||||
</span>
|
||||
)}
|
||||
{attendingCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="size-3.5 sm:size-4" />
|
||||
{attendingCount} attending
|
||||
</span>
|
||||
)}
|
||||
{interestedCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="size-3.5 sm:size-4" />
|
||||
{interestedCount} interested
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{summary && (
|
||||
<p className="max-w-2xl text-base sm:text-lg text-white/90 line-clamp-3">
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comment"
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pinnedNodes.length > 0 && (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-6">
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={pinnedNodes}
|
||||
renderItemHeader={(event) => (
|
||||
<EventPinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
|
||||
<div className="lg:hidden mb-6 space-y-4">
|
||||
{eventDetailsCard}
|
||||
{participantsCard}
|
||||
</div>
|
||||
|
||||
<div className="lg:flex lg:gap-8 lg:items-start">
|
||||
<div className="flex-1 min-w-0 space-y-8">
|
||||
<section className="space-y-5">
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hashtags.map((tag) => (
|
||||
<Link key={tag} to={`/t/${tag}`}>
|
||||
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
|
||||
#{tag}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(event.content || summary) && (
|
||||
<article className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
{event.content ? (
|
||||
<NoteContent event={event} hideEmbedImages={!!image} />
|
||||
) : (
|
||||
<p className="text-muted-foreground">{summary}</p>
|
||||
)}
|
||||
</article>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* External links */}
|
||||
{links.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{links.map((url) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85 hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<LinkIcon className="size-5 text-primary shrink-0" />
|
||||
<span className="text-sm truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
||||
</a>
|
||||
))}
|
||||
<section id="event-comments" className="scroll-mt-20">
|
||||
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Comments</h2>
|
||||
{replyTree.length > 0 ? (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{replyTree.length.toLocaleString()} {replyTree.length === 1 ? 'comment' : 'comments'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DetailCommentComposer event={event} className="mb-3" />
|
||||
|
||||
{commentsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-card border border-border/60 px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={replyTree}
|
||||
renderItemHeader={(event) => (
|
||||
<EventPinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplyOpen(true)}
|
||||
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
|
||||
>
|
||||
<p className="text-base font-medium text-foreground">No comments yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Be the first to comment.</p>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{participantsByRole.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Users className="size-4" /> Participants
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{participantsByRole.map(([role, pubkeys]) =>
|
||||
pubkeys.map((pk) => <PersonRow key={pk} pubkey={pk} label={role} size="sm" />),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Attendees */}
|
||||
{rsvps.total > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Users className="size-4" /> Attendees
|
||||
</h2>
|
||||
<div className="space-y-2.5">
|
||||
{([
|
||||
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
|
||||
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
|
||||
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
|
||||
] as const).map(([label, pks, cls]) => pks.length > 0 && (
|
||||
<div key={label} className="flex items-center gap-3">
|
||||
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
|
||||
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* RSVP section */}
|
||||
{showRSVP && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Check className="size-4" /> RSVP
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('flex-1 rounded-full', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
|
||||
onClick={() => handleRSVP('accepted')}
|
||||
>
|
||||
<Check className="size-3.5 mr-1.5" /> Going
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('flex-1 rounded-full', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
|
||||
onClick={() => handleRSVP('tentative')}
|
||||
>
|
||||
<Star className="size-3.5 mr-1.5" /> Interested
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('flex-1 rounded-full', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
|
||||
onClick={() => handleRSVP('declined')}
|
||||
>
|
||||
<XIcon className="size-3.5 mr-1.5" /> Can't Go
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comments"
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
className="-mx-5 px-5"
|
||||
/>
|
||||
<aside className="hidden lg:block lg:w-[360px] lg:shrink-0 lg:self-start">
|
||||
<div className="lg:sticky lg:top-4 space-y-4">
|
||||
{eventDetailsCard}
|
||||
{participantsCard}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
||||
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
|
||||
@@ -446,32 +628,40 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
event={event}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section>
|
||||
{commentsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<div className="-mx-5">
|
||||
<ThreadedReplyList roots={replyTree} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
No comments yet. Be the first to comment!
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
function handleTogglePin(event: NostrEvent) {
|
||||
const wasPinned = isPinned(event.id);
|
||||
togglePin.mutate(event.id, {
|
||||
onSuccess: () => {
|
||||
toast({ title: wasPinned ? 'Unpinned from event' : 'Pinned to event' });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to update event pins', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function EventPinHeader({
|
||||
isPinned,
|
||||
canManagePins,
|
||||
pinPending,
|
||||
onTogglePin,
|
||||
}: {
|
||||
isPinned: boolean;
|
||||
canManagePins: boolean;
|
||||
pinPending: boolean;
|
||||
onTogglePin: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PinnedCommentHeader
|
||||
isPinned={isPinned}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={pinPending}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CalendarClock, EyeOff, HandHeart, MapPin, ShieldCheck, Target } 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,
|
||||
} from '@/lib/campaign';
|
||||
import { formatCampaignAmount, formatUsdGoal, satsToUsd } 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.
|
||||
*
|
||||
* Per NIP.md Kind 33863, the campaign **goal** is integer USD and the
|
||||
* **raised** total is the sum of verified sats. We render both in the
|
||||
* goal's unit (USD) for consistency, converting the sats total at view
|
||||
* time using the live BTC price. While the price is loading the raised
|
||||
* amount falls back to sats.
|
||||
*/
|
||||
export function CampaignProgress({
|
||||
raisedSats,
|
||||
goalUsd,
|
||||
btcPrice,
|
||||
className,
|
||||
}: {
|
||||
raisedSats: number;
|
||||
goalUsd?: number;
|
||||
btcPrice?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const hasGoal = !!goalUsd && goalUsd > 0;
|
||||
const raisedUsd = satsToUsd(raisedSats, btcPrice);
|
||||
const pct = hasGoal && raisedUsd !== undefined
|
||||
? Math.min(100, Math.round((raisedUsd / goalUsd!) * 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 {formatUsdGoal(goalUsd!)} goal</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces {@link CampaignProgress} for silent-payment campaigns, where
|
||||
* on-chain totals are unobservable by design. Shows the goal as a target
|
||||
* (if set) but no progress bar or raised amount.
|
||||
*/
|
||||
export function CampaignPrivateNotice({
|
||||
goalUsd,
|
||||
className,
|
||||
}: {
|
||||
goalUsd?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('space-y-1.5 text-sm', className)}>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
<span>Private campaign — totals are not public</span>
|
||||
</div>
|
||||
{goalUsd && goalUsd > 0 && (
|
||||
<div className="text-xs text-muted-foreground">Target: {formatUsdGoal(goalUsd)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CampaignCardProps {
|
||||
campaign: ParsedCampaign;
|
||||
/** Visual variant: `compact` for grid items, `featured` for hero placement. */
|
||||
variant?: 'compact' | 'featured';
|
||||
className?: string;
|
||||
/** Optional footer affordance rendered opposite the author line. */
|
||||
footerBadge?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, footerBadge }: CampaignCardProps) {
|
||||
const author = useAuthor(campaign.pubkey);
|
||||
const { data: stats } = useCampaignDonations(campaign);
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const { data: moderation } = useCampaignModeration();
|
||||
|
||||
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
|
||||
const cover = sanitizeUrl(campaign.banner);
|
||||
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 isSilentPayment = campaign.wallet.mode === 'sp';
|
||||
|
||||
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>
|
||||
)}
|
||||
{isSilentPayment && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute top-3 left-3 backdrop-blur bg-background/80 border-border/40"
|
||||
>
|
||||
<ShieldCheck className="size-3.5 mr-1" />
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2">
|
||||
{isHidden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30"
|
||||
>
|
||||
<EyeOff className="size-3.5 mr-1" />
|
||||
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" />
|
||||
|
||||
{isSilentPayment ? (
|
||||
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
|
||||
) : (
|
||||
<CampaignProgress raisedSats={raisedSats} goalUsd={campaign.goalUsd} 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">
|
||||
{!isSilentPayment && 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="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<div className="truncate">
|
||||
by <span className="font-medium text-foreground">{creatorName}</span>
|
||||
</div>
|
||||
{footerBadge && <div className="shrink-0">{footerBadge}</div>}
|
||||
</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,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { CampaignCard } from '@/components/CampaignCard';
|
||||
import { parseCampaign } from '@/lib/campaign';
|
||||
|
||||
/**
|
||||
* Renders a kind 33863 Campaign event inside the activity feed using the
|
||||
* same polished {@link CampaignCard} component that powers the campaign
|
||||
* directory. The whole card is a `<Link>` to the campaign's naddr-based
|
||||
* detail route, so taps from the feed land directly on the campaign page.
|
||||
*
|
||||
* Malformed events (missing required fields, invalid wallet endpoint,
|
||||
* etc.) silently drop — `parseCampaign` returns `null` and we return
|
||||
* `null` from the component. A future enhancement could render a
|
||||
* "Malformed campaign" fallback, but for now keeping the feed clean
|
||||
* wins over surfacing parse errors to viewers.
|
||||
*/
|
||||
export function CampaignNoteCardContent({ event }: { event: NostrEvent }) {
|
||||
const campaign = parseCampaign(event);
|
||||
if (!campaign) return null;
|
||||
return <CampaignCard campaign={campaign} className="mt-2" />;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, Check, Copy, ExternalLink, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { CampaignWallet } from '@/lib/campaign';
|
||||
|
||||
interface CampaignWalletDonatePanelProps {
|
||||
/** Parsed wallet endpoint declared by the campaign's `w` tag. */
|
||||
wallet: CampaignWallet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline panel rendering the campaign's wallet endpoint as a scannable
|
||||
* QR code, a copyable string, and an "Open in wallet" button.
|
||||
*
|
||||
* Behavior forks on the wallet's mode:
|
||||
*
|
||||
* - **on-chain** (`bc1q…` / `bc1p…`) — BIP-21 QR with the address; a
|
||||
* public-ledger disclaimer reminds donors that the donation is
|
||||
* traceable.
|
||||
* - **sp** (`sp1…`) — raw silent-payment code QR; an "unlinkable by
|
||||
* design" notice replaces the traceability disclaimer.
|
||||
*
|
||||
* Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
|
||||
* that's `DonateDialog`'s job. This panel is the always-available
|
||||
* "scan and pay from any wallet" affordance.
|
||||
*/
|
||||
export function CampaignWalletDonatePanel({
|
||||
wallet,
|
||||
}: CampaignWalletDonatePanelProps) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Build the QR payload. For on-chain we use BIP-21 so any wallet that
|
||||
// recognizes the `bitcoin:` scheme can pre-fill the address; for SP we
|
||||
// use the BIP-21 `bitcoin:?sp=` extension. Donors pick the amount in
|
||||
// their wallet either way.
|
||||
const qrPayload = wallet.mode === 'onchain'
|
||||
? `bitcoin:${wallet.value}`
|
||||
: `bitcoin:?sp=${wallet.value}`;
|
||||
|
||||
const copyValue = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(wallet.value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
toast({ title: wallet.mode === 'sp' ? 'Silent-payment code copied' : 'Address copied' });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: 'Select and copy the value manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* QR — large, centered on a clean white tile with the Agora logo
|
||||
embedded in an orange circular badge in the center.
|
||||
Error-correction level H tolerates the centered occlusion
|
||||
(~30% of modules can be missing and the code still scans). */}
|
||||
<div className="flex justify-center">
|
||||
<div className="relative rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={qrPayload} size={280} level="H" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div className="rounded-full bg-primary p-2 ring-[6px] ring-white">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt=""
|
||||
className="size-16 object-contain brightness-0 invert"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyable value — single line, tap to copy. No wrapping
|
||||
container; sits flush with the rest of the column. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyValue}
|
||||
className="w-full flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs text-left hover:bg-muted/60 motion-safe:transition-colors"
|
||||
aria-label={wallet.mode === 'sp' ? 'Copy silent-payment code' : 'Copy Bitcoin address'}
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate" title={wallet.value}>
|
||||
{wallet.value}
|
||||
</span>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{wallet.mode === 'onchain' ? (
|
||||
<BitcoinPublicDisclaimer
|
||||
tone="soft"
|
||||
includeCashOutAdvice={false}
|
||||
leadText="Donations are public and can be traced back to you."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-muted/40 px-3 py-2.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="size-4 shrink-0 mt-0.5 text-primary" />
|
||||
<span>
|
||||
Silent-payment campaigns are unlinkable by design. Your donation
|
||||
cannot be tied to the campaign by anyone other than the organizer.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open in wallet — relies on the `bitcoin:` URI handler. SP codes
|
||||
inside `bitcoin:?sp=` are still understood by BIP-352-aware
|
||||
wallets. Older wallets that don't know about SP will ignore
|
||||
the parameter and either refuse the link or show an error — at
|
||||
which point the donor falls back to copy/paste anyway. */}
|
||||
<Button asChild className="w-full text-white">
|
||||
<a href={qrPayload}>
|
||||
<ExternalLink className="size-4 mr-1.5" />
|
||||
Open in wallet
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback rendered when the wallet failed to parse. The detail page
|
||||
* should normally never reach this — `parseCampaign` rejects events
|
||||
* without a valid `w` tag — but a defensive surface is cheap and helps
|
||||
* debugging.
|
||||
*/
|
||||
export function CampaignWalletMissing() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
|
||||
<span>This campaign is missing a valid wallet endpoint.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type React from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
Award, BarChart3, Bird, BookOpen, Camera, Clapperboard, FileText, Film,
|
||||
GitBranch, GitPullRequest, Highlighter, Mail, MapPin, MessageSquare, Mic, Music,
|
||||
GitBranch, GitPullRequest, HandHeart, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
|
||||
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus,
|
||||
Stars, Target, Users, UserCheck, Vote, Zap,
|
||||
} from 'lucide-react';
|
||||
@@ -29,11 +29,11 @@ import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
import { useScryfallCard } from '@/hooks/useScryfallCard';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { hasCustomFlag } from '@/lib/customFlags';
|
||||
import { useCountryFeed } from '@/contexts/CountryFeedContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFlagPalette } from '@/lib/flagPalette';
|
||||
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
|
||||
import { extractGathererCard, type GathererCard } from '@/lib/linkEmbed';
|
||||
import { cardPrimaryImage } from '@/lib/scryfall';
|
||||
|
||||
@@ -144,10 +144,11 @@ 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',
|
||||
33863: 'a campaign',
|
||||
35128: 'an nsite',
|
||||
36639: 'an action',
|
||||
36639: 'a pledge',
|
||||
36787: 'a track',
|
||||
37381: 'a Magic deck',
|
||||
37516: 'a treasure',
|
||||
@@ -178,7 +179,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
1618: GitPullRequest,
|
||||
15128: Rocket,
|
||||
35128: Rocket,
|
||||
36639: Zap,
|
||||
36639: Megaphone,
|
||||
10008: Award,
|
||||
30008: Award,
|
||||
30009: Award,
|
||||
@@ -203,6 +204,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
39089: PartyPopper,
|
||||
3367: Palette,
|
||||
9041: Target,
|
||||
33863: HandHeart,
|
||||
9735: Zap,
|
||||
9802: Highlighter,
|
||||
2473: Bird,
|
||||
@@ -244,7 +246,8 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
|
||||
37381: 'deck',
|
||||
37516: 'treasure',
|
||||
30621: 'constellation',
|
||||
34550: 'community',
|
||||
34550: 'organization',
|
||||
33863: 'campaign',
|
||||
30054: 'episode',
|
||||
30055: 'trailer',
|
||||
34139: 'playlist',
|
||||
@@ -383,6 +386,7 @@ function EventHoverLink({ display, link, hoverContent }: EventHoverLinkProps) {
|
||||
interface CommentContextProps {
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -390,7 +394,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 +409,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 +448,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 +479,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 +502,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 +524,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 +546,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 +574,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 +592,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 +612,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 +621,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 +643,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 +654,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 +662,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 +689,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 +699,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 +726,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 +740,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 +767,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 +781,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 +814,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 +841,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 +854,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>
|
||||
@@ -896,10 +913,9 @@ function useCountryRootContext(event: NostrEvent): { iTag: string; code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given event is rendering with country chrome (pill + flag
|
||||
* backdrop) in the current context. Useful for sibling components that want
|
||||
* to coordinate styling — e.g. NoteCard switching its text to white when a
|
||||
* flag is showing through behind the author row.
|
||||
* Whether the given event is rendering with country chrome (the corner
|
||||
* flag pill) in the current context. Useful for sibling components that
|
||||
* want to coordinate styling.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useIsCountryRooted(event: NostrEvent): boolean {
|
||||
@@ -923,105 +939,11 @@ export function CountryCommentPill({ event, className }: { event: NostrEvent; cl
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorative flag backdrop for country-rooted kind-1111 posts. Renders the
|
||||
* country's Wikipedia lead image (the flag, for country articles) faded
|
||||
* behind the post, echoing the country detail page's hero
|
||||
* (`CountryContentHeader` in `ExternalContentHeader.tsx`) but scaled down
|
||||
* to a card. Pairs with `CountryCommentPill`.
|
||||
*
|
||||
* Designed to be rendered as the first child of a `relative overflow-hidden`
|
||||
* parent. The wrapper is absolutely positioned at `z-0`; its foreground
|
||||
* siblings must declare `relative` (any positioned value works) so they
|
||||
* paint above the backdrop. Pointer events are disabled so the post body
|
||||
* stays fully interactive.
|
||||
*
|
||||
* The Wikipedia summary fetch is cached for 24 h across all cards
|
||||
* referencing the same country code, so a feed of N Venezuelan posts only
|
||||
* pays the network cost once.
|
||||
*
|
||||
* Visibility rules: see `useCountryRootContext` (identical to the pill).
|
||||
* Decorative flag backdrop for country-rooted kind-1111 posts has been
|
||||
* removed in favor of a cleaner card surface. The `CountryCommentPill`
|
||||
* in the upper-right of the header is now the sole country chrome for
|
||||
* world posts.
|
||||
*/
|
||||
export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
|
||||
const ctx = useCountryRootContext(event);
|
||||
const info = ctx ? getCountryInfo(ctx.code) : null;
|
||||
const wikiTitle = ctx ? getWikipediaTitle(ctx.code) : null;
|
||||
const { data: wiki } = useWikipediaSummary(wikiTitle);
|
||||
// Sample dominant colors from the flag emoji at render time. Used as the
|
||||
// fallback gradient while Wikipedia is still resolving and after image
|
||||
// load failures, so the backdrop never reverts to a giant blurred emoji.
|
||||
const palette = useFlagPalette(info?.flag);
|
||||
// Track image load failures so we cleanly fall back to the flag-color
|
||||
// gradient. Wikipedia hosts these PNGs from upload.wikimedia.org which is
|
||||
// generally CORS-friendly, but hotlink-protection or transient 4xx
|
||||
// responses can still happen.
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
|
||||
if (!ctx) return null;
|
||||
|
||||
// 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)
|
||||
: null;
|
||||
|
||||
// Pre-built gradient using the palette (sampled from the flag emoji at
|
||||
// mount). Used as the fallback when Wikipedia hasn't returned an image or
|
||||
// its image failed to load. Single-color palettes get duplicated so
|
||||
// linear-gradient still has two stops.
|
||||
const paletteGradient =
|
||||
palette && palette.length > 0
|
||||
? `linear-gradient(135deg, ${palette.length === 1 ? `${palette[0]}, ${palette[0]}` : palette.join(', ')})`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-64 sm:h-72">
|
||||
{flagImage ? (
|
||||
// Full-width flag banner across the top of the card. A mask-image
|
||||
// gradient fades the image to nothing at its bottom edge, so the
|
||||
// flag dissolves into the card with no hard seam.
|
||||
<img
|
||||
src={flagImage}
|
||||
alt=""
|
||||
decoding="async"
|
||||
onError={() => setImageFailed(true)}
|
||||
className="w-full h-full object-cover opacity-60 select-none"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
) : paletteGradient ? (
|
||||
// Wikipedia not yet resolved (or its image failed) — paint the
|
||||
// flag-color gradient as a placeholder/fallback. Same opacity and
|
||||
// mask shape as the image so the visual swap is seamless when the
|
||||
// image arrives.
|
||||
<div
|
||||
className="absolute inset-0 opacity-60"
|
||||
style={{
|
||||
backgroundImage: paletteGradient,
|
||||
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* Black wash for foreground readability. Mirrors the mask shape
|
||||
so the wash itself fades along with the flag — no hard edge. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.75) 50%, rgba(0,0,0,0) 100%)',
|
||||
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Body-level comment context for ISO 3166 roots — intentionally renders
|
||||
@@ -1029,12 +951,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)}`;
|
||||
@@ -1043,7 +965,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
|
||||
@@ -1105,10 +1027,12 @@ function GathererCardCommentContext({
|
||||
card,
|
||||
url,
|
||||
className,
|
||||
prefix,
|
||||
}: {
|
||||
card: GathererCard;
|
||||
url: string;
|
||||
className?: string;
|
||||
prefix: string;
|
||||
}) {
|
||||
const lookup = useMemo(() => (
|
||||
card.kind === 'multiverse'
|
||||
@@ -1122,7 +1046,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,335 +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>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, EyeOff, Eye, Loader2, MoreHorizontal, Sparkles, SparklesIcon } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { ModerationLabel } from '@/lib/agoraModeration';
|
||||
|
||||
interface CommunityModerationMenuProps {
|
||||
/** The organization's `34550:<pubkey>:<d>` coordinate. */
|
||||
coord: string;
|
||||
/** Visible name for the organization (for toast feedback). */
|
||||
organizationName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-card kebab menu exposing the moderator actions for an organization:
|
||||
*
|
||||
* Hide / Unhide (axis = hide)
|
||||
* Feature / Unfeature (axis = featured)
|
||||
*
|
||||
* Organizations intentionally do **not** have an `approved` axis — unlike
|
||||
* campaigns, which gate homepage placement on moderator approval, every
|
||||
* Agora-tagged organization is publicly visible by default. Moderators
|
||||
* curate via two narrower controls: lifting an org into the Featured
|
||||
* shelf, or suppressing it with a Hidden label.
|
||||
*
|
||||
* Renders `null` for users who are not Team Soapbox pack members. Sits
|
||||
* inside the clickable `CommunityMiniCard` `<Link>`, so the trigger
|
||||
* swallows its own click and the dropdown content stops propagation —
|
||||
* otherwise every menu interaction would navigate to the organization
|
||||
* detail page.
|
||||
*
|
||||
* The moderation rollup is read inside this component (after the
|
||||
* moderator gate) instead of at the parent so non-moderator viewers
|
||||
* never subscribe to the heavy `useOrganizationModeration` query — every
|
||||
* `CommunityMiniCard` in a grid would otherwise wake the same cache
|
||||
* subscription up to 18+ times per page.
|
||||
*/
|
||||
export function CommunityModerationMenu({
|
||||
coord,
|
||||
organizationName,
|
||||
className,
|
||||
}: CommunityModerationMenuProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
// Bail before the heavy moderation query subscribes. Non-moderators
|
||||
// (the overwhelming majority) never pay the network or render cost.
|
||||
if (!isMod) return null;
|
||||
|
||||
return <CommunityModerationMenuInner coord={coord} organizationName={organizationName} className={className} />;
|
||||
}
|
||||
|
||||
function CommunityModerationMenuInner({
|
||||
coord,
|
||||
organizationName,
|
||||
className,
|
||||
}: CommunityModerationMenuProps) {
|
||||
const { data: moderation, moderate } = useOrganizationModeration();
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState<ModerationLabel | null>(null);
|
||||
|
||||
const isHidden = moderation.hiddenCoords.has(coord);
|
||||
const isFeatured = moderation.featuredCoords.has(coord);
|
||||
|
||||
const runAction = async (action: ModerationLabel, verbPast: string) => {
|
||||
if (busy) return;
|
||||
setBusy(action);
|
||||
try {
|
||||
await moderate.mutateAsync({ coord, action });
|
||||
toast({ title: verbPast, description: organizationName });
|
||||
} 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 organization"
|
||||
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 />
|
||||
{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 organization')}>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Feature
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{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>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner-overlay wrapper for `CommunityMiniCard` cards. Renders the
|
||||
* moderator kebab plus a "Hidden" badge when applicable, both
|
||||
* absolutely-positioned at the card's top-right. Returns `null` for
|
||||
* non-moderators so non-mod grids never subscribe to the moderation
|
||||
* query at all.
|
||||
*
|
||||
* Pulling the overlay (and its `useOrganizationModeration` subscription)
|
||||
* out of `CommunityMiniCard` into a single moderator-gated component is
|
||||
* the perf win that lets `/communities` paint Featured/My orgs
|
||||
* immediately without waiting for the moderator pack or the label query
|
||||
* for every card on the page.
|
||||
*/
|
||||
export function CommunityModerationOverlay({
|
||||
coord,
|
||||
organizationName,
|
||||
}: {
|
||||
coord: string;
|
||||
organizationName: string;
|
||||
}) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
if (!isMod) return null;
|
||||
|
||||
return (
|
||||
<CommunityModerationOverlayInner coord={coord} organizationName={organizationName} />
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityModerationOverlayInner({
|
||||
coord,
|
||||
organizationName,
|
||||
}: {
|
||||
coord: string;
|
||||
organizationName: string;
|
||||
}) {
|
||||
const { data: moderation } = useOrganizationModeration();
|
||||
const isHidden = moderation.hiddenCoords.has(coord);
|
||||
|
||||
return (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1.5">
|
||||
{isHidden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30 h-6 px-1.5 text-[10px]"
|
||||
>
|
||||
<EyeOff className="size-3 mr-1" />
|
||||
Hidden
|
||||
</Badge>
|
||||
)}
|
||||
{/* The kebab inner uses the same moderation cache subscription, so
|
||||
no extra round-trip is incurred. */}
|
||||
<CommunityModerationMenuInner coord={coord} organizationName={organizationName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, HelpCircle } from 'lucide-react';
|
||||
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, Check, Globe, HelpCircle } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { encode as blurhashEncode } from 'blurhash';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
@@ -12,7 +12,21 @@ import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { GifPicker } from '@/components/GifPicker';
|
||||
@@ -29,12 +43,14 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { usePostComment } from '@/hooks/usePostComment';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useCountryFollows } from '@/hooks/useCountryFollows';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { useDefaultPostCountry } from '@/hooks/useDefaultPostCountry';
|
||||
import { COUNTRY_LIST, getCountryInfo } from '@/lib/countries';
|
||||
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
import type { Nip85EventStats } from '@/hooks/useNip85Stats';
|
||||
import { invalidateEventStats } from '@/lib/invalidateEventStats';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, mimeFromExt } from '@/lib/mediaUrls';
|
||||
@@ -50,6 +66,7 @@ import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import { resizeImage } from '@/lib/resizeImage';
|
||||
import { extractHashtags } from '@/lib/hashtag';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { AGORA_DEFAULT_NOTE_TAGS } from '@/lib/agoraNoteTags';
|
||||
|
||||
const MAX_CHARS = 5000;
|
||||
|
||||
@@ -132,6 +149,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 +173,18 @@ 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.
|
||||
*
|
||||
* Defaults to {@link AGORA_DEFAULT_NOTE_TAGS} (the silent `t:agora` tag) when
|
||||
* the composer is producing a top-level kind 1 note (no replyTo, not a quote,
|
||||
* not poll mode, no custom publish, no country-scoped destination). Replies,
|
||||
* quotes, polls, comments, and custom-kind publishes do not receive these
|
||||
* tags regardless of this prop. Pass `[]` to opt out explicitly.
|
||||
*/
|
||||
defaultTags?: string[][];
|
||||
/** If true, the composer starts expanded without taking modal/flex behavior. */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/** Circular progress ring for character count. */
|
||||
@@ -202,6 +235,7 @@ export function ComposeBox({
|
||||
forceExpanded = false,
|
||||
hideAvatar = false,
|
||||
hideBorder = false,
|
||||
className,
|
||||
previewMode: controlledPreviewMode,
|
||||
onHasPreviewableContentChange,
|
||||
initialContent = '',
|
||||
@@ -209,6 +243,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);
|
||||
@@ -223,6 +259,7 @@ export function ComposeBox({
|
||||
const { toast } = useToast();
|
||||
const { config } = useAppContext();
|
||||
const imageQuality = config.imageQuality;
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Build a stable localStorage key based on compose context.
|
||||
@@ -243,7 +280,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);
|
||||
@@ -258,23 +295,39 @@ export function ComposeBox({
|
||||
// from the home feed (no replyTo, not a custom-kind publish). When a
|
||||
// country code is selected, the post is published as a NIP-22 kind
|
||||
// 1111 comment rooted on that country instead of a plain kind 1 note.
|
||||
// Dropdown lists only the countries the user follows, with "Global"
|
||||
// always at the top.
|
||||
//
|
||||
// The dropdown shows: Global + the countries the user follows (quick
|
||||
// picks) + a "Choose another country…" item that opens a searchable
|
||||
// dialog over the full country list. So a user can post about any
|
||||
// country, even one they don't follow.
|
||||
const { followedCountries } = useCountryFollows();
|
||||
const canChooseDestination =
|
||||
!replyTo && !customPublish && mode === 'post' && !!user && followedCountries.length > 0;
|
||||
!replyTo && !customPublish && mode === 'post' && !!user;
|
||||
/**
|
||||
* User's saved default destination (persisted to localStorage). Used as
|
||||
* the initial value of `destination` on every fresh compose, and updated
|
||||
* when the user clicks "Set as default" in the destination menu.
|
||||
*/
|
||||
const [defaultPostCountry, setDefaultPostCountry] = useDefaultPostCountry();
|
||||
/** `'world'` for a regular kind-1 note, or an ISO 3166 country code for a kind-1111 community post. */
|
||||
const [destination, setDestination] = useState<'world' | string>('world');
|
||||
const [destination, setDestination] = useState<'world' | string>(defaultPostCountry);
|
||||
/** Open state for the "Choose another country" searchable picker dialog. */
|
||||
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
|
||||
const selectedCountryCode = destination !== 'world' ? destination : null;
|
||||
const selectedCountryInfo = selectedCountryCode ? getCountryInfo(selectedCountryCode) : null;
|
||||
// If the user unfollows the currently-selected country mid-session,
|
||||
// snap back to world so we don't try to publish a kind 1111 with
|
||||
// a root the user no longer cares about.
|
||||
// Snap back to world if the currently selected destination is an
|
||||
// invalid ISO code (e.g. a previously-followed country that was later
|
||||
// removed from the country directory). Picking a non-followed but
|
||||
// valid country is allowed — users can post about any country via the
|
||||
// "Choose another country" picker, so following is not a prerequisite.
|
||||
useEffect(() => {
|
||||
if (selectedCountryCode && !followedCountries.includes(selectedCountryCode)) {
|
||||
if (selectedCountryCode && !getCountryInfo(selectedCountryCode)) {
|
||||
setDestination('world');
|
||||
if (defaultPostCountry === selectedCountryCode) {
|
||||
setDefaultPostCountry('world');
|
||||
}
|
||||
}
|
||||
}, [selectedCountryCode, followedCountries]);
|
||||
}, [selectedCountryCode, defaultPostCountry, setDefaultPostCountry]);
|
||||
const [pollOptions, setPollOptions] = useState([
|
||||
{ id: pollOptionId(), label: '' },
|
||||
{ id: pollOptionId(), label: '' },
|
||||
@@ -300,7 +353,7 @@ export function ComposeBox({
|
||||
setContent('');
|
||||
setCwEnabled(false);
|
||||
setCwText('');
|
||||
setExpanded(false);
|
||||
setExpanded(defaultExpanded);
|
||||
setPickerOpen(false);
|
||||
setTrayOpen(false);
|
||||
setInternalPreviewMode(false);
|
||||
@@ -312,10 +365,10 @@ export function ComposeBox({
|
||||
setUploadedFileGroups(new Map());
|
||||
setWebxdcUuids(new Map());
|
||||
setWebxdcMetas(new Map());
|
||||
setDestination('world');
|
||||
setDestination(defaultPostCountry);
|
||||
// Clear the auto-saved draft
|
||||
try { localStorage.removeItem(draftKey); } catch { /* ignore */ }
|
||||
}, [initialMode, draftKey]);
|
||||
}, [initialMode, draftKey, defaultExpanded, defaultPostCountry]);
|
||||
|
||||
// Use controlled preview mode if provided, otherwise use internal state
|
||||
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
|
||||
@@ -857,6 +910,11 @@ export function ComposeBox({
|
||||
|
||||
// Reset state
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
// Voice messages can surface in the home Agora activity feed (via
|
||||
// the `t:Agora` marker on root messages and through the comment
|
||||
// path on replies). Refresh both home feed queries.
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||||
if (replyTo) {
|
||||
if (isExternalRoot(replyTo)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
|
||||
@@ -865,7 +923,13 @@ export function ComposeBox({
|
||||
if (replyTo.kind !== 1) {
|
||||
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
|
||||
}
|
||||
// Bump comment count on the parent event so the UI updates.
|
||||
invalidateEventStats(queryClient, replyTo, statsPubkey);
|
||||
}
|
||||
} else if (canChooseDestination && selectedCountryCode) {
|
||||
// Root voice message published to a country community feed.
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||||
}
|
||||
notificationSuccess();
|
||||
toast({ title: 'Voice message sent!', description: 'Your voice message has been published.' });
|
||||
@@ -875,7 +939,7 @@ export function ComposeBox({
|
||||
} finally {
|
||||
setIsPublishingVoice(false);
|
||||
}
|
||||
}, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess]);
|
||||
}, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess, canChooseDestination, selectedCountryCode, statsPubkey]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!content.trim() || !user || charCount > MAX_CHARS) return;
|
||||
@@ -1103,22 +1167,49 @@ export function ComposeBox({
|
||||
const countryRoot = new URL(createCountryIdentifier(selectedCountryCode));
|
||||
await postComment({ root: countryRoot, reply: undefined, content: finalContent, tags });
|
||||
} else {
|
||||
// Top-level kind 1 note. If the caller hasn't supplied `defaultTags`,
|
||||
// auto-attach the silent Agora tag so the post surfaces in the Agora
|
||||
// activity feed. Callers can opt out by passing `defaultTags={[]}`.
|
||||
const effectiveDefaultTags = defaultTags ?? AGORA_DEFAULT_NOTE_TAGS;
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content: finalContent,
|
||||
tags,
|
||||
tags: [...effectiveDefaultTags, ...tags],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
resetComposeState();
|
||||
// Optimistically bump the reply count on the parent event
|
||||
// Optimistically bump the comment count on the parent event
|
||||
if (replyTo && !isExternalRoot(replyTo)) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', replyTo.id], (prev) =>
|
||||
prev ? { ...prev, replies: prev.replies + 1 } : prev,
|
||||
queryClient.setQueryData<Nip85EventStats | null>(
|
||||
['nip85-event-stats', replyTo.id, statsPubkey],
|
||||
(prev) => prev ? { ...prev, commentCount: prev.commentCount + 1 } : prev,
|
||||
);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
// Top-level kind 1 posts with the silent Agora tag (the default for
|
||||
// user-authored notes) surface in the home Agora activity feed
|
||||
// (useAgoraFeed / mixed-feed). Invalidate both so the post appears
|
||||
// there without a refresh — over-invalidation is cheap here.
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||||
// Top-level kind 1 posts surface on country pages too. Country posts
|
||||
// route through `usePostComment` (which handles its own invalidation),
|
||||
// but the top-level branch above publishes via `createEvent`, so we
|
||||
// need to invalidate the country feed keys here. `selectedCountryCode`
|
||||
// is null for global posts, in which case nothing extra needs to
|
||||
// refresh (the global Agora feed is served by relays, not a per-country
|
||||
// query). For drafts attached to a specific country via customPublish
|
||||
// we conservatively invalidate the broader prefix.
|
||||
if (canChooseDestination && selectedCountryCode && !replyTo) {
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||||
}, 3000);
|
||||
}
|
||||
if (replyTo) {
|
||||
if (isExternalRoot(replyTo)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
|
||||
@@ -1133,7 +1224,7 @@ export function ComposeBox({
|
||||
}
|
||||
}
|
||||
if (quotedEvent) {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-stats', quotedEvent.id] });
|
||||
invalidateEventStats(queryClient, quotedEvent, statsPubkey);
|
||||
queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] });
|
||||
}
|
||||
notificationSuccess();
|
||||
@@ -1195,6 +1286,19 @@ export function ComposeBox({
|
||||
await createEvent({ kind: 1068, content: finalContent, tags });
|
||||
resetComposeState();
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
// World-layer polls (iso3166 root) and Agora-marked polls surface
|
||||
// in the home Agora activity feed.
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||||
// Polls published with an iso3166 root surface on the country feed.
|
||||
if (replyTo instanceof URL && replyTo.protocol === 'iso3166:') {
|
||||
const countryCode = replyTo.pathname.toUpperCase();
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', countryCode] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', countryCode] });
|
||||
} else if (canChooseDestination && selectedCountryCode) {
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||||
}
|
||||
notificationSuccess();
|
||||
toast({ title: 'Poll published!' });
|
||||
onSuccess?.();
|
||||
@@ -1217,6 +1321,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 && (
|
||||
@@ -1525,14 +1630,14 @@ export function ComposeBox({
|
||||
})()}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Select value={destination} onValueChange={setDestination}>
|
||||
<SelectTrigger
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Post destination"
|
||||
className={cn(
|
||||
'h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
|
||||
'border-0 bg-muted/50 hover:bg-muted shadow-none',
|
||||
'focus:ring-2 focus:ring-primary/50 focus:ring-offset-0',
|
||||
'rounded-lg',
|
||||
'inline-flex items-center justify-center h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
|
||||
'bg-muted/50 hover:bg-muted shadow-none',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-0',
|
||||
'rounded-lg motion-safe:transition-colors',
|
||||
)}
|
||||
>
|
||||
{/* Show just the flag in the trigger to keep the row
|
||||
@@ -1541,28 +1646,145 @@ export function ComposeBox({
|
||||
<span aria-hidden="true">
|
||||
{selectedCountryInfo?.flag ?? '🌍'}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" className="min-w-[180px]">
|
||||
<SelectItem value="world">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[240px]">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDestination('world')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 flex-1">
|
||||
<span aria-hidden="true">🌍</span>
|
||||
<span>Global</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{followedCountries.map((code) => {
|
||||
const info = getCountryInfo(code);
|
||||
if (!info) return null;
|
||||
return (
|
||||
<SelectItem key={code} value={code}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span aria-hidden="true">{info.flag}</span>
|
||||
<span>{info.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{destination === 'world' && (
|
||||
<Check className="size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{/* Build the quick-pick list. Followed countries appear first;
|
||||
if the user has selected an ad-hoc country via the
|
||||
searchable picker that they don't follow, show it too so
|
||||
they have a one-tap way back to it. De-duplicates by code. */}
|
||||
{(() => {
|
||||
const codes = new Set<string>();
|
||||
const quickPicks: string[] = [];
|
||||
for (const code of followedCountries) {
|
||||
if (!codes.has(code) && getCountryInfo(code)) {
|
||||
codes.add(code);
|
||||
quickPicks.push(code);
|
||||
}
|
||||
}
|
||||
if (selectedCountryCode && !codes.has(selectedCountryCode) && getCountryInfo(selectedCountryCode)) {
|
||||
quickPicks.push(selectedCountryCode);
|
||||
}
|
||||
return quickPicks.map((code) => {
|
||||
const info = getCountryInfo(code);
|
||||
if (!info) return null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onSelect={() => setDestination(code)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 flex-1">
|
||||
<span aria-hidden="true">{info.flag}</span>
|
||||
<span>{info.name}</span>
|
||||
</span>
|
||||
{destination === code && (
|
||||
<Check className="size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setCountryPickerOpen(true);
|
||||
}}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
<Globe className="size-4 mr-2 text-muted-foreground" aria-hidden />
|
||||
Choose another country…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{destination === defaultPostCountry ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{(() => {
|
||||
if (defaultPostCountry === 'world') return 'Global is your default';
|
||||
const info = getCountryInfo(defaultPostCountry);
|
||||
return info ? `${info.name} is your default` : 'This is your default';
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDefaultPostCountry(destination);
|
||||
const info = destination === 'world'
|
||||
? null
|
||||
: getCountryInfo(destination);
|
||||
toast({
|
||||
title: 'Default updated',
|
||||
description: info
|
||||
? `New posts will go to ${info.name} by default.`
|
||||
: 'New posts will be global by default.',
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Searchable picker over the full country list. Opened from the
|
||||
"Choose another country…" item in the destination dropdown,
|
||||
so users can post to any country without having to follow it
|
||||
first. */}
|
||||
<CommandDialog
|
||||
open={countryPickerOpen}
|
||||
onOpenChange={setCountryPickerOpen}
|
||||
>
|
||||
<CommandInput placeholder="Search countries..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No countries found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="Global 🌍"
|
||||
onSelect={() => {
|
||||
setDestination('world');
|
||||
setCountryPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" className="mr-2">🌍</span>
|
||||
<span>Global</span>
|
||||
{destination === 'world' && (
|
||||
<Check className="ml-auto size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</CommandItem>
|
||||
{COUNTRY_LIST.map((country) => (
|
||||
<CommandItem
|
||||
key={country.code}
|
||||
// Include code + name in the searchable value so users
|
||||
// can type either "iran" or "IR".
|
||||
value={`${country.name} ${country.code}`}
|
||||
onSelect={() => {
|
||||
setDestination(country.code);
|
||||
setCountryPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" className="mr-2">{country.flag}</span>
|
||||
<span>{country.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{country.code}</span>
|
||||
{destination === country.code && (
|
||||
<Check className="ml-auto size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1763,7 +1985,7 @@ export function ComposeBox({
|
||||
<Button
|
||||
onClick={handlePollSubmit}
|
||||
disabled={!isPollValid || isPollPending || !user}
|
||||
className="rounded-full px-5 font-bold"
|
||||
className="rounded-full px-5 font-bold text-white"
|
||||
size="sm"
|
||||
>
|
||||
{isPollPending ? 'Publishing...' : 'Publish poll'}
|
||||
@@ -1772,7 +1994,7 @@ export function ComposeBox({
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!content.trim() || isPending || isCommentPending || !user || charCount > MAX_CHARS}
|
||||
className="rounded-full px-5 font-bold"
|
||||
className="rounded-full px-5 font-bold text-white"
|
||||
size="sm"
|
||||
>
|
||||
{isPending || isCommentPending ? 'Posting...' : submitLabel}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
|
||||
Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
|
||||
Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -26,220 +25,108 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { FeedEditModal } from '@/components/FeedEditModal';
|
||||
import { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
|
||||
import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
|
||||
import type { SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
|
||||
import type { ExtraKindDef, SubKindDef } from '@/lib/extraKinds';
|
||||
import { EXTRA_KINDS } from '@/lib/extraKinds';
|
||||
import { SIDEBAR_ITEMS } from '@/lib/sidebarItems';
|
||||
import type { FeedSettings, SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
|
||||
import type { ExtraKindDef } from '@/lib/extraKinds';
|
||||
|
||||
export function ContentSettings() {
|
||||
return (
|
||||
<div>
|
||||
{/* Intro */}
|
||||
<div className="px-3 pt-2 pb-4">
|
||||
<h2 className="text-sm font-semibold">What You See</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Customize your feed, choose what content appears, and control what you want to hide.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Homepage Section */}
|
||||
<div className="space-y-8">
|
||||
<HomePageSetting />
|
||||
|
||||
{/* Feed Tabs Section */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">Home Feed Tabs</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<FeedTabsSection />
|
||||
</div>
|
||||
</div>
|
||||
<Section title="Saved Feeds">
|
||||
<FeedTabsSection />
|
||||
</Section>
|
||||
|
||||
{/* Notes Section */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">Basic Home Feed Options</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<div className="px-3 pt-3 pb-4">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Core content types that appear in your feed.
|
||||
</p>
|
||||
</div>
|
||||
<Section title="Content in Home Feed">
|
||||
<FlatContentList />
|
||||
</Section>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
|
||||
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
|
||||
</div>
|
||||
|
||||
<NotesFeedSettings />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other Stuff Section */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">Show More Content Types in Home Feed</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
{/* Intro section for Other Stuff */}
|
||||
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
|
||||
<IntroImage src="/feed-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">Other Stuff</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Nostr isn't just text posts — people publish all kinds of things. Pick what shows up in your sidebar and feed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
|
||||
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
|
||||
</div>
|
||||
|
||||
{/* Content type rows - reuse the internals from FeedSettingsForm */}
|
||||
<FeedSettingsFormInternals />
|
||||
</div>
|
||||
</div>
|
||||
<Section title="Muted">
|
||||
<MuteSettingsInternals />
|
||||
</Section>
|
||||
|
||||
<Section title="Sensitive Content">
|
||||
<SensitiveContentSection />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function KindBadge({ kind }: { kind: number }) {
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className="text-[10px] font-mono text-muted-foreground/60 shrink-0">
|
||||
[{kind}]
|
||||
</span>
|
||||
<section>
|
||||
<h2 className="text-base font-semibold px-3 pb-2 border-b border-border">{title}</h2>
|
||||
<div className="pt-2">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SubKindRow({ sub }: { sub: SubKindDef }) {
|
||||
const { feedSettings, updateFeedSettings } = useFeedSettings();
|
||||
const { updateSettings } = useEncryptedSettings();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const handleToggle = async (key: string, value: boolean) => {
|
||||
updateFeedSettings({ [key]: value });
|
||||
if (user) {
|
||||
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
|
||||
}
|
||||
};
|
||||
function FlatContentList() {
|
||||
// Flat, ordered list of curated kinds. No section grouping, no sub-rows, no kind badges.
|
||||
const orderedIds = [
|
||||
'posts', 'replies', 'reposts', 'articles', 'highlights',
|
||||
'photos', 'videos', 'voice',
|
||||
'events', 'polls', 'communities', 'badges',
|
||||
'reactions', 'zaps',
|
||||
];
|
||||
const byId = new Map(EXTRA_KINDS.map((def) => [def.id, def]));
|
||||
// Replies is id 'comments' in the registry; alias here for readability.
|
||||
byId.set('replies', byId.get('comments')!);
|
||||
const rows = orderedIds.map((id) => byId.get(id)).filter((d): d is ExtraKindDef => !!d && !!d.agora);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2.5 pl-12 pr-3 transition-colors">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm">{sub.label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<KindBadge kind={sub.kind} />{' '}{sub.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-[52px] flex justify-center">
|
||||
<Switch
|
||||
checked={feedSettings[sub.feedKey]}
|
||||
onCheckedChange={(checked) => handleToggle(sub.feedKey, checked)}
|
||||
className="scale-90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{rows.map((def) => (
|
||||
<li key={def.id}>
|
||||
<ContentTypeRow def={def} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function ContentTypeRow({ def }: { def: ExtraKindDef }) {
|
||||
const { feedSettings, updateFeedSettings } = useFeedSettings();
|
||||
const { updateSettings } = useEncryptedSettings();
|
||||
const { user } = useCurrentUser();
|
||||
const IconComponent = CONTENT_KIND_ICONS[def.id] ?? Palette;
|
||||
const icon = <IconComponent className="size-5" />;
|
||||
const hasSubKinds = !!def.subKinds;
|
||||
|
||||
const handleToggle = async (key: string, value: boolean) => {
|
||||
updateFeedSettings({ [key]: value });
|
||||
// Toggle key: prefer the feed inclusion key; fall back to the sidebar visibility key
|
||||
// for kinds that have no direct feed key of their own (e.g. parent kinds with sub-kinds).
|
||||
const toggleKey: keyof FeedSettings | undefined = def.feedKey ?? def.showKey;
|
||||
if (!toggleKey) return null;
|
||||
|
||||
const checked = feedSettings[toggleKey] !== false;
|
||||
|
||||
const handleToggle = async (value: boolean) => {
|
||||
const next: Partial<FeedSettings> = { [toggleKey]: value };
|
||||
// Parent kinds with sub-kinds: toggle all sub-kind feed keys together so the
|
||||
// single parent switch governs everything below it.
|
||||
if (def.subKinds) {
|
||||
for (const sub of def.subKinds) {
|
||||
next[sub.feedKey] = value;
|
||||
}
|
||||
}
|
||||
updateFeedSettings(next);
|
||||
if (user) {
|
||||
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
|
||||
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, ...next } });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border last:border-b-0">
|
||||
<div className="flex items-center justify-between py-3.5 px-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{icon}</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{def.label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<KindBadge kind={def.kind} />{' '}{def.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-[52px] flex justify-center">
|
||||
{!hasSubKinds && def.feedKey ? (
|
||||
<Switch
|
||||
checked={feedSettings[def.feedKey]}
|
||||
onCheckedChange={(checked) => handleToggle(def.feedKey!, checked)}
|
||||
/>
|
||||
) : !hasSubKinds && def.feedOnly && def.showKey ? (
|
||||
<Switch
|
||||
checked={feedSettings[def.showKey] !== false}
|
||||
onCheckedChange={(checked) => handleToggle(def.showKey!, checked)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 py-3.5 px-3">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{def.label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{def.description}</p>
|
||||
</div>
|
||||
|
||||
{hasSubKinds && def.subKinds && def.subKinds.map((sub) => (
|
||||
<SubKindRow
|
||||
key={sub.feedKey}
|
||||
sub={sub}
|
||||
/>
|
||||
))}
|
||||
<Switch checked={checked} onCheckedChange={handleToggle} className="shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesFeedSettings() {
|
||||
return (
|
||||
<>
|
||||
{FEED_KINDS.map((def) => (
|
||||
<ContentTypeRow key={def.id} def={def} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedSettingsFormInternals() {
|
||||
return (
|
||||
<>
|
||||
{SECTION_ORDER.map((section) => {
|
||||
const sectionKinds = EXTRA_KINDS.filter((def) => def.section === section);
|
||||
if (sectionKinds.length === 0) return null;
|
||||
return (
|
||||
<div key={section}>
|
||||
<div className="px-3 pt-4 pb-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{SECTION_LABELS[section]}
|
||||
</span>
|
||||
</div>
|
||||
{sectionKinds.map((def) => (
|
||||
<ContentTypeRow key={def.id} def={def} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Feed Tabs Section Component
|
||||
function FeedTabsSection() {
|
||||
const { toast } = useToast();
|
||||
@@ -407,14 +294,11 @@ function FeedTabsSection() {
|
||||
return (
|
||||
<div>
|
||||
{/* Intro section for Feed Tabs */}
|
||||
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
|
||||
<IntroImage src="/community-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">Feed Navigation</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Manage which feed tabs appear in your navigation and follow communities by domain.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-3 pb-4">
|
||||
<h3 className="text-sm font-semibold">Feed Navigation</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Manage which feed tabs appear in your navigation and follow communities by domain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feed Tab Toggles */}
|
||||
@@ -922,16 +806,10 @@ export function SensitiveContentSection() {
|
||||
return (
|
||||
<div>
|
||||
{/* Intro */}
|
||||
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
|
||||
<div className="w-40 shrink-0 flex items-center justify-center">
|
||||
<ShieldAlert className="size-16 text-muted-foreground/40" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">Content Warnings</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Some posts are tagged with content warnings (NIP-36) by their authors. This can include NSFW material, spoilers, or other sensitive content.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-3 pb-4">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Some posts are tagged by their authors as sensitive — NSFW, graphic, or otherwise needing a content warning. Choose how to handle them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Policy options — consistent row style with other settings */}
|
||||
|
||||
@@ -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,233 @@
|
||||
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;
|
||||
/**
|
||||
* Fires after a successful Blossom upload with the NIP-94-style tag
|
||||
* array returned by `useUploadFile`:
|
||||
* `[["url", "<url>"], ["x", "<sha256>"], ["ox", "<sha256>"], ["size", "<bytes>"], ["m", "image/jpeg"]]`.
|
||||
* Parents that want to publish a paired NIP-92 `imeta` tag in their
|
||||
* Nostr event should convert this array — see Kind 33863 publishing.
|
||||
*/
|
||||
onUploadComplete?: (nip94Tags: string[][]) => 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, onUploadComplete, 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 tags = await uploadFile(file);
|
||||
const [[, url]] = tags;
|
||||
onChange(url);
|
||||
// Forward the raw NIP-94 tag array to the parent so it can build a
|
||||
// paired NIP-92 imeta tag. The URL inside the tags is what Blossom
|
||||
// returned; the parent's `value` may pick up an appended extension
|
||||
// via the useUploadFile post-processing, but the sha256 ("x") still
|
||||
// identifies the same byte stream.
|
||||
if (onUploadComplete) {
|
||||
// Replace the URL in the first tag with the extension-corrected
|
||||
// value the parent now holds (matches the rendered banner src).
|
||||
const adjusted = tags.map((t) => [...t]);
|
||||
if (adjusted[0]?.[0] === 'url') adjusted[0][1] = url;
|
||||
onUploadComplete(adjusted);
|
||||
}
|
||||
} 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, ChevronRight, Clock, Loader2, Megaphone, Plus, Upload } from 'lucide-react';
|
||||
|
||||
import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
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 { createCountryIdentifier } from '@/lib/countryIdentifiers';
|
||||
import { countryCodeToFlag, getAllCountries, getGeoDisplayName } from '@/lib/countries';
|
||||
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 {
|
||||
countryCode?: string;
|
||||
communityATag?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface CreateActionFormState {
|
||||
title: string;
|
||||
description: string;
|
||||
tagInput: string;
|
||||
pledgeUsd: string;
|
||||
deadline: string;
|
||||
time: string;
|
||||
coverImage: string;
|
||||
selectedCountry: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
function normalizePledgeTag(value: string): string {
|
||||
return value.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
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({
|
||||
formData,
|
||||
setFormData,
|
||||
isSubmitting,
|
||||
handleSubmit,
|
||||
onCancel,
|
||||
pageCountryCode,
|
||||
}: {
|
||||
formData: CreateActionFormState;
|
||||
setFormData: (data: CreateActionFormState) => void;
|
||||
isSubmitting: boolean;
|
||||
handleSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
pageCountryCode?: string;
|
||||
}) {
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const allCountries = useMemo(() => getAllCountries(), []);
|
||||
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
|
||||
|
||||
const countryOptions = useMemo(() => {
|
||||
const options: Array<{ value: string; label: string; flag: string }> = [
|
||||
{ value: 'none', label: 'No country', flag: '🌍' },
|
||||
];
|
||||
if (pageCountryCode) {
|
||||
options.push({
|
||||
value: pageCountryCode,
|
||||
label: getGeoDisplayName(pageCountryCode),
|
||||
flag: countryCodeToFlag(pageCountryCode),
|
||||
});
|
||||
}
|
||||
allCountries.forEach((country) => {
|
||||
if (country.code !== pageCountryCode) {
|
||||
options.push({ value: country.code, label: country.name, flag: countryCodeToFlag(country.code) });
|
||||
}
|
||||
});
|
||||
return options;
|
||||
}, [pageCountryCode, allCountries]);
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const [[, url]] = await uploadFile(file);
|
||||
setFormData({ ...formData, coverImage: url });
|
||||
} catch (error) {
|
||||
console.error('Failed to upload cover image:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 py-2 px-4 max-w-full overflow-hidden">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="country">Country (optional)</Label>
|
||||
<Popover open={countryPickerOpen} onOpenChange={setCountryPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={countryPickerOpen} className="w-full justify-between">
|
||||
{formData.selectedCountry ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{countryCodeToFlag(formData.selectedCountry)}</span>
|
||||
<span>{getGeoDisplayName(formData.selectedCountry)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>No country</span>
|
||||
)}
|
||||
<ChevronRight className="ml-2 h-4 w-4 shrink-0 opacity-50 rotate-90" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start" sideOffset={4}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{countryOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={`${option.label} ${option.value}`}
|
||||
onSelect={() => {
|
||||
setFormData({ ...formData, selectedCountry: option.value === 'none' ? '' : option.value });
|
||||
setCountryPickerOpen(false);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<span>{option.flag}</span>
|
||||
<span className="flex-1">{option.label}</span>
|
||||
<Check className={cn('h-4 w-4', (formData.selectedCountry || 'none') === option.value ? 'opacity-100' : 'opacity-0')} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Cover image</Label>
|
||||
<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="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" />}
|
||||
<span className="text-sm">Upload custom</span>
|
||||
</Label>
|
||||
<input id="cover-upload" type="file" accept="image/*" className="hidden" onChange={handleFileUpload} disabled={isUploading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" placeholder="What needs to happen?" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
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 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<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="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="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 })} />}
|
||||
</div>
|
||||
|
||||
{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">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.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 pledge
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCancel} className="w-full">Cancel</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateActionDialog({ countryCode, communityATag, open, onOpenChange }: CreateActionDialogProps) {
|
||||
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();
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const [formData, setFormData] = useState<CreateActionFormState>({
|
||||
title: '',
|
||||
description: '',
|
||||
tagInput: '',
|
||||
pledgeUsd: '',
|
||||
deadline: '',
|
||||
time: '',
|
||||
coverImage: DEFAULT_COVER_IMAGE,
|
||||
selectedCountry: countryCode || '',
|
||||
timezone: browserTimezone,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!user) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const now = Date.now();
|
||||
const slug = formData.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
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],
|
||||
['bounty', String(pledgeSats)],
|
||||
['t', 'agora-action'],
|
||||
['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) {
|
||||
tags.push(...createOrganizationAssociationTags(communityATag));
|
||||
}
|
||||
if (formData.coverImage) tags.push(['image', formData.coverImage]);
|
||||
|
||||
if (formData.deadline) {
|
||||
const [year, month, day] = formData.deadline.split('-').map(Number);
|
||||
const [hours, minutes] = formData.time ? formData.time.split(':').map(Number) : [23, 59];
|
||||
tags.push(['deadline', String(unixSecondsInTimezone(year, month, day, hours, minutes, formData.timezone))]);
|
||||
}
|
||||
|
||||
await createEvent({ kind: 36639, content: formData.description, tags });
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['agora-actions'] });
|
||||
await queryClient.refetchQueries({ queryKey: ['agora-actions'] });
|
||||
if (communityATag) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['community-actions', communityATag] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['organization-activity', communityATag] }),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const [root, aTagsKey] = q.queryKey;
|
||||
return root === 'community-activity-feed'
|
||||
&& typeof aTagsKey === 'string'
|
||||
&& aTagsKey.split(',').includes(communityATag);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// Pledges (kind 36639) surface in the home Agora activity feed.
|
||||
await queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||||
|
||||
setFormData({
|
||||
title: '', description: '', tagInput: '', pledgeUsd: '',
|
||||
deadline: '', time: '',
|
||||
coverImage: DEFAULT_COVER_IMAGE,
|
||||
selectedCountry: countryCode || '',
|
||||
timezone: browserTimezone,
|
||||
});
|
||||
onOpenChange(false);
|
||||
toast({ title: 'Pledge created' });
|
||||
} catch (error) {
|
||||
console.error('Failed to create pledge:', error);
|
||||
toast({ title: 'Failed to create pledge', variant: 'destructive' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const description = communityATag
|
||||
? 'New organization pledge. You can optionally choose a country below.'
|
||||
: countryCode
|
||||
? `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 pledge</DrawerTitle>
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto flex-1 pb-safe">
|
||||
<CreateActionForm formData={formData} setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} onCancel={() => onOpenChange(false)} pageCountryCode={countryCode} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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 pledge</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto overflow-x-hidden flex-1 min-h-0">
|
||||
<CreateActionForm formData={formData} setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} onCancel={() => onOpenChange(false)} pageCountryCode={countryCode} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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,7 +23,9 @@ 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';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
|
||||
interface CreateCommunityEventDialogProps {
|
||||
communityATag?: string;
|
||||
@@ -80,11 +82,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 +240,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()) {
|
||||
@@ -315,7 +308,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
|
||||
const publishedEvent = await publishEvent({
|
||||
kind,
|
||||
content: description.trim(),
|
||||
tags,
|
||||
tags: withAgoraTag(tags),
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
@@ -339,6 +332,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;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getEffectiveRelays } from '@/lib/appRelays';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
|
||||
interface CreateGoalDialogProps {
|
||||
/** The community `a` tag coordinate (e.g. `34550:<pubkey>:<d-tag>`). */
|
||||
@@ -116,7 +117,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
|
||||
await publishEvent({
|
||||
kind: ZAP_GOAL_KIND,
|
||||
content: title.trim(),
|
||||
tags,
|
||||
tags: withAgoraTag(tags),
|
||||
});
|
||||
|
||||
// Refresh the goals tab and the community activity feed
|
||||
@@ -165,7 +166,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,257 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Ring {
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
maxRadius: number;
|
||||
life: number; // 1 → 0
|
||||
}
|
||||
|
||||
function parseHslString(hsl: string): { h: number; s: number; l: number } {
|
||||
const parts = hsl.trim().split(/\s+/);
|
||||
return {
|
||||
h: parseFloat(parts[0] ?? '30'),
|
||||
s: parseFloat(parts[1] ?? '100'),
|
||||
l: parseFloat(parts[2] ?? '55'),
|
||||
};
|
||||
}
|
||||
|
||||
export function CursorFireEffect() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particles = useRef<Particle[]>([]);
|
||||
const rings = useRef<Ring[]>([]);
|
||||
const cursor = useRef<{ x: number; y: number } | null>(null);
|
||||
const active = useRef(false);
|
||||
const raf = useRef(0);
|
||||
const pulse = useRef(0);
|
||||
const frame = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
function resize() {
|
||||
if (!canvas) return;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
cursor.current = { x: e.clientX, y: e.clientY };
|
||||
active.current = true;
|
||||
}
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
const t = e.touches[0];
|
||||
if (t) { cursor.current = { x: t.clientX, y: t.clientY }; active.current = true; }
|
||||
}
|
||||
function onLeave() { active.current = false; }
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
spawnClickBurst(e.clientX, e.clientY);
|
||||
}
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0];
|
||||
if (t) spawnClickBurst(t.clientX, t.clientY);
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||
window.addEventListener('mouseleave', onLeave);
|
||||
window.addEventListener('touchend', onLeave);
|
||||
window.addEventListener('click', onClick);
|
||||
window.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
|
||||
function getPrimary() {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim();
|
||||
return raw ? parseHslString(raw) : { h: 270, s: 80, l: 60 };
|
||||
}
|
||||
|
||||
function spawnWispParticles(x: number, y: number) {
|
||||
const count = Math.floor(Math.random() * 2) + 2;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.3;
|
||||
const speed = Math.random() * 0.6 + 0.3;
|
||||
particles.current.push({
|
||||
x: x + (Math.random() - 0.5) * 6,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed * 0.2,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
size: Math.random() * 28 + 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function spawnClickBurst(x: number, y: number) {
|
||||
// Expanding shockwave ring
|
||||
rings.current.push({ x, y, radius: 0, maxRadius: 120, life: 1 });
|
||||
|
||||
// Secondary smaller ring
|
||||
rings.current.push({ x, y, radius: 0, maxRadius: 60, life: 1 });
|
||||
|
||||
// Radial burst of particles in all directions
|
||||
const count = 18;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = (i / count) * Math.PI * 2;
|
||||
const speed = Math.random() * 3.5 + 1.5;
|
||||
particles.current.push({
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
size: Math.random() * 20 + 12,
|
||||
});
|
||||
}
|
||||
|
||||
// Extra upward plume
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.8;
|
||||
const speed = Math.random() * 4 + 2;
|
||||
particles.current.push({
|
||||
x: x + (Math.random() - 0.5) * 10,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed * 0.3,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
size: Math.random() * 30 + 18,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const { h, s, l } = getPrimary();
|
||||
|
||||
// Spawn wisp particles every 4th frame
|
||||
frame.current++;
|
||||
if (active.current && cursor.current && frame.current % 4 === 0) {
|
||||
spawnWispParticles(cursor.current.x, cursor.current.y);
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
|
||||
// Draw expanding rings
|
||||
const aliveRings: Ring[] = [];
|
||||
for (const r of rings.current) {
|
||||
r.life -= 0.022;
|
||||
if (r.life <= 0) continue;
|
||||
r.radius += (r.maxRadius - r.radius) * 0.08;
|
||||
|
||||
const t = r.life;
|
||||
const lineAlpha = Math.pow(t, 1.5) * 0.8;
|
||||
const glowAlpha = Math.pow(t, 2) * 0.4;
|
||||
const lineWidth = t * 3;
|
||||
|
||||
// Outer glow halo
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `hsla(${h}, ${s}%, ${Math.min(l + 20, 85)}%, ${glowAlpha})`;
|
||||
ctx.lineWidth = lineWidth + 8;
|
||||
ctx.stroke();
|
||||
|
||||
// Sharp ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `hsla(${h - 10}, ${s}%, 90%, ${lineAlpha})`;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.stroke();
|
||||
|
||||
aliveRings.push(r);
|
||||
}
|
||||
rings.current = aliveRings;
|
||||
|
||||
// Draw flame particles
|
||||
const alive: Particle[] = [];
|
||||
for (const p of particles.current) {
|
||||
p.life -= 0.005 + Math.random() * 0.002;
|
||||
if (p.life <= 0) continue;
|
||||
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy -= 0.018;
|
||||
p.vx *= 0.98;
|
||||
p.size *= 0.985;
|
||||
|
||||
const t = p.life;
|
||||
const ph = h + (1 - t) * 25;
|
||||
const pl = Math.min(l + t * 40, 90);
|
||||
const alpha = Math.pow(t, 1.5) * 0.18;
|
||||
const radius = p.size * (0.4 + t * 0.6);
|
||||
|
||||
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius);
|
||||
g.addColorStop(0, `hsla(${ph - 5}, ${s}%, ${pl}%, ${alpha})`);
|
||||
g.addColorStop(0.35, `hsla(${ph}, ${s}%, ${Math.max(l, 40)}%, ${alpha * 0.6})`);
|
||||
g.addColorStop(0.7, `hsla(${ph + 15}, ${s}%, ${Math.max(l - 15, 20)}%, ${alpha * 0.2})`);
|
||||
g.addColorStop(1, `hsla(${ph + 25}, ${s}%, ${Math.max(l - 25, 5)}%, 0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fill();
|
||||
|
||||
alive.push(p);
|
||||
}
|
||||
particles.current = alive;
|
||||
|
||||
// Orb: slow pulsing core glow at cursor
|
||||
if (active.current && cursor.current) {
|
||||
const { x, y } = cursor.current;
|
||||
pulse.current += 0.025;
|
||||
const pv = (Math.sin(pulse.current) + 1) / 2;
|
||||
const r = 20 + pv * 12;
|
||||
const a = 0.5 + pv * 0.3;
|
||||
|
||||
const orb = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
orb.addColorStop(0, `hsla(${h - 10}, ${Math.max(s - 10, 0)}%, 95%, ${a})`);
|
||||
orb.addColorStop(0.4, `hsla(${h}, ${s}%, ${Math.min(l + 15, 85)}%, ${a * 0.5})`);
|
||||
orb.addColorStop(1, `hsla(${h + 15}, ${s}%, ${l}%, 0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = orb;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
raf.current = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
raf.current = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
window.removeEventListener('mouseleave', onLeave);
|
||||
window.removeEventListener('touchend', onLeave);
|
||||
window.removeEventListener('click', onClick);
|
||||
window.removeEventListener('touchstart', onTouchStart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-[9999]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
|
||||
interface DetailCommentComposerProps {
|
||||
event: NostrEvent;
|
||||
placeholder?: string;
|
||||
onSuccess?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DetailCommentComposer({
|
||||
event,
|
||||
placeholder = "What's on your mind?",
|
||||
onSuccess,
|
||||
className,
|
||||
}: DetailCommentComposerProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ComposeBox
|
||||
compact
|
||||
defaultExpanded
|
||||
hideBorder
|
||||
replyTo={event}
|
||||
placeholder={placeholder}
|
||||
onSuccess={onSuccess}
|
||||
className="bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
HandHeart,
|
||||
Heart,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Sparkle,
|
||||
Sparkles,
|
||||
Star,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CampaignWalletDonatePanel } from '@/components/CampaignWalletDonatePanel';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import AuthDialog from '@/components/auth/AuthDialog';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDonateCampaign, type DonateCampaignResult, type DonationFeeSpeed } from '@/hooks/useDonateCampaign';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
BITCOIN_DUST_LIMIT,
|
||||
estimateFee,
|
||||
fetchUTXOs,
|
||||
formatSats,
|
||||
getFeeRates,
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
satsToUSD,
|
||||
usdToSats,
|
||||
type FeeRates,
|
||||
} from '@/lib/bitcoin';
|
||||
import {
|
||||
type ParsedCampaign,
|
||||
} from '@/lib/campaign';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Donation presets in USD. The signed event and Bitcoin transaction still use
|
||||
* sats; USD is only the user-facing input currency.
|
||||
*/
|
||||
const PRESET_AMOUNTS: readonly { amountUsd: number; icon: React.ComponentType<{ className?: string }>; label: string }[] = [
|
||||
{ amountUsd: 10, icon: Sparkle, label: '$10' },
|
||||
{ amountUsd: 25, icon: Sparkles, label: '$25' },
|
||||
{ amountUsd: 100, icon: Star, label: '$100' },
|
||||
{ amountUsd: 500, icon: Heart, label: '$500' },
|
||||
{ amountUsd: 1_000, icon: HandHeart, label: '$1K' },
|
||||
];
|
||||
|
||||
function parseUsdInput(input: string): number {
|
||||
const cleaned = input.replace(/[, $]/g, '').trim();
|
||||
if (!cleaned) return 0;
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
}
|
||||
|
||||
function feeRateForSpeed(rates: FeeRates, speed: DonationFeeSpeed): number {
|
||||
return {
|
||||
fastest: rates.fastestFee,
|
||||
halfHour: rates.halfHourFee,
|
||||
hour: rates.hourFee,
|
||||
economy: rates.economyFee,
|
||||
}[speed];
|
||||
}
|
||||
|
||||
function estimateDonationFee({
|
||||
feeRate,
|
||||
utxoCount,
|
||||
}: {
|
||||
feeRate: number;
|
||||
utxoCount: number;
|
||||
}): number {
|
||||
// Single recipient + change output.
|
||||
return estimateFee(utxoCount, 2, feeRate);
|
||||
}
|
||||
|
||||
interface DonateDialogProps {
|
||||
campaign: ParsedCampaign;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Spot price of BTC in USD, used for inline USD previews. Optional. */
|
||||
btcPrice?: number;
|
||||
}
|
||||
|
||||
type Step = 'form' | 'confirm' | 'success';
|
||||
|
||||
/**
|
||||
* Donate dialog for **on-chain** (`bc1q…` / `bc1p…`) campaigns. The
|
||||
* campaign's `w` wallet endpoint is the single output destination —
|
||||
* there are no recipient splits, no per-recipient previews, and no
|
||||
* dust math beyond the one-output PSBT.
|
||||
*
|
||||
* Silent-payment campaigns (`sp1…`) never open this dialog; their
|
||||
* detail-page donate column points directly at the SP code via the
|
||||
* `CampaignWalletDonatePanel` so donors can scan/copy and pay from a
|
||||
* BIP-352-aware external wallet.
|
||||
*/
|
||||
export function DonateDialog({ campaign, open, onOpenChange, btcPrice }: DonateDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { canSignPsbt } = useBitcoinSigner();
|
||||
const { donateToCampaign } = useDonateCampaign();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [step, setStep] = useState<Step>('form');
|
||||
const [amountUsd, setAmountUsd] = useState<number>(PRESET_AMOUNTS[1].amountUsd);
|
||||
const [customUsd, setCustomUsd] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [feeSpeed, setFeeSpeed] = useState<DonationFeeSpeed>('fastest');
|
||||
const [result, setResult] = useState<DonateCampaignResult | null>(null);
|
||||
|
||||
// Reset when the dialog reopens for a fresh donation.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('form');
|
||||
setResult(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const effectiveUsd = customUsd.trim()
|
||||
? parseUsdInput(customUsd)
|
||||
: amountUsd;
|
||||
const effectiveAmount = usdToSats(effectiveUsd, btcPrice);
|
||||
|
||||
const belowDust = Number.isFinite(effectiveAmount) && effectiveAmount > 0 && effectiveAmount < BITCOIN_DUST_LIMIT;
|
||||
|
||||
const donateMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
donateToCampaign({
|
||||
campaign,
|
||||
amountSats: effectiveAmount,
|
||||
comment,
|
||||
feeSpeed,
|
||||
}),
|
||||
onSuccess: (r) => {
|
||||
setResult(r);
|
||||
setStep('success');
|
||||
if (!r.receiptPublished) {
|
||||
toast({
|
||||
title: 'Donation sent, but the receipt failed',
|
||||
description: `On-chain tx ${r.txid.slice(0, 12)}… broadcast; the kind 8333 receipt didn't publish${r.receiptPublishError ? ` (${r.receiptPublishError})` : ''}.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Donation sent',
|
||||
description: `Thanks for supporting ${campaign.title}.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
toast({
|
||||
title: 'Donation failed',
|
||||
description: msg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
if (donateMutation.isPending) return;
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// ── Logged-out flow ──
|
||||
if (open && !user) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<LoggedOutChooserView
|
||||
campaign={campaign}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Logged-in but the signer can't build a PSBT (e.g. NIP-07 extension
|
||||
// without signPsbt). Direct the donor at the external-wallet panel on
|
||||
// the page — the in-app flow simply isn't possible without a PSBT
|
||||
// signer.
|
||||
if (open && !canSignPsbt) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<SignerUnsupportedView campaign={campaign} onClose={handleClose} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
||||
{step === 'form' && (
|
||||
<FormView
|
||||
campaign={campaign}
|
||||
amountUsd={amountUsd}
|
||||
customUsd={customUsd}
|
||||
comment={comment}
|
||||
feeSpeed={feeSpeed}
|
||||
effectiveAmount={effectiveAmount}
|
||||
effectiveUsd={effectiveUsd}
|
||||
belowDust={belowDust}
|
||||
btcPrice={btcPrice}
|
||||
isPending={donateMutation.isPending}
|
||||
onAmountChange={(usd) => {
|
||||
setAmountUsd(usd);
|
||||
setCustomUsd('');
|
||||
}}
|
||||
onCustomChange={setCustomUsd}
|
||||
onCommentChange={setComment}
|
||||
onFeeSpeedChange={setFeeSpeed}
|
||||
onContinue={() => setStep('confirm')}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<ConfirmView
|
||||
campaign={campaign}
|
||||
amountSats={effectiveAmount}
|
||||
effectiveUsd={effectiveUsd}
|
||||
comment={comment}
|
||||
feeSpeed={feeSpeed}
|
||||
btcPrice={btcPrice}
|
||||
isPending={donateMutation.isPending}
|
||||
onBack={() => setStep('form')}
|
||||
onSubmit={() => donateMutation.mutate()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'success' && result && (
|
||||
<SuccessView
|
||||
campaign={campaign}
|
||||
result={result}
|
||||
btcPrice={btcPrice}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Form step
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FormViewProps {
|
||||
campaign: ParsedCampaign;
|
||||
amountUsd: number;
|
||||
customUsd: string;
|
||||
comment: string;
|
||||
feeSpeed: DonationFeeSpeed;
|
||||
effectiveAmount: number;
|
||||
effectiveUsd: number;
|
||||
belowDust: boolean;
|
||||
btcPrice: number | undefined;
|
||||
isPending: boolean;
|
||||
onAmountChange: (usd: number) => void;
|
||||
onCustomChange: (value: string) => void;
|
||||
onCommentChange: (value: string) => void;
|
||||
onFeeSpeedChange: (speed: DonationFeeSpeed) => void;
|
||||
onContinue: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function FormView({
|
||||
campaign,
|
||||
amountUsd,
|
||||
customUsd,
|
||||
comment,
|
||||
feeSpeed,
|
||||
effectiveAmount,
|
||||
effectiveUsd,
|
||||
belowDust,
|
||||
btcPrice,
|
||||
isPending,
|
||||
onAmountChange,
|
||||
onCustomChange,
|
||||
onCommentChange,
|
||||
onFeeSpeedChange,
|
||||
onContinue,
|
||||
}: FormViewProps) {
|
||||
const usingCustom = customUsd.trim().length > 0;
|
||||
const canContinue = effectiveAmount > 0 && !belowDust;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Donate to {campaign.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send Bitcoin to the campaign's wallet from your in-app balance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Preset amounts */}
|
||||
<div>
|
||||
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Amount
|
||||
</Label>
|
||||
<div className="mt-2 grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
{PRESET_AMOUNTS.map(({ amountUsd: usd, icon: Icon, label }) => {
|
||||
const selected = !usingCustom && amountUsd === usd;
|
||||
return (
|
||||
<button
|
||||
key={usd}
|
||||
type="button"
|
||||
onClick={() => onAmountChange(usd)}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 rounded-lg border px-2 py-2.5 text-xs font-semibold motion-safe:transition-colors',
|
||||
selected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-card hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom amount */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="donate-custom" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Or custom (USD)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="donate-custom"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="50"
|
||||
value={customUsd}
|
||||
onChange={(e) => onCustomChange(e.target.value)}
|
||||
className="pl-7"
|
||||
/>
|
||||
</div>
|
||||
{effectiveAmount > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
≈ {formatSats(effectiveAmount)} sats
|
||||
{btcPrice && effectiveUsd > 0 && (
|
||||
<> · ${effectiveUsd.toLocaleString()} at current price</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="donate-comment" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Public comment (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="donate-comment"
|
||||
value={comment}
|
||||
onChange={(e) => onCommentChange(e.target.value)}
|
||||
placeholder="Stay strong."
|
||||
rows={2}
|
||||
maxLength={280}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fee speed */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Confirmation speed
|
||||
</Label>
|
||||
<Select value={feeSpeed} onValueChange={(v) => onFeeSpeedChange(v as DonationFeeSpeed)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fastest">Fastest (~10 min)</SelectItem>
|
||||
<SelectItem value="halfHour">Half hour</SelectItem>
|
||||
<SelectItem value="hour">Hour</SelectItem>
|
||||
<SelectItem value="economy">Economy (cheapest)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{belowDust && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertDescription>
|
||||
Amount is below the Bitcoin dust limit ({BITCOIN_DUST_LIMIT.toLocaleString()} sats).
|
||||
Choose a larger amount.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<BitcoinPublicDisclaimer tone="soft" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onContinue}
|
||||
disabled={!canContinue || isPending}
|
||||
>
|
||||
Review donation
|
||||
<ArrowUpRight className="size-4 ml-1.5" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Confirm step
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ConfirmViewProps {
|
||||
campaign: ParsedCampaign;
|
||||
amountSats: number;
|
||||
effectiveUsd: number;
|
||||
comment: string;
|
||||
feeSpeed: DonationFeeSpeed;
|
||||
btcPrice: number | undefined;
|
||||
isPending: boolean;
|
||||
onBack: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
function ConfirmView({
|
||||
campaign,
|
||||
amountSats,
|
||||
effectiveUsd,
|
||||
comment,
|
||||
feeSpeed,
|
||||
btcPrice,
|
||||
isPending,
|
||||
onBack,
|
||||
onSubmit,
|
||||
}: ConfirmViewProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
|
||||
const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : null;
|
||||
|
||||
// Pre-fetch UTXOs + fee rates so the confirm screen can show an
|
||||
// accurate fee estimate before the donor commits.
|
||||
const utxosQuery = useQuery({
|
||||
queryKey: ['bitcoin-utxos', senderAddress, esploraApis],
|
||||
queryFn: ({ signal }) => fetchUTXOs(senderAddress!, esploraApis, signal),
|
||||
enabled: !!senderAddress,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const feeRatesQuery = useQuery({
|
||||
queryKey: ['bitcoin-fee-rates', esploraApis],
|
||||
queryFn: ({ signal }) => getFeeRates(esploraApis, signal),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const feeEstimate = useMemo(() => {
|
||||
const utxos = utxosQuery.data;
|
||||
const rates = feeRatesQuery.data;
|
||||
if (!utxos || !rates) return null;
|
||||
return estimateDonationFee({
|
||||
feeRate: feeRateForSpeed(rates, feeSpeed),
|
||||
utxoCount: utxos.length,
|
||||
});
|
||||
}, [utxosQuery.data, feeRatesQuery.data, feeSpeed]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground motion-safe:transition-colors -ml-1"
|
||||
disabled={isPending}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back
|
||||
</button>
|
||||
<DialogTitle>Confirm donation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the details before signing the transaction.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<Row label="Campaign" value={campaign.title} />
|
||||
<Row
|
||||
label="Amount"
|
||||
value={
|
||||
<span>
|
||||
<span className="font-semibold">{formatSats(amountSats)} sats</span>
|
||||
{btcPrice && effectiveUsd > 0 && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">≈ ${effectiveUsd.toLocaleString()}</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="To wallet"
|
||||
value={
|
||||
<span className="font-mono text-xs break-all">{campaign.wallet.value}</span>
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Network fee"
|
||||
value={
|
||||
feeEstimate === null ? (
|
||||
<Skeleton className="h-4 w-20" />
|
||||
) : (
|
||||
<span>
|
||||
<span className="font-semibold">{formatSats(feeEstimate)} sats</span>
|
||||
{btcPrice && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
≈ ${satsToUSD(feeEstimate, btcPrice)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{comment.trim() && (
|
||||
<Row label="Comment" value={<span className="italic">"{comment}"</span>} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onSubmit}
|
||||
disabled={isPending || feeEstimate === null}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Sending donation…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HandHeart className="size-5 mr-2" />
|
||||
Send donation
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 text-sm">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="text-right min-w-0">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Success step
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SuccessView({
|
||||
campaign,
|
||||
result,
|
||||
btcPrice,
|
||||
onClose,
|
||||
}: {
|
||||
campaign: ParsedCampaign;
|
||||
result: DonateCampaignResult;
|
||||
btcPrice: number | undefined;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="mx-auto rounded-full bg-primary/15 p-3 mb-2">
|
||||
<Check className="size-8 text-primary" />
|
||||
</div>
|
||||
<DialogTitle className="text-center">Thank you!</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
Your donation to <span className="font-semibold text-foreground">{campaign.title}</span> is on its way.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
<Row
|
||||
label="Amount"
|
||||
value={
|
||||
<span className="font-semibold">
|
||||
{formatSats(result.totalSats)} sats
|
||||
{btcPrice && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
≈ ${satsToUSD(result.totalSats, btcPrice)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Network fee"
|
||||
value={<span>{formatSats(result.fee)} sats</span>}
|
||||
/>
|
||||
<Row
|
||||
label="Transaction"
|
||||
value={
|
||||
<a
|
||||
href={`https://mempool.space/tx/${result.txid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-xs text-primary hover:underline break-all"
|
||||
>
|
||||
{result.txid.slice(0, 16)}…
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button size="lg" className="w-full" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Logged-out chooser
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function LoggedOutChooserView({
|
||||
campaign,
|
||||
onClose,
|
||||
}: {
|
||||
campaign: ParsedCampaign;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Donate to {campaign.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Log in to donate from your in-app wallet, or scan the QR on the
|
||||
campaign page to pay from any external Bitcoin wallet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => setAuthOpen(true)}
|
||||
>
|
||||
<LogIn className="size-4 mr-2" />
|
||||
Log in to donate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
Pay from external wallet instead
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AuthDialog isOpen={authOpen} onClose={() => setAuthOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Signer-unsupported fallback
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SignerUnsupportedView({
|
||||
campaign,
|
||||
onClose,
|
||||
}: {
|
||||
campaign: ParsedCampaign;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Donate to {campaign.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan the QR code with your phone's Bitcoin wallet, or tap "Open in
|
||||
wallet" to send your donation. You choose the amount in your wallet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<CampaignWalletDonatePanel wallet={campaign.wallet} />
|
||||
|
||||
<Button variant="outline" size="lg" className="w-full" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Loading skeleton (for callers that need a placeholder button)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DonateButtonSkeleton() {
|
||||
return <Skeleton className="h-11 w-full rounded-md" />;
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { z } from 'zod';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
|
||||
// Extended form schema that includes custom fields
|
||||
@@ -203,7 +202,7 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
|
||||
// Combine existing metadata with new values
|
||||
const data: Record<string, unknown> = { ...metadata, ...standardMetadata };
|
||||
|
||||
// Strip any legacy avatar shape data from old Ditto-style profiles
|
||||
// Strip any legacy avatar-shape field carried over from older clients.
|
||||
delete data.shape;
|
||||
|
||||
// Clean up empty values in standard metadata
|
||||
@@ -248,14 +247,11 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
|
||||
return (
|
||||
<div>
|
||||
{/* Intro */}
|
||||
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
|
||||
<IntroImage src="/profile-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">Your Identity</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-2 pb-4">
|
||||
<h2 className="text-sm font-semibold">Your Identity</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Crop dialog */}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Award, Image, MessageSquareOff } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ActionContent } from '@/components/ActionContent';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
|
||||
@@ -88,6 +89,10 @@ export function EmbeddedNaddr({ addr, className, disableHoverCards }: EmbeddedNa
|
||||
return <EmbeddedProfileBadgesCard event={event} className={className} />;
|
||||
}
|
||||
|
||||
if (event.kind === 36639) {
|
||||
return <ActionContent event={event} />;
|
||||
}
|
||||
|
||||
return <EmbeddedNaddrCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, BookOpen, Coins, ExternalLink, FileText, Globe, Landmark, Languages, MapPin, MessageCircle, Package, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users, Zap } from 'lucide-react';
|
||||
import { ArrowLeft, BookOpen, Coins, ExternalLink, FileText, Globe, Landmark, Languages, MapPin, Megaphone, MessageCircle, Package, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { Skeleton } from '@/components/ui/skeleton';import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
|
||||
import { FollowToggleButton } from '@/components/FollowButton';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
@@ -22,10 +21,11 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCountryFollows } from '@/hooks/useCountryFollows';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
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 +231,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]" />
|
||||
@@ -627,9 +636,11 @@ function WikipediaExtract({ extract, articleUrl }: { extract: string; articleUrl
|
||||
* above the Wikipedia extract doesn't draw against a phantom row.
|
||||
*/
|
||||
function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts | undefined }) {
|
||||
const { data: weather, isLoading } = useWeather(code);
|
||||
const capital = facts?.capital ?? null;
|
||||
|
||||
// Weather has been removed; this row now renders only the country vitals
|
||||
// (population / languages / currency). The legacy name is preserved so
|
||||
// the mount call sites don't churn — the row still vanishes when there
|
||||
// are no vitals to show, matching the original behavior.
|
||||
void code;
|
||||
const vitals: { key: string; icon: React.ReactNode; label: string; value: string }[] = [];
|
||||
if (facts) {
|
||||
if (facts.population !== null) {
|
||||
@@ -659,40 +670,17 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading && vitals.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-2 flex items-center gap-3">
|
||||
<Skeleton className="size-6 rounded-md" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (vitals.length === 0) return null;
|
||||
|
||||
const hasWeatherSide = !!weather || !!capital;
|
||||
const hasVitalsSide = vitals.length > 0;
|
||||
if (!hasWeatherSide && !hasVitalsSide) return null;
|
||||
const capital = facts?.capital ?? null;
|
||||
const hasCapitalSide = !!capital;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 text-sm">
|
||||
{/* Left group — weather + capital. */}
|
||||
{hasWeatherSide && (
|
||||
{/* Left group — capital. */}
|
||||
{hasCapitalSide && (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 min-w-0">
|
||||
{weather && (
|
||||
<>
|
||||
<span className="flex items-baseline gap-2 text-foreground">
|
||||
<span className="text-xl leading-none" role="img" aria-label={weather.description}>
|
||||
{weather.icon}
|
||||
</span>
|
||||
<span className="font-bold tabular-nums">{weather.temperature}°</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">{weather.description}</span>
|
||||
</>
|
||||
)}
|
||||
{capital && (
|
||||
// The country's capital is the stable national place anchor for
|
||||
// the header. The weather-station city is intentionally omitted
|
||||
// — it's often a smaller, less-recognised town nearby and
|
||||
// duplicates a less-meaningful place name on the same line.
|
||||
<span className="flex items-center gap-1 text-muted-foreground/80 text-xs">
|
||||
<Landmark className="size-3 shrink-0" />
|
||||
<span>{capital}</span>
|
||||
@@ -701,28 +689,21 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right group — vitals (population, language, currency). On narrow
|
||||
viewports this wraps onto its own line under the weather group
|
||||
rather than getting crushed beside it. Styled to match the
|
||||
capital chip on the left (text-xs muted-foreground/80 with a
|
||||
size-3 icon) so the row reads as a single uniform metadata
|
||||
strip rather than two competing weights. */}
|
||||
{hasVitalsSide && (
|
||||
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 min-w-0">
|
||||
{vitals.map((item) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className="flex items-center gap-1 min-w-0"
|
||||
title={`${item.label}: ${item.value}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="truncate max-w-[14ch] sm:max-w-[18ch]">
|
||||
{item.value}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Right group — vitals (population, language, currency). */}
|
||||
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 min-w-0">
|
||||
{vitals.map((item) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className="flex items-center gap-1 min-w-0"
|
||||
title={`${item.label}: ${item.value}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="truncate max-w-[14ch] sm:max-w-[18ch]">
|
||||
{item.value}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -866,7 +847,6 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
// Country facts are only fetched for sovereign countries (alpha-2 codes);
|
||||
// the hook's internal guard returns `null` for subdivisions like `US-CA`.
|
||||
const { data: facts } = useCountryFacts(info?.subdivision ? null : code);
|
||||
const { data: weather } = useWeather(code);
|
||||
const { user } = useCurrentUser();
|
||||
const { isFollowingCountry, toggleCountryFollow, isPending } = useCountryFollows();
|
||||
const { toast } = useToast();
|
||||
@@ -899,15 +879,23 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const heroImage = wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null;
|
||||
const isDay = weather?.isDay ?? true;
|
||||
// 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;
|
||||
// Always render the daytime sky overlay. Previously we keyed this off the
|
||||
// live `weather.isDay` flag to flip into a night palette; weather has been
|
||||
// removed so we default to the warm amber/rose daytime tint.
|
||||
const 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 +908,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 +980,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 +1020,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 +1226,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 +1251,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 +1275,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 +1319,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 +1433,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 } }) {
|
||||
@@ -1458,7 +1458,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey
|
||||
// Fallback icons for well-known kinds not in EXTRA_KINDS
|
||||
if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063 || addr.kind === 3063) return Package;
|
||||
if (addr.kind === 15128 || addr.kind === 35128) return Globe;
|
||||
if (addr.kind === 36639) return Zap;
|
||||
if (addr.kind === 36639) return Megaphone;
|
||||
return FileText;
|
||||
}, [kindDef, addr.kind]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -7,30 +6,25 @@ import { LandingHero } from '@/components/LandingHero';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { FeedModeSwitcher } from '@/components/FeedModeSwitcher';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Globe2, Loader2 } from 'lucide-react';
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { useMixedFeed, type FeedMode } from '@/hooks/useMixedFeed';
|
||||
import { shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
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 {
|
||||
@@ -44,73 +38,42 @@ interface FeedProps {
|
||||
hideCompose?: boolean;
|
||||
/** Message shown when the feed is empty. */
|
||||
emptyMessage?: string;
|
||||
/** Unique identifier for this feed page, used to persist the active tab in sessionStorage. Defaults to 'home'. */
|
||||
/** Unique identifier for this feed page, used to persist the active tab/mode in localStorage. Defaults to 'home'. */
|
||||
feedId?: string;
|
||||
}
|
||||
|
||||
const FEED_MODES: readonly FeedMode[] = ['agora', 'all-nostr', 'following'] as const;
|
||||
|
||||
function isFeedMode(value: string): value is FeedMode {
|
||||
return (FEED_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
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.
|
||||
// For the home /feed page we use a three-mode picker instead of the
|
||||
// Follows/Global tab pair. Mode persists via the same useFeedTab storage,
|
||||
// keyed under the same feedId.
|
||||
const homeFeedMode: FeedMode = (() => {
|
||||
if (!isHomeAgoraFeed) return 'agora';
|
||||
if (isFeedMode(rawActiveTab)) return rawActiveTab;
|
||||
// Legacy values get coerced to the Agora default.
|
||||
return 'agora';
|
||||
})();
|
||||
|
||||
// Specialized feed pages keep the original Follows + Global tabs.
|
||||
const activeTab: FeedTab = (() => {
|
||||
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 (isHomeAgoraFeed) return homeFeedMode;
|
||||
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
|
||||
@@ -121,60 +84,51 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
}
|
||||
}, [rawActiveTab, handleSetActiveTab]);
|
||||
|
||||
const handleModeChange = (mode: FeedMode) => {
|
||||
handleSetActiveTab(mode);
|
||||
};
|
||||
|
||||
// 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;
|
||||
// -------------------------------------------------------------------------
|
||||
// Home feed (mixed-mode): drives off useMixedFeed.
|
||||
// -------------------------------------------------------------------------
|
||||
const mixedFeed = useMixedFeed(homeFeedMode, isHomeAgoraFeed);
|
||||
|
||||
// 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;
|
||||
|
||||
// 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';
|
||||
// -------------------------------------------------------------------------
|
||||
// Specialized feed pages: original Follows/Global behavior.
|
||||
// -------------------------------------------------------------------------
|
||||
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters && !isHomeAgoraFeed;
|
||||
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'
|
||||
? (isHomeFollowingActive ? 'network' : 'network')
|
||||
? 'network'
|
||||
: activeTab === 'network' || activeTab === 'global' || activeTab === 'communities'
|
||||
? (activeTab as UseFeedTab)
|
||||
: 'global';
|
||||
const standardFeedOptions = (kinds || tagFilters)
|
||||
? { kinds, tagFilters, enabled: !isHomeFollowingActive }
|
||||
: { enabled: !isHomeFollowingActive };
|
||||
? { kinds, tagFilters, enabled: !isHomeFollowingActive && !isHomeAgoraFeed }
|
||||
: { enabled: !isHomeFollowingActive && !isHomeAgoraFeed };
|
||||
const feedQuery = useFeed(
|
||||
isCoreFeedTab && !isWorldActive ? feedTabForQuery : 'global',
|
||||
isCoreFeedTab && !isHomeAgoraFeed ? 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;
|
||||
|
||||
// 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],
|
||||
() => isHomeAgoraFeed
|
||||
? ['mixed-feed', homeFeedMode]
|
||||
: isHomeFollowingActive
|
||||
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
|
||||
: ['feed', activeTab],
|
||||
[isHomeAgoraFeed, homeFeedMode, 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 +140,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 = isHomeAgoraFeed ? mixedFeed.fetchNextPage : fetchNextPageStandard;
|
||||
const hasNextPage = isHomeAgoraFeed ? mixedFeed.hasNextPage : hasNextPageStandard;
|
||||
const isFetchingNextPage = isHomeAgoraFeed ? mixedFeed.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 && !isHomeAgoraFeed && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [isHomeFollowingActive, isWorldActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
|
||||
}, [isHomeFollowingActive, isHomeAgoraFeed, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
|
||||
|
||||
// Intersection observer for infinite scroll
|
||||
const { ref: scrollRef, inView } = useInView({
|
||||
@@ -211,9 +165,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 (isHomeAgoraFeed) {
|
||||
return mixedFeed.items;
|
||||
}
|
||||
|
||||
if (!rawData?.pages) return [];
|
||||
@@ -229,82 +182,67 @@ 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]);
|
||||
}, [isHomeAgoraFeed, mixedFeed.items, rawData?.pages, muteItems]);
|
||||
|
||||
// Show skeletons while loading.
|
||||
const showSkeleton = isWorldActive
|
||||
? worldFeed.isLoading
|
||||
: (isPending || (isLoading && !rawData));
|
||||
const showSkeleton = isHomeAgoraFeed
|
||||
? mixedFeed.isLoading && feedItems.length === 0
|
||||
: (isPending || (isLoading && !rawData));
|
||||
|
||||
const showSavedFeedTabs = user && !isKindSpecificPage && !tagFilters;
|
||||
// Per-mode empty-state copy for the home feed.
|
||||
const homeEmptyMessage = (() => {
|
||||
if (homeFeedMode === 'agora') {
|
||||
return "Quiet moment on Agora. New campaigns, pledges, donations, and posts will appear here as they happen.";
|
||||
}
|
||||
if (homeFeedMode === 'following') {
|
||||
return user
|
||||
? "Your follow feed is empty. Follow some people to see what they're up to, or switch to Agora or All Nostr."
|
||||
: "Log in to see posts from people you follow.";
|
||||
}
|
||||
return 'Nothing to show. Check your relay connections or try again in a moment.';
|
||||
})();
|
||||
|
||||
return (
|
||||
<main className="flex-1 min-w-0 min-h-dvh">
|
||||
{header}
|
||||
<main className="flex-1 min-w-0 min-h-dvh bg-background">
|
||||
<div>
|
||||
{header}
|
||||
|
||||
{/* CTA (logged out, main feed only) */}
|
||||
{!user && !kinds && (
|
||||
<LandingHero
|
||||
onLoginClick={() => setLoginDialogOpen(true)}
|
||||
onSignupClick={startSignup}
|
||||
/>
|
||||
)}
|
||||
{/* CTA (logged out, main feed only) */}
|
||||
{!user && !kinds && (
|
||||
<LandingHero onJoinClick={() => setAuthDialogOpen(true)} />
|
||||
)}
|
||||
|
||||
{!hideCompose && <ComposeBox compact hideBorder />}
|
||||
|
||||
{/* 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) && (
|
||||
<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)}
|
||||
{/* Home-feed mode switcher: top-left, anchors the page visually */}
|
||||
{isHomeAgoraFeed && (
|
||||
<div className="px-4 pt-5 pb-3 sm:pt-6">
|
||||
<FeedModeSwitcher
|
||||
value={homeFeedMode}
|
||||
onChange={handleModeChange}
|
||||
followingAvailable={!!user}
|
||||
onLoginRequested={() => setAuthDialogOpen(true)}
|
||||
/>
|
||||
))}
|
||||
</SubHeaderBar>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{!hideCompose && (
|
||||
<ComposeBox
|
||||
compact
|
||||
hideBorder={isHomeAgoraFeed}
|
||||
defaultExpanded
|
||||
placeholder="What's happening?"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabs are only kept for specialized feed pages. The home feed uses
|
||||
the FeedModeSwitcher above. */}
|
||||
{user && (isKindSpecificPage || tagFilters) && (
|
||||
<SubHeaderBar>
|
||||
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
|
||||
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
|
||||
</SubHeaderBar>
|
||||
)}
|
||||
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{showSkeleton ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
@@ -318,7 +256,6 @@ 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)}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
@@ -331,157 +268,78 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isHomeFollowingActive && !emptyMessage ? (
|
||||
<FollowingEmptyState onExploreWorld={() => navigate('/world')} />
|
||||
) : isHomeAgoraFeed ? (
|
||||
<HomeFeedEmptyState
|
||||
mode={homeFeedMode}
|
||||
message={homeEmptyMessage}
|
||||
onSwitchToAgora={homeFeedMode !== 'agora' ? () => handleModeChange('agora') : undefined}
|
||||
onLoginClick={!user && homeFeedMode === 'following' ? () => setAuthDialogOpen(true) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<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.'
|
||||
: 'No posts 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>
|
||||
);
|
||||
}
|
||||
interface HomeFeedEmptyStateProps {
|
||||
mode: FeedMode;
|
||||
message: string;
|
||||
onSwitchToAgora?: () => void;
|
||||
onLoginClick?: () => void;
|
||||
}
|
||||
|
||||
function HomeFeedEmptyState({ mode, message, onSwitchToAgora, onLoginClick }: HomeFeedEmptyStateProps) {
|
||||
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>
|
||||
<div className="py-20 px-8 flex flex-col items-center text-center">
|
||||
<p className="text-muted-foreground max-w-sm leading-relaxed">{message}</p>
|
||||
<div className="flex flex-col gap-2 mt-6 w-full max-w-xs">
|
||||
{onLoginClick && (
|
||||
<Button className="rounded-full" onClick={onLoginClick}>
|
||||
Log in
|
||||
</Button>
|
||||
)}
|
||||
{onSwitchToAgora && (
|
||||
<Button
|
||||
variant={mode === 'following' ? 'default' : 'ghost'}
|
||||
className="rounded-full"
|
||||
onClick={onSwitchToAgora}
|
||||
>
|
||||
Browse the Agora feed
|
||||
</Button>
|
||||
)}
|
||||
{!hasNextPage && <div ref={scrollRef} className="py-2" />}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCardSkeleton() {
|
||||
function NoteCardSkeleton({ className }: { className?: string }) {
|
||||
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">
|
||||
@@ -502,25 +360,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 countries yet</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Explore the World page and follow countries to build your Following feed.
|
||||
</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" />
|
||||
Explore World
|
||||
</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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Check, ChevronDown, Globe, Sparkles, Users } from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { FeedMode } from '@/hooks/useMixedFeed';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FeedModeOption {
|
||||
mode: FeedMode;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const OPTIONS: FeedModeOption[] = [
|
||||
{ mode: 'agora', label: 'Agora', icon: Sparkles },
|
||||
{ mode: 'all-nostr', label: 'All Nostr', icon: Globe },
|
||||
{ mode: 'following', label: 'Following', icon: Users },
|
||||
];
|
||||
|
||||
interface FeedModeSwitcherProps {
|
||||
value: FeedMode;
|
||||
onChange: (mode: FeedMode) => void;
|
||||
/** When false, Following mode is disabled (requires login). */
|
||||
followingAvailable: boolean;
|
||||
/** Click handler for the disabled Following item (typically opens the auth dialog). */
|
||||
onLoginRequested?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary feed-mode picker rendered at the top-left of the home feed page.
|
||||
*
|
||||
* Visually anchored as the page heading — the active mode label is the largest
|
||||
* text on the page. Clicking opens a compact dropdown menu offering the three
|
||||
* modes; the active one is marked with a check.
|
||||
*
|
||||
* Logged-out users see "Following" greyed out; clicking it invokes
|
||||
* {@link FeedModeSwitcherProps.onLoginRequested} to surface the auth dialog.
|
||||
*/
|
||||
export function FeedModeSwitcher({
|
||||
value,
|
||||
onChange,
|
||||
followingAvailable,
|
||||
onLoginRequested,
|
||||
className,
|
||||
}: FeedModeSwitcherProps) {
|
||||
const active = OPTIONS.find((opt) => opt.mode === value) ?? OPTIONS[0];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-2 rounded-lg -ml-1 px-1 py-1 outline-none',
|
||||
'text-foreground hover:text-foreground motion-safe:transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
className,
|
||||
)}
|
||||
aria-label={`Feed mode: ${active.label}. Click to change.`}
|
||||
>
|
||||
<span className="text-2xl sm:text-3xl font-bold tracking-tight leading-none">
|
||||
{active.label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="size-5 text-muted-foreground motion-safe:transition-transform group-data-[state=open]:rotate-180"
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-56 p-1.5">
|
||||
{OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
const isActive = opt.mode === value;
|
||||
const isFollowing = opt.mode === 'following';
|
||||
const disabled = isFollowing && !followingAvailable;
|
||||
|
||||
const handleSelect = (event: Event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
onLoginRequested?.();
|
||||
return;
|
||||
}
|
||||
onChange(opt.mode);
|
||||
};
|
||||
|
||||
const itemContent = (
|
||||
<DropdownMenuItem
|
||||
key={opt.mode}
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 cursor-pointer',
|
||||
disabled && 'opacity-60 data-[disabled]:opacity-60',
|
||||
)}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="flex-1 text-sm font-medium">{opt.label}</span>
|
||||
{isActive && <Check className="size-4 shrink-0 text-primary" aria-hidden />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<Tooltip key={opt.mode}>
|
||||
<TooltipTrigger asChild>{itemContent}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Log in to see posts from people you follow
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return itemContent;
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -15,12 +15,9 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parsePackEvent } from '@/lib/packUtils';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
@@ -139,10 +136,9 @@ export function PackMembersTab({
|
||||
*/
|
||||
export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
const { toast } = useToast();
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followList } = useFollowList();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { followMany } = useFollowActions();
|
||||
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
@@ -175,27 +171,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
|
||||
setIsFollowingAll(true);
|
||||
try {
|
||||
// 1. Fetch freshest kind 3 from relays (not cache)
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
|
||||
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
|
||||
const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
|
||||
const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
|
||||
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
|
||||
|
||||
// 3. Merge: add new pubkeys that aren't already followed
|
||||
const newPTags = pubkeys
|
||||
.filter((pk) => !existingPubkeys.has(pk))
|
||||
.map((pk) => ['p', pk]);
|
||||
const added = newPTags.length;
|
||||
|
||||
// 4. Publish with prev for published_at preservation
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content: prev?.content ?? '',
|
||||
tags: [...nonPTags, ...existingPTags, ...newPTags],
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
const added = await followMany(pubkeys);
|
||||
|
||||
toast({
|
||||
title: 'Following all!',
|
||||
@@ -213,7 +189,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
} finally {
|
||||
setIsFollowingAll(false);
|
||||
}
|
||||
}, [user, pubkeys, nostr, publishEvent, toast]);
|
||||
}, [user, pubkeys, followMany, toast]);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
|
||||
@@ -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,129 @@
|
||||
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="bg-background mt-auto pt-12">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
|
||||
<nav className="flex items-center gap-5">
|
||||
<a href="/planetora" className="hover:text-foreground motion-safe:transition-colors">Planetora</a>
|
||||
<a href="/help" className="hover:text-foreground motion-safe:transition-colors">Help</a>
|
||||
<a href="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</a>
|
||||
<a href="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</a>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { type GuideSection } from '@/lib/helpContent';
|
||||
import { renderInlineMarkup } from '@/lib/helpMarkup';
|
||||
|
||||
/**
|
||||
* Renders a single {@link GuideSection} as a Card. Used by the Donor Guide
|
||||
* and Activist Guide pages.
|
||||
*
|
||||
* Paragraphs accept the same inline markup as FAQ answers (**bold** and
|
||||
* [link](url)). Optional `pros` / `cons` arrays render as colored bullet
|
||||
* lists beneath the paragraphs.
|
||||
*/
|
||||
export function GuideSectionCard({ section }: { section: GuideSection }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{section.heading}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm leading-relaxed text-foreground/80">
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<p key={i}>{renderInlineMarkup(p)}</p>
|
||||
))}
|
||||
|
||||
{section.pros && section.pros.length > 0 && (
|
||||
<div className="pt-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-1">
|
||||
Pros
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{section.pros.map((p, i) => (
|
||||
<li key={i}>{renderInlineMarkup(p)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.cons && section.cons.length > 0 && (
|
||||
<div className="pt-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 mb-1">
|
||||
Cons
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{section.cons.map((c, i) => (
|
||||
<li key={i}>{renderInlineMarkup(c)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useHdWalletAccess } from '@/hooks/useHdWalletAccess';
|
||||
import { useHdWallet } from '@/hooks/useHdWallet';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
import {
|
||||
isLargeAmount,
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
satsToUSD,
|
||||
} from '@/lib/bitcoin';
|
||||
import {
|
||||
broadcastBlockbookTx,
|
||||
type BlockbookFeeRates,
|
||||
fetchFeeRates,
|
||||
} from '@/lib/hdwallet/blockbook';
|
||||
import {
|
||||
buildHdSpendPsbt,
|
||||
finalizeHdPsbt,
|
||||
type HdInput,
|
||||
type HdSpendableSpUtxo,
|
||||
type HdSpendableUtxo,
|
||||
parseHdRecipient,
|
||||
previewHdFee,
|
||||
signHdPsbt,
|
||||
} from '@/lib/hdwallet/transaction';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USD_PRESETS = [1, 5, 10, 25, 100];
|
||||
|
||||
type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
|
||||
|
||||
const FEE_SPEED_LABELS: Record<FeeSpeed, string> = {
|
||||
fastest: '~10 min',
|
||||
halfHour: '~30 min',
|
||||
hour: '~1 hour',
|
||||
economy: '~1 day',
|
||||
};
|
||||
|
||||
const FEE_SPEED_ORDER: FeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
|
||||
|
||||
function getRateForSpeed(rates: BlockbookFeeRates, speed: FeeSpeed): 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: BlockbookFeeRates | undefined): FeeSpeed[] {
|
||||
if (!rates) return FEE_SPEED_ORDER;
|
||||
const seen = new Set<number>();
|
||||
const result: FeeSpeed[] = [];
|
||||
for (const speed of FEE_SPEED_ORDER) {
|
||||
const rate = getRateForSpeed(rates, speed);
|
||||
if (!seen.has(rate)) { seen.add(rate); result.push(speed); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recipient resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ResolvedRecipient {
|
||||
/**
|
||||
* Final P2TR/P2WPKH/etc. address used as the PSBT output.
|
||||
*
|
||||
* For silent-payment (`sp1…`) recipients this is the original `sp1…`
|
||||
* string — the real on-chain `P_k` is derived at build time, after coin
|
||||
* selection. The dialog never displays this value directly when
|
||||
* `kind === 'sp'`; it's kept here so {@link buildHdSpendPsbt} can route
|
||||
* by recipient kind.
|
||||
*/
|
||||
address: string;
|
||||
/** Optional Nostr pubkey when the recipient was an npub/nprofile. */
|
||||
pubkey?: string;
|
||||
/** Raw text the user typed (for re-display). */
|
||||
raw: string;
|
||||
/**
|
||||
* Recipient kind. `'address'` for bare Bitcoin addresses (including
|
||||
* Nostr-derived ones); `'sp'` for BIP-352 silent-payment addresses.
|
||||
*/
|
||||
kind: 'address' | 'sp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the recipient input as one of:
|
||||
* - bare Bitcoin address (mainnet, any standard type)
|
||||
* - silent-payment address (`sp1…`, mainnet, v0)
|
||||
* - npub1… → P2TR derived from the Nostr pubkey
|
||||
* - nprofile1… → P2TR derived from the encoded pubkey
|
||||
*
|
||||
* Returns `null` for unparseable input. The caller should treat `null` as
|
||||
* "input still in progress" rather than "error" until the user submits.
|
||||
*/
|
||||
function resolveRecipient(input: string): ResolvedRecipient | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Try bare Bitcoin / silent-payment via the unified parser.
|
||||
const parsed = parseHdRecipient(trimmed);
|
||||
if (parsed) {
|
||||
if (parsed.kind === 'address') {
|
||||
return { address: parsed.address, raw: trimmed, kind: 'address' };
|
||||
}
|
||||
return { address: parsed.spAddress, raw: trimmed, kind: 'sp' };
|
||||
}
|
||||
|
||||
// Try NIP-19 npub / nprofile.
|
||||
if (trimmed.startsWith('npub1') || trimmed.startsWith('nprofile1')) {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type === 'npub') {
|
||||
const address = nostrPubkeyToBitcoinAddress(decoded.data);
|
||||
if (address) return { address, pubkey: decoded.data, raw: trimmed, kind: 'address' };
|
||||
} else if (decoded.type === 'nprofile') {
|
||||
const address = nostrPubkeyToBitcoinAddress(decoded.data.pubkey);
|
||||
if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed, kind: 'address' };
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HDSendBitcoinDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** BTC/USD price — passed in to avoid duplicate fetches. */
|
||||
btcPrice?: number;
|
||||
}
|
||||
|
||||
interface SendResult {
|
||||
txid: string;
|
||||
amountSats: number;
|
||||
fee: number;
|
||||
/**
|
||||
* Silent-payment UTXOs (`(txid, vout)`) consumed by the broadcast tx.
|
||||
* Pruned from local SP storage in `onSuccess` — otherwise the wallet
|
||||
* would keep treating them as spendable and the displayed balance would
|
||||
* jump *up* after the spend (because the BIP-86 change credits to
|
||||
* Blockbook's xpub balance while the SP entries remain locally).
|
||||
*/
|
||||
consumedSpUtxos: Array<{ txid: string; vout: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Send Bitcoin" dialog for the HD wallet at `/wallet`.
|
||||
*
|
||||
* Provides a large editable USD amount, preset chips, fee speed picker, two-tap
|
||||
* arming for large amounts, and a privacy disclaimer for raw addresses. Uses
|
||||
* the HD wallet's UTXO set across many addresses, signs with per-input HD-derived
|
||||
* keys, and emits change to a fresh internal address.
|
||||
*/
|
||||
export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoinDialogProps) {
|
||||
const availability = useHdWalletAccess();
|
||||
const {
|
||||
scan,
|
||||
silentPaymentBalance,
|
||||
silentPaymentStorage,
|
||||
refetch: refetchWallet,
|
||||
pruneSpentSilentPaymentUtxos,
|
||||
} = useHdWallet();
|
||||
const { config } = useAppContext();
|
||||
const { blockbookBaseUrl } = config;
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isReady = availability.status === 'available';
|
||||
|
||||
// ── Form state ───────────────────────────────────────────────
|
||||
const [recipientInput, setRecipientInput] = useState('');
|
||||
const [usdAmount, setUsdAmount] = useState<number | string>(5);
|
||||
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
|
||||
const [error, setError] = useState('');
|
||||
const [editingAmount, setEditingAmount] = useState(false);
|
||||
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
|
||||
const [success, setSuccess] = useState<SendResult | null>(null);
|
||||
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
const feeSpeedUserChanged = useRef(false);
|
||||
|
||||
const recipient = useMemo(() => resolveRecipient(recipientInput), [recipientInput]);
|
||||
|
||||
// ── Fee rates ────────────────────────────────────────────────
|
||||
const { data: feeRates } = useQuery({
|
||||
queryKey: ['blockbook-fee-rates', blockbookBaseUrl],
|
||||
queryFn: ({ signal }) => fetchFeeRates(blockbookBaseUrl, signal),
|
||||
enabled: isOpen && isReady,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const currentFeeRate = useMemo(() => {
|
||||
if (!feeRates) return undefined;
|
||||
return getRateForSpeed(feeRates, feeSpeed);
|
||||
}, [feeRates, feeSpeed]);
|
||||
|
||||
// ── Owned UTXO set ───────────────────────────────────────────
|
||||
//
|
||||
// Combines BIP-86 UTXOs scanned from Blockbook with silent-payment UTXOs
|
||||
// discovered by the BIP-352 scanner and persisted via NIP-78. Both can
|
||||
// fund a send; the PSBT builder dispatches per-input.
|
||||
const bip86Utxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]);
|
||||
const spUtxos: HdSpendableSpUtxo[] = useMemo(
|
||||
() =>
|
||||
(silentPaymentStorage?.utxos ?? []).map((u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
value: u.value,
|
||||
tweakHex: u.tweak,
|
||||
k: u.k,
|
||||
height: u.height,
|
||||
})),
|
||||
[silentPaymentStorage],
|
||||
);
|
||||
const ownedInputs: HdInput[] = useMemo(
|
||||
() => [
|
||||
...bip86Utxos.map<HdInput>((utxo) => ({ kind: 'bip86', utxo })),
|
||||
...spUtxos.map<HdInput>((utxo) => ({ kind: 'sp', utxo })),
|
||||
],
|
||||
[bip86Utxos, spUtxos],
|
||||
);
|
||||
const totalBalance = useMemo(
|
||||
() => bip86Utxos.reduce((s, u) => s + u.value, 0) + silentPaymentBalance,
|
||||
[bip86Utxos, silentPaymentBalance],
|
||||
);
|
||||
|
||||
// ── USD → sats ───────────────────────────────────────────────
|
||||
const amountSats = useMemo(() => {
|
||||
if (!btcPrice) return 0;
|
||||
const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
if (!Number.isFinite(usd) || usd <= 0) return 0;
|
||||
return Math.round((usd / btcPrice) * 100_000_000);
|
||||
}, [usdAmount, btcPrice]);
|
||||
|
||||
// ── Fee estimate (matches the actual coin selection) ────────
|
||||
//
|
||||
// Crucially we do NOT use `ownedInputs.length` as the input count: an HD
|
||||
// wallet typically has many UTXOs across many addresses, but a real send
|
||||
// only consumes the minimal set the coin selector picks. Using the full
|
||||
// count would over-estimate fees by 10x or more on an active wallet, and
|
||||
// would also make the UI think we're insufficient when we're not.
|
||||
const estimatedFeeSats = useMemo(() => {
|
||||
if (!ownedInputs.length || !currentFeeRate || !amountSats) return 0;
|
||||
return previewHdFee(ownedInputs, amountSats, currentFeeRate);
|
||||
}, [ownedInputs, currentFeeRate, amountSats]);
|
||||
|
||||
const totalSats = amountSats + estimatedFeeSats;
|
||||
// `previewHdFee` returns 0 when the coin selector can't cover `amount + fee`.
|
||||
// Treat that as insufficient so the UI doesn't claim a 0-sat fee is fine.
|
||||
const selectionFailed =
|
||||
amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
|
||||
const insufficient = selectionFailed || (totalBalance > 0 && totalSats > totalBalance);
|
||||
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
|
||||
|
||||
// Auto-tune fee speed to keep fees < 40% of the send amount, unless the
|
||||
// user has manually overridden.
|
||||
useEffect(() => {
|
||||
if (feeSpeedUserChanged.current) return;
|
||||
if (!ownedInputs.length || !feeRates || amountSats <= 0) return;
|
||||
|
||||
const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
|
||||
const threshold = amountSats * 0.4;
|
||||
|
||||
let target: FeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
|
||||
for (const speed of uniqueSpeeds) {
|
||||
const rate = getRateForSpeed(feeRates, speed);
|
||||
const fee = previewHdFee(ownedInputs, amountSats, rate);
|
||||
if (fee > 0 && fee <= threshold) { target = speed; break; }
|
||||
}
|
||||
setFeeSpeed((prev) => (prev === target ? prev : target));
|
||||
}, [amountSats, feeRates, ownedInputs, totalBalance]);
|
||||
|
||||
const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
|
||||
feeSpeedUserChanged.current = true;
|
||||
setFeeSpeed(speed);
|
||||
setFeePopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
// ── Two-tap arm + raw-address disclaimer ─────────────────────
|
||||
const isLarge = isLargeAmount(totalSats, btcPrice);
|
||||
// SP recipients (`sp1…`) produce a fresh, unlinkable Taproot output per
|
||||
// payment — they do NOT have the privacy concern of a reused on-chain
|
||||
// address. The public disclaimer is only needed for bare BTC addresses
|
||||
// typed in directly (no Nostr identity attached, no SP).
|
||||
const isRawAddress =
|
||||
!!recipient && recipient.kind === 'address' && !recipient.pubkey;
|
||||
const [confirmArmed, setConfirmArmed] = useState(false);
|
||||
const [acknowledgedPublic, setAcknowledgedPublic] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmArmed(false);
|
||||
}, [amountSats, currentFeeRate, btcPrice, recipient?.address]);
|
||||
|
||||
// Reset the privacy acknowledgement only when the recipient changes —
|
||||
// not when the user adjusts the amount or fee tier. Toggling between
|
||||
// fee speeds should not silently uncheck the warning.
|
||||
useEffect(() => {
|
||||
setAcknowledgedPublic(false);
|
||||
}, [recipient?.address]);
|
||||
|
||||
const requiresArm = isLarge || isRawAddress;
|
||||
|
||||
// ── Amount focus management ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (editingAmount) {
|
||||
amountInputRef.current?.focus();
|
||||
amountInputRef.current?.select();
|
||||
}
|
||||
}, [editingAmount]);
|
||||
|
||||
const commitAmountEdit = useCallback(() => {
|
||||
setEditingAmount(false);
|
||||
if (typeof usdAmount === 'string' && usdAmount.trim() === '') setUsdAmount(0);
|
||||
}, [usdAmount]);
|
||||
|
||||
// ── Send mutation ────────────────────────────────────────────
|
||||
const [progress, setProgress] = useState<'idle' | 'building' | 'signing' | 'broadcasting'>('idle');
|
||||
|
||||
const sendMutation = useMutation<SendResult, Error, void>({
|
||||
mutationFn: async () => {
|
||||
if (availability.status !== 'available') {
|
||||
throw new Error('HD wallet is not available for this login type.');
|
||||
}
|
||||
if (!recipient) throw new Error('Enter a Bitcoin address, sp1… address, or npub.');
|
||||
if (!ownedInputs.length) throw new Error('No spendable Bitcoin in this wallet.');
|
||||
if (!feeRates) throw new Error('Fee rates not loaded.');
|
||||
if (recipient.pubkey === availability.pubkey) throw new Error("You can't send to yourself.");
|
||||
if (amountSats <= 0) throw new Error('Enter an amount.');
|
||||
if (insufficient) throw new Error('Not enough Bitcoin for this amount + network fee.');
|
||||
|
||||
const rate = getRateForSpeed(feeRates, feeSpeed);
|
||||
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
|
||||
|
||||
setProgress('building');
|
||||
const built = buildHdSpendPsbt({
|
||||
account: availability.account,
|
||||
inputs: ownedInputs,
|
||||
recipient:
|
||||
recipient.kind === 'sp'
|
||||
? { kind: 'sp', spAddress: recipient.address }
|
||||
: { kind: 'address', address: recipient.address },
|
||||
amountSats,
|
||||
feeRate: rate,
|
||||
nextChangeIndex,
|
||||
nsecBytes: availability.nsecBytes,
|
||||
});
|
||||
|
||||
setProgress('signing');
|
||||
const signedHex = signHdPsbt(
|
||||
built.psbtHex,
|
||||
built.inputDescriptors,
|
||||
availability.account,
|
||||
availability.nsecBytes,
|
||||
);
|
||||
const txHex = finalizeHdPsbt(signedHex);
|
||||
|
||||
setProgress('broadcasting');
|
||||
const txid = await broadcastBlockbookTx(blockbookBaseUrl, txHex);
|
||||
|
||||
return { txid, amountSats, fee: built.fee, consumedSpUtxos: built.consumedSpUtxos };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
notificationSuccess();
|
||||
setSuccess(result);
|
||||
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
|
||||
// Remove the SP UTXOs we just spent from local storage and
|
||||
// republish the NIP-78 doc. Blockbook's xpub scan can't see SP
|
||||
// outputs, so without this the spent UTXOs would linger forever:
|
||||
// the balance would still count them, the coin selector would try
|
||||
// to spend them again (resulting in "missing/spent input" broadcast
|
||||
// errors), and the wallet would appear to *gain* money on each SP
|
||||
// spend (BIP-86 change is observed by Blockbook, but the consumed
|
||||
// SP value is not subtracted locally).
|
||||
if (result.consumedSpUtxos.length > 0) {
|
||||
pruneSpentSilentPaymentUtxos(result.consumedSpUtxos);
|
||||
}
|
||||
void refetchWallet();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' });
|
||||
},
|
||||
onSettled: () => setProgress('idle'),
|
||||
});
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
setError('');
|
||||
if (availability.status !== 'available') {
|
||||
setError('HD wallet is not available for this login type.'); return;
|
||||
}
|
||||
if (!recipient) { setError('Enter a Bitcoin address, sp1… address, or npub.'); return; }
|
||||
if (recipient.pubkey === availability.pubkey) { setError("You can't send to yourself."); return; }
|
||||
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
|
||||
if (amountSats <= 0) { setError('Enter an amount.'); return; }
|
||||
if (!ownedInputs.length) { setError("You don't have any Bitcoin yet."); return; }
|
||||
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
|
||||
if (isRawAddress && !acknowledgedPublic) {
|
||||
setError('Acknowledge the privacy warning before sending.'); return;
|
||||
}
|
||||
if (requiresArm && !confirmArmed) { setConfirmArmed(true); return; }
|
||||
sendMutation.mutate();
|
||||
}, [
|
||||
availability,
|
||||
recipient,
|
||||
btcPrice,
|
||||
amountSats,
|
||||
ownedInputs.length,
|
||||
insufficient,
|
||||
isRawAddress,
|
||||
acknowledgedPublic,
|
||||
requiresArm,
|
||||
confirmArmed,
|
||||
sendMutation,
|
||||
]);
|
||||
|
||||
// ── Reset on close ───────────────────────────────────────────
|
||||
const handleClose = useCallback(() => {
|
||||
if (sendMutation.isPending) return;
|
||||
onClose();
|
||||
// defer to allow exit animation
|
||||
setTimeout(() => {
|
||||
setRecipientInput('');
|
||||
setUsdAmount(5);
|
||||
setError('');
|
||||
setConfirmArmed(false);
|
||||
setAcknowledgedPublic(false);
|
||||
setSuccess(null);
|
||||
feeSpeedUserChanged.current = false;
|
||||
}, 200);
|
||||
}, [onClose, sendMutation.isPending]);
|
||||
|
||||
// ── Render helpers ───────────────────────────────────────────
|
||||
const sendButtonLabel = (() => {
|
||||
if (sendMutation.isPending) {
|
||||
switch (progress) {
|
||||
case 'building': return 'Building transaction…';
|
||||
case 'signing': return 'Signing…';
|
||||
case 'broadcasting': return 'Broadcasting…';
|
||||
default: return 'Sending…';
|
||||
}
|
||||
}
|
||||
if (confirmArmed) return 'Tap again to confirm';
|
||||
return 'Send Bitcoin';
|
||||
})();
|
||||
|
||||
const sendDisabled =
|
||||
sendMutation.isPending ||
|
||||
!recipient ||
|
||||
!btcPrice ||
|
||||
amountSats <= 0 ||
|
||||
insufficient ||
|
||||
!ownedInputs.length ||
|
||||
(isRawAddress && !acknowledgedPublic);
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
||||
<DialogContent className="sm:max-w-md p-0 gap-0 overflow-hidden [&>button]:hidden">
|
||||
<DialogTitle className="sr-only">Send Bitcoin</DialogTitle>
|
||||
|
||||
{success ? (
|
||||
<SuccessScreen
|
||||
txid={success.txid}
|
||||
amountSats={success.amountSats}
|
||||
btcPrice={btcPrice}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-5 px-6 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">Send Bitcoin</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="flex flex-col items-center py-2">
|
||||
{editingAmount ? (
|
||||
<div className="flex items-center text-4xl font-bold tracking-tight">
|
||||
<span className="text-muted-foreground">$</span>
|
||||
<Input
|
||||
ref={amountInputRef}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={usdAmount}
|
||||
onChange={(e) => setUsdAmount(e.target.value)}
|
||||
onBlur={commitAmountEdit}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitAmountEdit(); }}
|
||||
className="bg-transparent border-none focus-visible:ring-0 text-4xl font-bold tracking-tight w-32 text-center px-0 h-auto"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingAmount(true)}
|
||||
className="text-4xl font-bold tracking-tight hover:text-primary transition-colors cursor-text"
|
||||
>
|
||||
${typeof usdAmount === 'number' ? usdAmount : (parseFloat(usdAmount) || 0)}
|
||||
</button>
|
||||
)}
|
||||
{amountSats > 0 && btcPrice && (
|
||||
<span className="text-xs text-muted-foreground mt-1 tabular-nums">
|
||||
≈ {amountSats.toLocaleString()} sats
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* USD presets */}
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{USD_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => { setUsdAmount(preset); setEditingAmount(false); }}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-xs border transition-colors',
|
||||
Number(usdAmount) === preset
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
${preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recipient */}
|
||||
<div className="grid gap-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor="hd-recipient-input">
|
||||
Recipient
|
||||
</label>
|
||||
<Input
|
||||
id="hd-recipient-input"
|
||||
value={recipientInput}
|
||||
onChange={(e) => setRecipientInput(e.target.value)}
|
||||
placeholder="bc1…, sp1…, or npub…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{recipient && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{recipient.kind === 'sp' ? (
|
||||
<>Sending via a silent payment — the recipient gets a fresh, unlinkable on-chain address.</>
|
||||
) : recipient.pubkey ? (
|
||||
<>Sending to a Nostr user's on-chain address.</>
|
||||
) : (
|
||||
<>Sending to a raw Bitcoin address.</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Privacy disclaimer for raw addresses */}
|
||||
{isRawAddress && (
|
||||
<BitcoinPublicDisclaimer
|
||||
acknowledged={acknowledgedPublic}
|
||||
onAcknowledgedChange={setAcknowledgedPublic}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fee speed */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Network fee</span>
|
||||
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-muted-foreground tabular-nums"
|
||||
>
|
||||
{estimatedFeeSats > 0 && btcPrice ? (
|
||||
<>≈ {satsToUSD(estimatedFeeSats, btcPrice)}</>
|
||||
) : currentFeeRate ? (
|
||||
<>{currentFeeRate} sat/vB</>
|
||||
) : (
|
||||
<>—</>
|
||||
)}
|
||||
<span className="opacity-60">·</span>
|
||||
{FEE_SPEED_LABELS[feeSpeed]}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="end">
|
||||
<div className="grid gap-0.5">
|
||||
{getUniqueFeeSpeeds(feeRates).map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
onClick={() => handleFeeSpeedChange(speed)}
|
||||
className={cn(
|
||||
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
|
||||
feeSpeed === speed && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<span>{FEE_SPEED_LABELS[speed]}</span>
|
||||
{feeRates && (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{getRateForSpeed(feeRates, speed)} sat/vB
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{showBalance && totalBalance > 0 && btcPrice && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Available: {satsToUSD(totalBalance, btcPrice)} ({totalBalance.toLocaleString()} sats)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertTriangle className="size-3.5" />
|
||||
<AlertDescription className="text-xs">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={sendDisabled}
|
||||
className={cn(
|
||||
'w-full',
|
||||
confirmArmed && !sendMutation.isPending && 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
)}
|
||||
>
|
||||
{sendMutation.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
|
||||
{sendButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Success screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SuccessScreenProps {
|
||||
txid: string;
|
||||
amountSats: number;
|
||||
btcPrice: number | undefined;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SuccessScreen({ txid, amountSats, btcPrice, onClose }: SuccessScreenProps) {
|
||||
const usdDisplay = btcPrice ? satsToUSD(amountSats, btcPrice) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="relative grid gap-5 px-6 py-8 w-full overflow-hidden text-center motion-safe:animate-success-fade-up"
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_35%,hsl(var(--primary)/0.18),transparent_65%)]"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto flex size-28 items-center justify-center">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400/40 to-orange-500/30 motion-safe:animate-success-halo"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 shadow-lg shadow-orange-500/30 motion-safe:animate-success-pop"
|
||||
/>
|
||||
<Check className="relative size-14 text-white drop-shadow-sm motion-safe:animate-success-pop" strokeWidth={3} aria-hidden />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Bitcoin sent</h2>
|
||||
<div className="text-4xl font-bold tabular-nums bg-gradient-to-br from-amber-500 to-orange-600 bg-clip-text text-transparent">
|
||||
{usdDisplay || `${amountSats.toLocaleString()} sats`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button type="button" variant="outline" asChild className="w-full">
|
||||
<Link to={`/i/bitcoin:tx:${txid}`} onClick={onClose}>
|
||||
<ExternalLink className="size-4 mr-2" />
|
||||
View transaction
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="button" onClick={onClose} className="w-full">Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useHdWalletSp } from '@/hooks/useHdWalletSp';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HD wallet — silent-payment "Scan history" dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Walks the user through running a BIP-352 chain scan over a configurable
|
||||
// block range. Defaults to "from last scanned height → tip", which is the
|
||||
// common forward-catch-up case; advanced users can edit the bounds for a
|
||||
// targeted backfill.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HDSilentPaymentScanDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymentScanDialogProps) {
|
||||
const sp = useHdWalletSp();
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [includeSpent, setIncludeSpent] = useState(false);
|
||||
|
||||
// Seed defaults whenever the dialog opens or upstream data changes.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTouched(false);
|
||||
setIncludeSpent(false);
|
||||
return;
|
||||
}
|
||||
if (touched) return;
|
||||
const tip = sp.tipHeight;
|
||||
const lastScanned = sp.storage?.scanHeight ?? 0;
|
||||
const defaultFrom = lastScanned > 0 ? lastScanned + 1 : tip ? Math.max(0, tip - 144) : 0;
|
||||
setFrom(String(defaultFrom));
|
||||
setTo(tip ? String(tip) : '');
|
||||
}, [open, sp.tipHeight, sp.storage?.scanHeight, touched]);
|
||||
|
||||
const fromNum = Number(from);
|
||||
const toNum = Number(to);
|
||||
const fromValid = Number.isInteger(fromNum) && fromNum >= 0;
|
||||
const toValid = to === '' || (Number.isInteger(toNum) && toNum >= fromNum);
|
||||
const inputsValid = fromValid && toValid;
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!inputsValid) return;
|
||||
await sp.scanRange({
|
||||
fromHeight: fromNum,
|
||||
toHeight: to === '' ? undefined : toNum,
|
||||
includeSpent,
|
||||
});
|
||||
};
|
||||
|
||||
const progressPercent = sp.scanProgress
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
((sp.scanProgress.currentHeight - sp.scanProgress.fromHeight + 1) /
|
||||
Math.max(1, sp.scanProgress.toHeight - sp.scanProgress.fromHeight + 1)) *
|
||||
100,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Scan for silent payments</DialogTitle>
|
||||
<DialogDescription>
|
||||
Walks the configured BIP-352 indexer block-by-block to detect incoming silent payments.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-scan-from" className="text-xs">
|
||||
From block
|
||||
</Label>
|
||||
<Input
|
||||
id="sp-scan-from"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={from}
|
||||
onChange={(e) => {
|
||||
setTouched(true);
|
||||
setFrom(e.target.value);
|
||||
}}
|
||||
disabled={sp.isScanning}
|
||||
aria-invalid={!fromValid}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-scan-to" className="text-xs">
|
||||
To block
|
||||
</Label>
|
||||
<Input
|
||||
id="sp-scan-to"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
placeholder="tip"
|
||||
value={to}
|
||||
onChange={(e) => {
|
||||
setTouched(true);
|
||||
setTo(e.target.value);
|
||||
}}
|
||||
disabled={sp.isScanning}
|
||||
aria-invalid={!toValid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sp.tipHeight !== undefined && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Indexer tip: <span className="font-mono">{sp.tipHeight.toLocaleString()}</span>
|
||||
{sp.storage && (
|
||||
<>
|
||||
{' · '}
|
||||
Last fully scanned:{' '}
|
||||
<span className="font-mono">
|
||||
{sp.storage.scanHeight > 0 ? sp.storage.scanHeight.toLocaleString() : 'never'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* "Include already-spent" deep-rescan toggle. Off by default
|
||||
* because the normal scan path doesn't want already-spent
|
||||
* outputs cluttering the active UTXO set. Turn on to recover
|
||||
* historical receive rows whose UTXOs were later spent and
|
||||
* subsequently pruned from local storage — matches against
|
||||
* spent outputs are routed straight into the `spent` archive,
|
||||
* which powers both the receive-history rows and the
|
||||
* send-vs-receive classifier in the tx list.
|
||||
*/}
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="sp-include-spent"
|
||||
checked={includeSpent}
|
||||
onCheckedChange={(v) => setIncludeSpent(v === true)}
|
||||
disabled={sp.isScanning}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sp-include-spent" className="text-xs cursor-pointer">
|
||||
Include already-spent
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Also detect silent payments that have since been spent. Use when
|
||||
rebuilding receive history after a missed scan or a reset.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sp.isScanning && sp.scanProgress && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progressPercent} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Block {sp.scanProgress.currentHeight.toLocaleString()} /{' '}
|
||||
{sp.scanProgress.toHeight.toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{sp.scanProgress.matchesFound} match
|
||||
{sp.scanProgress.matchesFound === 1 ? '' : 'es'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sp.isScanning && sp.scanError && (
|
||||
<div className="flex items-start gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-4 shrink-0 mt-0.5" />
|
||||
<p>{sp.scanError.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sp.isScanning && !sp.scanError && sp.scanProgress && (
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<CheckCircle2 className="size-4 shrink-0 mt-0.5 text-green-500" />
|
||||
<p>
|
||||
Scanned blocks {sp.scanProgress.fromHeight.toLocaleString()} →{' '}
|
||||
{sp.scanProgress.currentHeight.toLocaleString()}.{' '}
|
||||
{sp.scanProgress.matchesFound > 0
|
||||
? `Found ${sp.scanProgress.matchesFound} new ${
|
||||
sp.scanProgress.matchesFound === 1 ? 'output' : 'outputs'
|
||||
}.`
|
||||
: 'No new payments.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Reconcile spent UTXOs ──────────────────────────── */}
|
||||
{/*
|
||||
* Manual fix-up path for SP UTXOs that were spent outside the
|
||||
* local send flow — different device, or a build that predates
|
||||
* the send-time prune logic. Walks the stored set, asks
|
||||
* Blockbook for each output's spent status, and drops the spent
|
||||
* ones. Capped at 50 UTXOs per click; subsequent clicks pick up
|
||||
* any remainder.
|
||||
*/}
|
||||
{sp.storage && sp.storage.utxos.length > 0 && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Reconcile spent UTXOs</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Checks each stored silent-payment UTXO against Blockbook and removes any
|
||||
that have been spent. Use this if the balance is higher than it should
|
||||
be after a send.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sp.reconcileProgress && !sp.reconcileError && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sp.isReconciling
|
||||
? `Checking ${sp.reconcileProgress.checked} / ${sp.reconcileProgress.total}…`
|
||||
: `Checked ${sp.reconcileProgress.checked} UTXO${
|
||||
sp.reconcileProgress.checked === 1 ? '' : 's'
|
||||
} · pruned ${sp.reconcileProgress.prunedSoFar}.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sp.reconcileError && (
|
||||
<div className="flex items-start gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-4 shrink-0 mt-0.5" />
|
||||
<p>{sp.reconcileError.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void sp.reconcileSpentUtxos();
|
||||
}}
|
||||
disabled={sp.isReconciling || sp.isScanning}
|
||||
>
|
||||
{sp.isReconciling ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin mr-2" />
|
||||
Reconciling…
|
||||
</>
|
||||
) : (
|
||||
'Reconcile now'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{sp.isScanning ? (
|
||||
<Button variant="outline" onClick={() => sp.cancelScan()}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleScan} disabled={!inputsValid}>
|
||||
Start scan
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||