Compare commits
324 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bae956ffa | |||
| a2f59a5b1b | |||
| df56af9b0e | |||
| 83f7f9584f | |||
| a2d440e54f | |||
| 4132e8449b | |||
| ee444416e4 | |||
| 10c12c3c48 | |||
| db3775ae99 | |||
| 393acce884 | |||
| 68fe663730 | |||
| f65a4b0db0 | |||
| cdfb502e6e | |||
| 1a2c83e49b | |||
| e6c7a675a9 | |||
| 69c04f29f4 | |||
| 04c6f9b4fe | |||
| 86ec12a9db | |||
| 72b3111c64 | |||
| 6709c91779 | |||
| bb6e7495f5 | |||
| df17929681 | |||
| e083719ceb | |||
| bfdc69f18c | |||
| e7ae20afb7 | |||
| 229d92055f | |||
| 64c77cfd13 | |||
| 3a63894562 | |||
| 1d272f8b37 | |||
| bac433b640 | |||
| 62f573eac0 | |||
| b3ea62c53c | |||
| b0731503a8 | |||
| 2421c02c24 | |||
| 25e868118d | |||
| 2880044e0e | |||
| 5300404b46 | |||
| d949d58076 | |||
| 997b223e95 | |||
| ba52a97e26 | |||
| cc4c7b5fe9 | |||
| 8e2ebd11fc | |||
| 9cae4da9f4 | |||
| c05d7e99e2 | |||
| 2390599e8f | |||
| 1a4d45fa9c | |||
| 57447e5bf4 | |||
| 8e411daaef | |||
| 183aebf841 | |||
| e3e500ccc2 | |||
| e7a2535ece | |||
| 761e369313 | |||
| 5248275d73 | |||
| cb033279dd | |||
| 41d50d8c28 | |||
| a52c2b4c3c | |||
| b5917cb184 | |||
| 57348472f8 | |||
| 4b6223dc00 | |||
| 5525e45a15 | |||
| 80a2ae60b0 | |||
| d7e95f5d2f | |||
| ca4e5ae5ee | |||
| b673658c0c | |||
| 5c5c130700 | |||
| 2d89ca6c0e | |||
| 806a7c2609 | |||
| 501ce8067d | |||
| 6429f82829 | |||
| fe626218ea | |||
| b62b1bc063 | |||
| d980f36246 | |||
| b469addd29 | |||
| 6923c2a8b7 | |||
| 1d3f32fb99 | |||
| 42a550788a | |||
| b1c68972c9 | |||
| 3978e32d5f | |||
| ba2b5d182e | |||
| bef04fa899 | |||
| 4f8609421c | |||
| 07660c9d44 | |||
| a324dad2ba | |||
| dbaa0f5d49 | |||
| 478721d349 | |||
| a669a23dbc | |||
| cfeb6478cc | |||
| 64539c49c1 | |||
| 0399ae37ec | |||
| 173a411a36 | |||
| 62013a2ea2 | |||
| c82cf4a4c2 | |||
| df42085be6 | |||
| b09d3065ae | |||
| c050f5a9e3 | |||
| 78e6c0eca0 | |||
| da4da45348 | |||
| dc2af86db8 | |||
| 7502004aba | |||
| 2e8678e4c6 | |||
| 97569016fc | |||
| fe72798592 | |||
| 4583c4e028 | |||
| 0b98197a86 | |||
| 0e94a9c33f | |||
| 3dff1fcb4d | |||
| e163286dd4 | |||
| a99e12f12e | |||
| c3dd997e57 | |||
| a730384baf | |||
| 43cf91e877 | |||
| 75bee027e1 | |||
| 5cbf69a8bd | |||
| ecbb3086d8 | |||
| 7476767aa7 | |||
| e5b8987a9d | |||
| 6ca74c21bf | |||
| e0099141aa | |||
| d0491ed202 | |||
| cbc2137ced | |||
| f9ac13ba11 | |||
| b3533c285f | |||
| a636ae6592 | |||
| 69e3ee0aff | |||
| a39a87ba6d | |||
| 5b22d6ac01 | |||
| 7334cd26f8 | |||
| 44555215cf | |||
| 0cc25913c0 | |||
| 004b30b737 | |||
| 632f330b4c | |||
| 666433912f | |||
| db98ce8db7 | |||
| 71dcfae5ff | |||
| 04155f5b23 | |||
| b4058389ec | |||
| 483fa81b74 | |||
| a8d1c4bbbc | |||
| 0a8c2faa74 | |||
| dd3231e70f | |||
| 7ff9c00032 | |||
| 9ed483abf7 | |||
| b9aeaf29a4 | |||
| 65e3f81f36 | |||
| c6641dba31 | |||
| e48d1e0e59 | |||
| d1e5aee84e | |||
| 5cb22d0bed | |||
| d1c6f53d7c | |||
| 6e238f98c0 | |||
| 290274d6c8 | |||
| e1de0239c9 | |||
| bec77d59e8 | |||
| 84f8794d7c | |||
| 4cddf41bf3 | |||
| 125a7e238e | |||
| 468200b717 | |||
| bdfcb99781 | |||
| 38da650861 | |||
| dd006badfc | |||
| 87e4e3fe5b | |||
| af3e38254f | |||
| 70843f54d3 | |||
| bda75b29b4 | |||
| 750830d593 | |||
| 3c0f1a1d2f | |||
| 4253b0ed29 | |||
| 3c9b3f23df | |||
| e0d83608be | |||
| a0301d599b | |||
| 7dcaa0e8d7 | |||
| 129f49bcc7 | |||
| fc3b68c390 | |||
| 52c7df8a15 | |||
| ce1c4dd488 | |||
| fc6a1a3819 | |||
| 69bd6d0e70 | |||
| 6d383d54e8 | |||
| 998c48b1d3 | |||
| 7217d122b5 | |||
| 1c37c5bb3d | |||
| e8f785b558 | |||
| c94d314f6d | |||
| 2672a8f922 | |||
| 8a8d80d692 | |||
| 95698813c6 | |||
| 4001e877b4 | |||
| 99defc6d79 | |||
| a94883089e | |||
| 5ea4aeb75c | |||
| 456d111925 | |||
| 837ae4b38e | |||
| ffbcbf86c3 | |||
| bcda637192 | |||
| 72c7dd6126 | |||
| a2a4b3599f | |||
| 4955a4f16c | |||
| bb1ff4fb11 | |||
| b81f7c9ed3 | |||
| 689cfb6d45 | |||
| 9da3141650 | |||
| e4fe18df2f | |||
| ba80ebac63 | |||
| d4943daa82 | |||
| cde03ec0fe | |||
| 4f6c08f8a2 | |||
| 38e0fc53ad | |||
| 2a30ca5306 | |||
| 4a4ea13bef | |||
| 239bd3f31a | |||
| 831ec05012 | |||
| 0cc0598287 | |||
| 0a5bc618c2 | |||
| 069904f07a | |||
| 03b42c8276 | |||
| 8697cc23be | |||
| 69e1f97e72 | |||
| 3e832af3e4 | |||
| 84b8650fa4 | |||
| 83abb5aa94 | |||
| a12eddb47b | |||
| c87166247c | |||
| 037c8cb41b | |||
| 79de2e1176 | |||
| d4b026a3ad | |||
| 00f383ff2e | |||
| 6f6bb508db | |||
| e2a0672ca5 | |||
| e2a5fe7a79 | |||
| 5d02ae75dc | |||
| 2460bbbc83 | |||
| 084d8d931b | |||
| 6ee4ac1a89 | |||
| 1d07097350 | |||
| 63d6b362c7 | |||
| bfed277ea9 | |||
| 9e8aa2ef3a | |||
| 4bbc0878f7 | |||
| 16a3ba2a9b | |||
| 7c11eb8947 | |||
| 6bdc8d4d9f | |||
| b9048936ba | |||
| b9620f4443 | |||
| f2249fe592 | |||
| fd42a0e8d4 | |||
| 37d52ba35f | |||
| 3037323dc0 | |||
| 5301ef876d | |||
| aa054d8b1a | |||
| 3655790e5f | |||
| 6cca823ed4 | |||
| 18a383edab | |||
| 43da7d628e | |||
| 2fae3ca248 | |||
| d99ada44f5 | |||
| cb0119b9b8 | |||
| dac9ef8e4e | |||
| 528917b90e | |||
| a22db78967 | |||
| 5718510779 | |||
| f877dc7fbe | |||
| df03fb1116 | |||
| 7455b49f8d | |||
| ae00eb0b9c | |||
| b82e434c70 | |||
| 576c9c2c95 | |||
| cef046b3ae | |||
| 18ae6f6044 | |||
| 664f3c01e0 | |||
| 15e82c4e41 | |||
| 397ecf773e | |||
| 45397e7fb8 | |||
| 11aa841241 | |||
| cc1c18d55f | |||
| e3fbd69e6e | |||
| ac756bf266 | |||
| 8e28ff13e9 | |||
| d8b87db784 | |||
| 0b8c6c4a49 | |||
| 9f4f468bf0 | |||
| 7563dff621 | |||
| f782898b62 | |||
| d0601400cd | |||
| d262da39e5 | |||
| 7d617d8399 | |||
| d2b7db18af | |||
| 89c2690254 | |||
| 34945d1c42 | |||
| 43b207c4dc | |||
| 55efb3fdfd | |||
| c4a1ad2e33 | |||
| fd8442c632 | |||
| e0875eb9b9 | |||
| 962ac7d80c | |||
| 5338ee11bc | |||
| 6d2e9a037d | |||
| ac8530bd9a | |||
| f7d11cf124 | |||
| 72d85e5740 | |||
| e57b5721f6 | |||
| 4ba6c72459 | |||
| c33698c662 | |||
| cf4e40c4cf | |||
| 664da505cd | |||
| 573d4e3cfb | |||
| b2dc41f25b | |||
| b3bc0e4957 | |||
| 0e79e5b9cc | |||
| 34c7bfcffb | |||
| fd9fee8f50 | |||
| b14c3ab345 | |||
| 823058e335 | |||
| 60ec6924f3 | |||
| 18fc895fcb | |||
| 42295159a0 | |||
| db408ac30d | |||
| 1ced5689c3 | |||
| 263a803875 | |||
| 58afb8fa0c | |||
| 4aaa19ea1b | |||
| 2f9010cd13 | |||
| 12fcdfcd4f | |||
| 317ab57ed2 | |||
| 52ef67740a |
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
android
|
||||||
|
ios
|
||||||
|
build
|
||||||
+10
-5
@@ -1,14 +1,19 @@
|
|||||||
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
|
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
|
||||||
|
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
|
||||||
VITE_BURROW_URL=
|
VITE_BURROW_URL=
|
||||||
VITE_PLATFORM_URL=https://flotilla.social
|
VITE_PLATFORM_URL=https://app.flotilla.social
|
||||||
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
||||||
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
||||||
VITE_PLATFORM_NAME=Flotilla
|
VITE_PLATFORM_NAME=Flotilla
|
||||||
VITE_PLATFORM_LOGO=static/flotilla.png
|
VITE_PLATFORM_LOGO=static/logo.png
|
||||||
VITE_PLATFORM_RELAY=
|
VITE_PLATFORM_RELAYS=
|
||||||
VITE_PLATFORM_ACCENT="#7161FF"
|
VITE_PLATFORM_ACCENT="#7161FF"
|
||||||
|
VITE_PLATFORM_SECONDARY="#EB5E28"
|
||||||
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
|
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
|
||||||
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/
|
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/,wss://indexer.coracle.social/
|
||||||
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
|
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://offchain.pub/
|
||||||
|
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
|
||||||
|
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
|
||||||
|
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
||||||
VITE_GLITCHTIP_API_KEY=
|
VITE_GLITCHTIP_API_KEY=
|
||||||
GLITCHTIP_AUTH_TOKEN=
|
GLITCHTIP_AUTH_TOKEN=
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
package-lock.json -diff
|
pnpm-lock.yaml -diff
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['master']
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: docker-container
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
- name: Generate artifact attestation
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||||
|
subject-digest: ${{ steps.push.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
+2
-1
@@ -1,5 +1,5 @@
|
|||||||
# Env
|
# Env
|
||||||
.env.local
|
.env
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
@@ -60,6 +60,7 @@ google-services.json
|
|||||||
GoogleService-Info.plist
|
GoogleService-Info.plist
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
|
.roo
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
pnpm run lint
|
pnpm run lint
|
||||||
pnpm run check
|
pnpm run check
|
||||||
|
|
||||||
|
if [[ ! -z $(cat package.json | grep 'link:') ]]; then
|
||||||
|
echo "Some packages are linked to local files!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|||||||
+189
@@ -1,5 +1,194 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
# 1.6.2
|
||||||
|
|
||||||
|
* Fix modal scrolling and style
|
||||||
|
|
||||||
|
# 1.6.1
|
||||||
|
|
||||||
|
* Fix skinny profile images
|
||||||
|
* Custom handler for relay urls
|
||||||
|
* Improve time based chat partitioning
|
||||||
|
* Improve authenticated image access interop
|
||||||
|
* Fix image detail dialog
|
||||||
|
* Fix zapper loading
|
||||||
|
* Fix recent events missing in feeds
|
||||||
|
|
||||||
|
# 1.6.0
|
||||||
|
|
||||||
|
* Switch back to indexeddb to fix memory and performance
|
||||||
|
* Add pay invoice functionality
|
||||||
|
* Add space membership management and bans
|
||||||
|
* Add event info to profile dialog
|
||||||
|
* Add better room membership management
|
||||||
|
* Refactor stores for performance
|
||||||
|
* Hide nav when keyboard is open
|
||||||
|
* Handle flotilla links in-app
|
||||||
|
* Fix new messages indicator z-index
|
||||||
|
* Fix some display bugs
|
||||||
|
* Add date to chat items
|
||||||
|
* Refine data synchronization
|
||||||
|
* Hide nav when keyboard is open on mobile
|
||||||
|
|
||||||
|
# 1.5.3
|
||||||
|
|
||||||
|
* Add space edit form
|
||||||
|
* Improve room syncing
|
||||||
|
* Return better blossom errors
|
||||||
|
* Fix access restricted bugs
|
||||||
|
* Add room detail dialog
|
||||||
|
* Fix broken link to self hosting
|
||||||
|
* Tweak shadows
|
||||||
|
* Always join spaces when visiting them
|
||||||
|
|
||||||
|
# 1.5.2
|
||||||
|
|
||||||
|
* Fix negentropy room syncing
|
||||||
|
|
||||||
|
# 1.5.1
|
||||||
|
|
||||||
|
* Fix chat path link
|
||||||
|
|
||||||
|
# 1.5.0
|
||||||
|
|
||||||
|
* Restyle mobile dialogs
|
||||||
|
* Add room membership lists
|
||||||
|
* Add space membership lists
|
||||||
|
* Add edit room form
|
||||||
|
* Support closed/private/restricted/hidden rooms
|
||||||
|
* Add hosting services page
|
||||||
|
* Improve performance and UI
|
||||||
|
* Fix push notifications
|
||||||
|
* Improve error detection and handling
|
||||||
|
* Support invite links on discover page
|
||||||
|
* Add link to landlubber if user is admin
|
||||||
|
* Clear reply/share/edit on escape
|
||||||
|
|
||||||
|
# 1.4.1
|
||||||
|
|
||||||
|
* Improve data synchronization
|
||||||
|
* Fix app url on capacitor deployments
|
||||||
|
|
||||||
|
# 1.4.0
|
||||||
|
|
||||||
|
* Allow "editing" chat messages
|
||||||
|
* Check for room create permission
|
||||||
|
* Re-work space navigation
|
||||||
|
* Show all messages in non-nip29 chat
|
||||||
|
* Improve synchronization logic
|
||||||
|
* Add connection status to space menu
|
||||||
|
* Add icon picker to room create component
|
||||||
|
* Improve mention suggestions
|
||||||
|
* Improve storage adapter and relay list performance
|
||||||
|
* Fix modals
|
||||||
|
* Add room deletion
|
||||||
|
* Fix zapper loading
|
||||||
|
* Add support for relay/group member lists and join/leave events
|
||||||
|
|
||||||
|
# 1.3.1
|
||||||
|
|
||||||
|
* Fix memory leak in storage adapter
|
||||||
|
* Show fewer annoying toast messages
|
||||||
|
|
||||||
|
# 1.3.0
|
||||||
|
|
||||||
|
* Add optional badge and sound for notifications
|
||||||
|
* Improve link rendering
|
||||||
|
* Remove imgproxy
|
||||||
|
* Bring back blossom feature detection for spaces
|
||||||
|
* Improve light theme
|
||||||
|
* Add more info to signer status
|
||||||
|
* Simplify navigation for adding a space
|
||||||
|
* Add ability to scan QR code for invite links
|
||||||
|
* Streamline wallet setup and move receive address setting
|
||||||
|
* Remove indexeddb on mobile, use capacitor file storage API
|
||||||
|
* Fix duplicate DMs showing up
|
||||||
|
|
||||||
|
# 1.2.5
|
||||||
|
|
||||||
|
* Fix icons in build
|
||||||
|
|
||||||
|
# 1.2.4
|
||||||
|
|
||||||
|
* Add direct message alerts
|
||||||
|
* Add alert settings page
|
||||||
|
* Add instructions to key download
|
||||||
|
* Add option that allows relays to strip signatures
|
||||||
|
* Detect relays that mostly refuse to serve requests
|
||||||
|
* Compress and upload profile images
|
||||||
|
* Use system theme by default
|
||||||
|
* Switch icon set, refactor how they're included
|
||||||
|
* Use capacitor's preferences for storage instead of localStorage
|
||||||
|
|
||||||
|
# 1.2.3
|
||||||
|
|
||||||
|
* Add `created_at` to event info dialog
|
||||||
|
* Add signer status to profile page
|
||||||
|
* Re-work bunker login flow
|
||||||
|
* Add in-app onboarding flow
|
||||||
|
* Only protect events if relay authenticates
|
||||||
|
* Filter out non-global chats from global chat
|
||||||
|
* Improve publish status indicator
|
||||||
|
* Fix encrypted upload content type
|
||||||
|
* Add relays to event details dialog
|
||||||
|
* Add universal link handler for apps
|
||||||
|
|
||||||
|
# 1.2.2
|
||||||
|
|
||||||
|
* Fix phantom chat notifications
|
||||||
|
* Fix zaps on mobile
|
||||||
|
|
||||||
|
# 1.2.1
|
||||||
|
|
||||||
|
* Add zaps to chat, threads, and events
|
||||||
|
* Add funding goals
|
||||||
|
* Add NWC support
|
||||||
|
* Add wallet settings page
|
||||||
|
* Handle invalid bunker url
|
||||||
|
* Fix sidebar overflow
|
||||||
|
* Fix profile npub display
|
||||||
|
|
||||||
|
# 1.2.0
|
||||||
|
|
||||||
|
* Fix sort order of thread comments
|
||||||
|
* Fix link display when no title is available
|
||||||
|
* Fix making profiles non-protected
|
||||||
|
* Replace bunker url with relay claims for notifier auth
|
||||||
|
* Add push notifications on all platforms
|
||||||
|
* Add "mark all as read" on desktop
|
||||||
|
* Re-design space dashboard
|
||||||
|
|
||||||
|
# 1.1.1
|
||||||
|
|
||||||
|
* Add chat quick link
|
||||||
|
|
||||||
|
# 1.1.0
|
||||||
|
|
||||||
|
* Add better theming support
|
||||||
|
* Improve forms for entering invite codes
|
||||||
|
* Rely more heavily on NIP 29 for rooms
|
||||||
|
* Support multiple platform relays
|
||||||
|
* Remove default general room
|
||||||
|
* Remove room tag from threads/calendars
|
||||||
|
* Show pubkey on profile detail
|
||||||
|
* Support pasting pubkey into chat start dialog
|
||||||
|
* Add minimal style for quoted messages
|
||||||
|
|
||||||
|
# 1.0.4
|
||||||
|
|
||||||
|
* Fix thunk status click handler
|
||||||
|
* Remove duplicate dependencies
|
||||||
|
* Improve navigation on white-labeled instances
|
||||||
|
* Add setting for font size
|
||||||
|
|
||||||
|
# 1.0.3
|
||||||
|
|
||||||
|
* Add light theme
|
||||||
|
* Use correct alerts server
|
||||||
|
* Ignore relay errors for claims
|
||||||
|
* Fix inline code blocks
|
||||||
|
* Add custom emoji parsing and display
|
||||||
|
|
||||||
# 1.0.2
|
# 1.0.2
|
||||||
|
|
||||||
* Fix add relay button
|
* Fix add relay button
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Flotilla - AI Assistant Context
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Flotilla is a Discord-like Nostr client based on the concept of "relays as groups". It's built with SvelteKit, TypeScript, and Capacitor for cross-platform support (web, Android, iOS).
|
||||||
|
|
||||||
|
On boot, please run `tree -I assets src` to get an idea of the project structure.
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
`@welshman/*` libraries contain the majority of nostr-related functionality.
|
||||||
|
`@app/core/*` contains additional app-specific data stores and commands.
|
||||||
|
|
||||||
|
When creating an import statement, first identify what functionality you need. Search the codebase for components with similar functionality, and imitate their imports.
|
||||||
|
|
||||||
|
## Dependency Graph (Acyclic)
|
||||||
|
|
||||||
|
The project follows a strict dependency hierarchy:
|
||||||
|
1. **External libraries** (bottom layer)
|
||||||
|
2. **`lib/`** - Only depends on external libraries
|
||||||
|
3. **`app/core/`** and **`app/util/`** - Can depend on `lib` only
|
||||||
|
4. **`app/components/`** - Can depend on anything in `app` or `lib`
|
||||||
|
5. **`routes/`** - Can depend on anything (top layer)
|
||||||
|
|
||||||
|
**Import Ordering Convention:** Always sort imports by dependency level:
|
||||||
|
1. Third-party libraries first
|
||||||
|
2. Then `lib` imports
|
||||||
|
3. Then `app` imports
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
When creating components related to a given space or room, parameterize them only with the entity's identifier (i.e., `url` and `h`). Only pass additional props if they can't be derived from the identifiers. For example, a room's `members` should be derived inside the child component, not passed in by the parent.
|
||||||
|
|
||||||
|
Do not use null, only undefined.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Contributing guidelines
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Flotilla is a svelte/typescript/capacitor project. It's intended to be an alternative to Discord for Nostr users. A high-quality UX is a priority, with an emphasis on well-tested, intuitive designs, and robust implementations.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, a pre-commit hook will run to lint and typecheck your work. To run the project on Android or iOS, use Android Studio or Xcode.
|
||||||
|
|
||||||
|
The `master` branch is automatically deployed to production, so always work on feature branches based on the `dev` branch. This project frequently uses unreleased versions of [welshman](https://welshman.coracle.social), using `pnpm` link to hotlink a local copy of the code. To set that up, clone welshman to the parent directory of your `coracle` client, then add `link:../welshman/packages/packagename` to the `pnpm.overrides` section of your `package.json`. Below is a nodejs script that will do that automatically for you:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
|
||||||
|
|
||||||
|
packageJson.pnpm.overrides = Object.keys(packageJson.dependencies)
|
||||||
|
.filter(pkg => pkg.startsWith('@welshman/'))
|
||||||
|
.reduce((acc, pkg) => {
|
||||||
|
const packageName = pkg.split('/')[1]
|
||||||
|
acc[pkg] = `link:../welshman/packages/${packageName}`
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
fs.writeFileSync('./package.json', JSON.stringify(packageJson, null, 2) + '\n')
|
||||||
|
|
||||||
|
console.log('Added welshman package overrides.')
|
||||||
|
```
|
||||||
|
|
||||||
|
Be sure to avoid committing overrides to either `package.json` or `pnpm-lock.yaml`. These overrides can generally be added, installed, and removed, and will persist until another `pnpm install` command gets run.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
The main parts of the application are as follows:
|
||||||
|
|
||||||
|
- `static` - static assets like fonts, images, etc.
|
||||||
|
- `src/assets` - svgs for use as icons.
|
||||||
|
- `src/lib` - general purpose components and utilities.
|
||||||
|
- `src/app/core/state` - environment variables, constants, custom stores, and some utilities derived from them.
|
||||||
|
- `src/app/core/requests` - utilities related to loading data from the nostr network.
|
||||||
|
- `src/app/core/commands` - utilities related to publishing nostr events and uploading media to blossom servers.
|
||||||
|
- `src/app/utils` - other application logic, including stuff related to modals, routing, etc.
|
||||||
|
- `src/app/editor` - configuration for `@welshman/editor` for use in various app views.
|
||||||
|
- `src/app/components` - reusable components that depend on other `app` stuff.
|
||||||
|
- `src/routes` - file-based routing interpreted by sveltekit.
|
||||||
|
|
||||||
|
Application organization is based on an acyclic dependency graph:
|
||||||
|
|
||||||
|
- `routes` can depend on anything
|
||||||
|
- `app/components` can depend on anything in `app` or `lib`
|
||||||
|
- `app/utils` and `app/core` can only depend on `lib`
|
||||||
|
- `lib` (and everything else) can depend only on external libraries
|
||||||
|
|
||||||
|
The main stylistic/organizational rule when working in this project is that imports should be sorted based on the dependency graph. Third-party libraries should come first, then `lib`, then `app`.
|
||||||
|
|
||||||
|
## System Architecture
|
||||||
|
|
||||||
|
Flotilla's architecture generally mirrors the file structure. State is stored using Svelte `store`s provided either by `@welshman/app` or by `app/core/state`, allowing for idiomatic svelte 4 usage (svelte 5 runes are [ghey](https://habla.news/u/hodlbod@coracle.social/1739830562159) and not allowed outside of UI components).
|
||||||
|
|
||||||
|
State is then synchronized to local storage or indexeddb using storage helpers provided by welshman in `routes/+layout.svelte`. Other top level synchronization logic generally belongs there.
|
||||||
|
|
||||||
|
`app/core/state` contains all environment variables, constants, custom stores, and utilities derived from them. Most stores are `derived` from our event `repository` using `deriveEventsMapped`, which efficiently queries the repository and maps events to custom data structures. Some of these data structures are provided by `welshman`, and some are defined in `app/core/state`. In either case, they can always be mapped back to an event, which is important for updating replaceables without dropping unknown data.
|
||||||
|
|
||||||
|
Here are a few important domain objects:
|
||||||
|
|
||||||
|
- Spaces are relays used as community groups. Their `url`s are core to a lot of data and components, and are frequently passed around from place to place.
|
||||||
|
- Chats are direct message conversations. There is currently some ambiguity in routing, since relays that don't support NIP 29 also have a "chat" tab, which uses vanilla NIP-C7.
|
||||||
|
- NIP 29 groups are called "rooms". Conventionally, "h" is a group id, while a "room" as an object representing the group's metadata.
|
||||||
|
- "Alerts" are records of requests the user has made to be notified, following [this NIP](https://github.com/nostr-protocol/nips/pull/1796)
|
||||||
|
|
||||||
|
`app/core/requests` contains utilities related to loading data from the nostr network. This might include feed manager utilities, loaders, or listeners.
|
||||||
|
|
||||||
|
`app/core/commands` contains utilities related to publishing nostr events and uploading media to blossom servers. This also includes utilities related to sending lighting payments, authenticating with relays, or probing relay policy. Event creation should generally be split into `make` functions which build the event, and `publish` functions which publish the event using `publishThunk`.
|
||||||
|
|
||||||
|
Any of these utilities can be included either in `app/components` or `routes`. Crucial to keep in mind is that nearly all global state runs through welshman's `repository` in a unidirectional way. To update state, run `publishThunk`, which immediately publishes the event to the local repository. State can be read from the repository using `deriveEventsMapped` or other utilities provided by welshman like `deriveProfile`.
|
||||||
|
|
||||||
|
Thunks are designed to reduce UI latency, handling signatures and delayed sending the background. In most cases, thunk status should be displayed to the user so that they can cancel sending or address errors.
|
||||||
|
|
||||||
|
Toast, modals, and sidebar dialogs are controlled in `app/util/modal` and `app/util/toast`. In both cases, component objects can be passed along with parameters, but care has to be taken that the calling component either doesn't unmount before the modal (as when one modal replaces another), or that `$state.snapshot` is appropriately called on any state runes. These components frequently run into weird svelte compiler bugs too, in which case you may have to do some silly things to cope.
|
||||||
|
|
||||||
|
## Issues and Pull Requests
|
||||||
|
|
||||||
|
All work by contributors should be done against an issue. If there is no issue for the work you're doing, please open one or ask the project owner to open one. All PRs should be opened against the `dev` branch (unless for hotfixes).
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
Discussion about development is done [on Flotilla](https://app.flotilla.social/spaces/internal.coracle.social). The group is currently closed, so please let me know if you'd like access.
|
||||||
|
|
||||||
|
## Project License
|
||||||
|
|
||||||
|
This project is licensed under the MIT license. By contributing, you agree to waive all intellectual property rights to your contributions to this project.
|
||||||
|
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@latest
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm i
|
||||||
|
|
||||||
|
# Copy the rest of the application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# Default to serving the build directory
|
||||||
|
CMD ["npx", "serve", "build"]
|
||||||
|
|
||||||
@@ -4,23 +4,15 @@ A discord-like nostr client based on the idea of "relays as groups".
|
|||||||
|
|
||||||
If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797
|
If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797
|
||||||
|
|
||||||
# Deploy
|
|
||||||
|
|
||||||
To run your own Flotilla, it's as simple as:
|
|
||||||
|
|
||||||
- `pnpm install`
|
|
||||||
- `pnpm run build`
|
|
||||||
- `npx serve build`
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env` for examples):
|
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
|
||||||
|
|
||||||
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust.
|
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
|
||||||
- `VITE_PLATFORM_URL` - The url where the app will be hosted. This is only used for build-time population of meta tags.
|
- `VITE_PLATFORM_URL` - The url where the app will be hosted
|
||||||
- `VITE_PLATFORM_NAME` - The name of the app
|
- `VITE_PLATFORM_NAME` - The name of the app
|
||||||
- `VITE_PLATFORM_LOGO` - A logo url for the app
|
- `VITE_PLATFORM_LOGO` - A logo url for the app
|
||||||
- `VITE_PLATFORM_RELAY` - A relay url that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the platform relay the home page.
|
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
|
||||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||||
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
||||||
- `VITE_GLITCHTIP_API_KEY` - A Sentry DSN for use with glitchtip (error reporting)
|
- `VITE_GLITCHTIP_API_KEY` - A Sentry DSN for use with glitchtip (error reporting)
|
||||||
@@ -28,84 +20,29 @@ You can also optionally create an `.env.local` file and populate it with the fol
|
|||||||
|
|
||||||
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
||||||
|
|
||||||
## Nginx/TLS (optional)
|
## Development
|
||||||
|
|
||||||
If you'd like to set up flotilla on a server you control, you'll want to set up a reverse proxy and provision a TSL certificate for the domain you'll be using. You should also make sure to add swap to your server.
|
See [./CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
There will be some parts of the following templates, for example `<SERVER NAME>`, which you'll need to fill in before running the code.
|
## Deployment
|
||||||
|
|
||||||
First, create an `A` record with your DNS provider pointing to the IP of your server. This will allow certbot to create your certificate later.
|
To run your own Flotilla, it's as simple as:
|
||||||
|
|
||||||
Next install `nginx`, `git`, and `certbot`. If you're on a debian- or ubuntu-based distro, run `sudo apt-get update && sudo apt-get install nginx git certbot python3-certbot-nginx`.
|
|
||||||
|
|
||||||
Now, create a new user where your code will be stored, clone the repository, fill in your `.env.local` file, and build the app.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Replace with your password
|
pnpm install
|
||||||
PASSWORD=<YOUR PASSWORD HERE>
|
pnpm run build
|
||||||
|
npx serve build
|
||||||
# Add the user and set a password
|
|
||||||
adduser flotilla
|
|
||||||
echo flotilla:$PASSWORD | chpasswd
|
|
||||||
|
|
||||||
# Login as flotilla
|
|
||||||
sudo su flotilla
|
|
||||||
|
|
||||||
# Go to flotilla's home directory
|
|
||||||
cd ~
|
|
||||||
|
|
||||||
# Install nvm, yarn, clone repos
|
|
||||||
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
|
||||||
|
|
||||||
# Update PATH
|
|
||||||
. ~/.bashrc
|
|
||||||
|
|
||||||
# Clone repository and install dependencies
|
|
||||||
git clone https://github.com/coracle-social/flotilla.git
|
|
||||||
cd ~/flotilla
|
|
||||||
nvm install
|
|
||||||
nvm use
|
|
||||||
pnpm i
|
|
||||||
|
|
||||||
# Optionally create and populate .env.local to suit your use case
|
|
||||||
|
|
||||||
# Build the app
|
|
||||||
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
|
|
||||||
|
|
||||||
# Exit back to root
|
|
||||||
exit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Once you've exited back to root, you can set up nginx. Place the following in a file named after your domain in the `/etc/nginx/sites-available` directory, for example, `flotilla.example.com`. This should match the `A` record you registered above.
|
Or, if you prefer to use a container:
|
||||||
|
|
||||||
```conf
|
```sh
|
||||||
server {
|
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
|
||||||
listen 80;
|
|
||||||
server_name <SERVER NAME>;
|
|
||||||
root /home/flotilla/flotilla/build;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Now you can run `certbot`, which will provision a TLS certificate for your domain and update your nginx configuration.
|
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir ./mount
|
||||||
|
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
|
||||||
```
|
```
|
||||||
certbot --nginx -d <SERVER NAME>
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, enable the site and restart nginx. If you want to be careful, run `nginx -t` before restarting nginx.
|
|
||||||
|
|
||||||
```
|
|
||||||
ln -s /etc/nginx/sites-{available,enabled}/<SERVER NAME>
|
|
||||||
service nginx restart
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, visit your domain. You should be all set up!
|
|
||||||
|
|
||||||
# Development
|
|
||||||
|
|
||||||
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, run `pnpm run format && pnpm run lint` and fix any errors that come up.
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "social.flotilla"
|
applicationId "social.flotilla"
|
||||||
minSdk rootProject.ext.minSdkVersion
|
minSdk rootProject.ext.minSdkVersion
|
||||||
targetSdk rootProject.ext.targetSdkVersion
|
targetSdk rootProject.ext.targetSdkVersion
|
||||||
versionCode 16
|
versionCode 38
|
||||||
versionName "1.0.2"
|
versionName "1.6.2"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation project(':capacitor-community-safe-area')
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-filesystem')
|
||||||
implementation project(':capacitor-keyboard')
|
implementation project(':capacitor-keyboard')
|
||||||
|
implementation project(':capacitor-preferences')
|
||||||
|
implementation project(':capacitor-push-notifications')
|
||||||
|
implementation project(':capawesome-capacitor-android-dark-mode-support')
|
||||||
|
implementation project(':capawesome-capacitor-badge')
|
||||||
implementation project(':nostr-signer-capacitor-plugin')
|
implementation project(':nostr-signer-capacitor-plugin')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="app.flotilla.social" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
@@ -34,4 +41,5 @@
|
|||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@null</item>
|
||||||
|
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:ignore="NewApi">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.4.3_@capacitor+core@7.4.3/node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-community-safe-area'
|
||||||
|
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.4.3/node_modules/@capacitor-community/safe-area/android')
|
||||||
|
|
||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.1.0_@capacitor+core@7.4.3/node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-filesystem'
|
||||||
|
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@7.1.4_@capacitor+core@7.4.3/node_modules/@capacitor/filesystem/android')
|
||||||
|
|
||||||
include ':capacitor-keyboard'
|
include ':capacitor-keyboard'
|
||||||
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android')
|
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.3_@capacitor+core@7.4.3/node_modules/@capacitor/keyboard/android')
|
||||||
|
|
||||||
|
include ':capacitor-preferences'
|
||||||
|
project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@7.0.2_@capacitor+core@7.4.3/node_modules/@capacitor/preferences/android')
|
||||||
|
|
||||||
|
include ':capacitor-push-notifications'
|
||||||
|
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@7.0.3_@capacitor+core@7.4.3/node_modules/@capacitor/push-notifications/android')
|
||||||
|
|
||||||
|
include ':capawesome-capacitor-android-dark-mode-support'
|
||||||
|
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@7.0.0_@capacitor+core@7.4.3/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
|
||||||
|
|
||||||
|
include ':capawesome-capacitor-badge'
|
||||||
|
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.4.3/node_modules/@capawesome/capacitor-badge/android')
|
||||||
|
|
||||||
include ':nostr-signer-capacitor-plugin'
|
include ':nostr-signer-capacitor-plugin'
|
||||||
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')
|
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.4.3/node_modules/nostr-signer-capacitor-plugin/android')
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
# Fetch tags and set to env vars
|
# Fetch tags and set to env vars
|
||||||
git fetch --prune --unshallow --tags
|
git fetch --prune --unshallow --tags || true
|
||||||
git describe --tags --abbrev=0
|
git describe --tags --abbrev=0 || true
|
||||||
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
|
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
|
||||||
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
|
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
|
||||||
|
|
||||||
# Remove link overrides
|
# Install dependencies
|
||||||
node remove-pnpm-overrides.js package.json
|
CI=0 pnpm i
|
||||||
|
|
||||||
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
|
|
||||||
pnpm i --no-frozen-lockfile
|
|
||||||
|
|
||||||
# Rebuild sharp
|
# Rebuild sharp
|
||||||
pnpm rebuild
|
pnpm rebuild
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
temp_env=$(declare -p -x)
|
temp_env=$(declare -p -x)
|
||||||
|
|
||||||
if [ -f .env ]; then
|
if [ -f .env.template ]; then
|
||||||
source .env
|
source .env.template
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f .env.local ]; then
|
|
||||||
source .env.local
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Avoid overwriting env vars provided directly
|
# Avoid overwriting env vars provided directly
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const config: CapacitorConfig = {
|
|||||||
server: {
|
server: {
|
||||||
androidScheme: "https"
|
androidScheme: "https"
|
||||||
},
|
},
|
||||||
|
android: {
|
||||||
|
adjustMarginsForEdgeToEdge: false,
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
SplashScreen: {
|
SplashScreen: {
|
||||||
androidSplashResourceName: "splash"
|
androidSplashResourceName: "splash"
|
||||||
@@ -15,6 +18,10 @@ const config: CapacitorConfig = {
|
|||||||
style: "DARK",
|
style: "DARK",
|
||||||
resizeOnFullScreen: true,
|
resizeOnFullScreen: true,
|
||||||
},
|
},
|
||||||
|
Badge: {
|
||||||
|
persist: true,
|
||||||
|
autoClear: true
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
|
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
|
||||||
// server: {
|
// server: {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||||
|
3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */; };
|
||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||||
@@ -18,8 +19,10 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Flotilla Chat.entitlements"; sourceTree = "<group>"; };
|
||||||
1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; };
|
1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||||
|
3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||||
504EC3041FED79650016851F /* Flotilla Chat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Flotilla Chat.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
504EC3041FED79650016851F /* Flotilla Chat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Flotilla Chat.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
@@ -57,6 +60,8 @@
|
|||||||
504EC2FB1FED79650016851F = {
|
504EC2FB1FED79650016851F = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */,
|
||||||
|
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */,
|
||||||
504EC3061FED79650016851F /* App */,
|
504EC3061FED79650016851F /* App */,
|
||||||
504EC3051FED79650016851F /* Products */,
|
504EC3051FED79650016851F /* Products */,
|
||||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||||
@@ -160,6 +165,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
||||||
|
3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||||
@@ -349,16 +355,17 @@
|
|||||||
baseConfigurationReference = 7B9FA71C362B734D9F965709 /* Pods-Flotilla Chat.debug.xcconfig */;
|
baseConfigurationReference = 7B9FA71C362B734D9F965709 /* Pods-Flotilla Chat.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 10;
|
CURRENT_PROJECT_VERSION = 28;
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.0.2;
|
MARKETING_VERSION = 1.6.2;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -374,16 +381,17 @@
|
|||||||
baseConfigurationReference = 1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */;
|
baseConfigurationReference = 1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 10;
|
CURRENT_PROJECT_VERSION = 28;
|
||||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.0.2;
|
MARKETING_VERSION = 1.6.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@@ -46,4 +46,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||||
|
// Enable push notifications https://capacitorjs.com/docs/apis/push-notifications
|
||||||
|
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||||
|
// Enable push notifications https://capacitorjs.com/docs/apis/push-notifications
|
||||||
|
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,5 +49,9 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>applinks:app.flotilla.social</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+11
-6
@@ -1,4 +1,4 @@
|
|||||||
require_relative '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios/scripts/pods_helpers'
|
require_relative '../../node_modules/.pnpm/@capacitor+ios@7.4.3_@capacitor+core@7.4.3/node_modules/@capacitor/ios/scripts/pods_helpers'
|
||||||
|
|
||||||
platform :ios, '14.0'
|
platform :ios, '14.0'
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
@@ -9,11 +9,16 @@ use_frameworks!
|
|||||||
install! 'cocoapods', :disable_input_output_paths => true
|
install! 'cocoapods', :disable_input_output_paths => true
|
||||||
|
|
||||||
def capacitor_pods
|
def capacitor_pods
|
||||||
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
|
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.4.3_@capacitor+core@7.4.3/node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
|
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.4.3_@capacitor+core@7.4.3/node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
|
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.4.3/node_modules/@capacitor-community/safe-area'
|
||||||
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
|
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.1.0_@capacitor+core@7.4.3/node_modules/@capacitor/app'
|
||||||
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
|
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@7.1.4_@capacitor+core@7.4.3/node_modules/@capacitor/filesystem'
|
||||||
|
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.3_@capacitor+core@7.4.3/node_modules/@capacitor/keyboard'
|
||||||
|
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@7.0.2_@capacitor+core@7.4.3/node_modules/@capacitor/preferences'
|
||||||
|
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@7.0.3_@capacitor+core@7.4.3/node_modules/@capacitor/push-notifications'
|
||||||
|
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.4.3/node_modules/@capawesome/capacitor-badge'
|
||||||
|
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.4.3/node_modules/nostr-signer-capacitor-plugin'
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'Flotilla Chat' do
|
target 'Flotilla Chat' do
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>C617.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+65
-67
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flotilla",
|
"name": "flotilla",
|
||||||
"version": "1.0.2",
|
"version": "1.6.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -10,86 +10,84 @@
|
|||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check src && eslint src",
|
"lint": "prettier --check src && eslint src",
|
||||||
"format": "prettier --write src",
|
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte)$' | xargs -r prettier --write",
|
||||||
|
"format:all": "prettier --write src",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@sentry/cli": "^2.40.0",
|
"@eslint/js": "^9.37.0",
|
||||||
"@sveltejs/kit": "^2.5.27",
|
"@sentry/cli": "^2.56.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/kit": "^2.46.5",
|
||||||
"@types/eslint": "^9.6.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||||
"autoprefixer": "^10.4.19",
|
"@types/eslint": "^9.6.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.37.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.2",
|
||||||
"eslint-plugin-svelte": "^2.45.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
"globals": "^15.0.0",
|
"globals": "^15.15.0",
|
||||||
"postcss": "^8.4.40",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.39.12",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.3.3",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^3.4.18",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.0.0",
|
"typescript-eslint": "^8.46.1",
|
||||||
"vite": "^5.4.4"
|
"vite": "^5.4.20"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^7.0.0",
|
"@capacitor-community/safe-area": "7.0.0-alpha.1",
|
||||||
"@capacitor/app": "^7.0.0",
|
"@capacitor/android": "^7.4.3",
|
||||||
"@capacitor/cli": "^7.0.0",
|
"@capacitor/app": "^7.1.0",
|
||||||
"@capacitor/core": "^7.0.1",
|
"@capacitor/cli": "^7.4.3",
|
||||||
"@capacitor/ios": "^7.0.0",
|
"@capacitor/core": "^7.4.3",
|
||||||
"@capacitor/keyboard": "^7.0.0",
|
"@capacitor/filesystem": "^7.1.4",
|
||||||
"@noble/curves": "^1.5.0",
|
"@capacitor/ios": "^7.4.3",
|
||||||
"@noble/hashes": "^1.4.0",
|
"@capacitor/keyboard": "^7.0.3",
|
||||||
|
"@capacitor/preferences": "^7.0.2",
|
||||||
|
"@capacitor/push-notifications": "^7.0.3",
|
||||||
|
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
|
||||||
|
"@capawesome/capacitor-badge": "^7.0.1",
|
||||||
|
"@getalby/lightning-tools": "^6.0.0",
|
||||||
|
"@getalby/sdk": "^5.1.2",
|
||||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||||
"@sentry/browser": "^8.35.0",
|
"@sentry/browser": "^8.55.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.4",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@tiptap/core": "^2.26.3",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"@vite-pwa/assets-generator": "^0.2.6",
|
"@vite-pwa/assets-generator": "^0.2.6",
|
||||||
"@vite-pwa/sveltekit": "^0.6.6",
|
"@vite-pwa/sveltekit": "^0.6.8",
|
||||||
"@welshman/app": "^0.2.4",
|
"@welshman/app": "^0.7.1",
|
||||||
"@welshman/content": "^0.2.1",
|
"@welshman/content": "^0.7.1",
|
||||||
"@welshman/dvm": "^0.2.0",
|
"@welshman/editor": "^0.7.1",
|
||||||
"@welshman/editor": "^0.2.1",
|
"@welshman/feeds": "^0.7.1",
|
||||||
"@welshman/feeds": "^0.2.2",
|
"@welshman/lib": "^0.7.1",
|
||||||
"@welshman/lib": "^0.2.2",
|
"@welshman/net": "^0.7.1",
|
||||||
"@welshman/net": "^0.2.3",
|
"@welshman/router": "^0.7.1",
|
||||||
"@welshman/relay": "^0.2.0",
|
"@welshman/signer": "^0.7.1",
|
||||||
"@welshman/router": "^0.2.0",
|
"@welshman/store": "^0.7.1",
|
||||||
"@welshman/signer": "^0.2.3",
|
"@welshman/util": "^0.7.1",
|
||||||
"@welshman/store": "^0.2.0",
|
"compressorjs": "^1.2.1",
|
||||||
"@welshman/util": "^0.2.2",
|
"daisyui": "^4.12.24",
|
||||||
"daisyui": "^4.12.10",
|
"date-picker-svelte": "^2.16.0",
|
||||||
"date-picker-svelte": "^2.13.0",
|
"dotenv": "^16.6.1",
|
||||||
"dotenv": "^16.4.5",
|
"emoji-picker-element": "^1.27.0",
|
||||||
"emoji-picker-element": "^1.22.8",
|
"fuse.js": "^7.1.0",
|
||||||
"fuse.js": "^7.0.0",
|
"husky": "^9.1.7",
|
||||||
"husky": "^9.1.6",
|
"idb": "^8.0.3",
|
||||||
"idb": "^8.0.0",
|
|
||||||
"nostr-signer-capacitor-plugin": "^0.0.4",
|
"nostr-signer-capacitor-plugin": "^0.0.4",
|
||||||
"nostr-tools": "^2.7.2",
|
"nostr-tools": "^2.14.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"qrcode": "^1.5.4"
|
"qr-scanner": "^1.4.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"throttle-debounce": "^5.0.2",
|
||||||
|
"tippy.js": "^6.3.7"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
|
||||||
"@welshman/lib": "link:../welshman/packages/lib",
|
|
||||||
"@welshman/util": "link:../welshman/packages/util",
|
|
||||||
"@welshman/app": "link:../welshman/packages/app",
|
|
||||||
"@welshman/content": "link:../welshman/packages/content",
|
|
||||||
"@welshman/dvm": "link:../welshman/packages/dvm",
|
|
||||||
"@welshman/feeds": "link:../welshman/packages/feeds",
|
|
||||||
"@welshman/net": "link:../welshman/packages/net",
|
|
||||||
"@welshman/relay": "link:../welshman/packages/relay",
|
|
||||||
"@welshman/router": "link:../welshman/packages/router",
|
|
||||||
"@welshman/signer": "link:../welshman/packages/signer",
|
|
||||||
"@welshman/store": "link:../welshman/packages/store",
|
|
||||||
"@welshman/editor": "link:../welshman/packages/editor"
|
|
||||||
},
|
|
||||||
"ignoredBuiltDependencies": [
|
"ignoredBuiltDependencies": [
|
||||||
"@sentry/cli",
|
"@sentry/cli",
|
||||||
"esbuild"
|
"esbuild"
|
||||||
|
|||||||
Generated
+2741
-1595
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
import dotenv from "dotenv"
|
import dotenv from "dotenv"
|
||||||
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
||||||
|
|
||||||
dotenv.config({path: ".env.local"})
|
|
||||||
dotenv.config({path: ".env"})
|
dotenv.config({path: ".env"})
|
||||||
|
dotenv.config({path: ".env.template"})
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
preset,
|
preset,
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
// This script is necessary for installing stuff on a host, since our links don't exist there.
|
|
||||||
|
|
||||||
import fs from "fs"
|
|
||||||
|
|
||||||
const pkgName = process.argv[2]
|
|
||||||
|
|
||||||
if (!pkgName?.endsWith("package.json")) {
|
|
||||||
console.log("File passed was not a package.json file")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pkg = JSON.parse(fs.readFileSync(pkgName, "utf8"))
|
|
||||||
|
|
||||||
if (pkg.pnpm && pkg.pnpm.overrides) {
|
|
||||||
delete pkg.pnpm.overrides
|
|
||||||
fs.writeFileSync(pkgName, JSON.stringify(pkg, null, 2) + "\n")
|
|
||||||
console.log("Removed pnpm.overrides from package.json")
|
|
||||||
} else {
|
|
||||||
console.log("No pnpm.overrides found in package.json")
|
|
||||||
}
|
|
||||||
+37
-21
@@ -46,6 +46,14 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: Lato;
|
font-family: Lato;
|
||||||
|
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||||
|
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||||
|
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||||
|
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme] {
|
||||||
|
@apply bg-base-300;
|
||||||
--base-100: oklch(var(--b1));
|
--base-100: oklch(var(--b1));
|
||||||
--base-200: oklch(var(--b2));
|
--base-200: oklch(var(--b2));
|
||||||
--base-300: oklch(var(--b3));
|
--base-300: oklch(var(--b3));
|
||||||
@@ -54,16 +62,8 @@
|
|||||||
--primary-content: oklch(var(--pc));
|
--primary-content: oklch(var(--pc));
|
||||||
--secondary: oklch(var(--s));
|
--secondary: oklch(var(--s));
|
||||||
--secondary-content: oklch(var(--sc));
|
--secondary-content: oklch(var(--sc));
|
||||||
--sait: env(safe-area-inset-top);
|
--neutral: oklch(var(--n));
|
||||||
--saib: env(safe-area-inset-bottom);
|
--neutral-content: oklch(var(--nc));
|
||||||
--sail: env(safe-area-inset-left);
|
|
||||||
--sair: env(safe-area-inset-right);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root,
|
|
||||||
body,
|
|
||||||
html {
|
|
||||||
@apply bg-base-300;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* safe area insets */
|
/* safe area insets */
|
||||||
@@ -162,11 +162,11 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card2 {
|
.card2 {
|
||||||
@apply rounded-box p-6 text-base-content;
|
@apply rounded-box p-4 text-base-content sm:p-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card2.card2-sm {
|
.card2.card2-sm {
|
||||||
@apply p-4 text-base-content;
|
@apply p-2 text-base-content sm:p-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
@@ -217,12 +217,6 @@ html {
|
|||||||
@apply ellipsize;
|
@apply ellipsize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
[data-tip]::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-padding-x {
|
.content-padding-x {
|
||||||
@apply px-4 sm:px-8 md:px-12;
|
@apply px-4 sm:px-8 md:px-12;
|
||||||
}
|
}
|
||||||
@@ -280,8 +274,8 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap {
|
.tiptap {
|
||||||
--tiptap-object-bg: var(--base-100);
|
--tiptap-object-bg: var(--neutral);
|
||||||
--tiptap-object-fg: var(--base-content);
|
--tiptap-object-fg: var(--neutral-content);
|
||||||
--tiptap-active-bg: var(--primary);
|
--tiptap-active-bg: var(--primary);
|
||||||
--tiptap-active-fg: var(--primary-content);
|
--tiptap-active-fg: var(--primary-content);
|
||||||
}
|
}
|
||||||
@@ -293,6 +287,14 @@ html {
|
|||||||
--tiptap-active-fg: var(--base-content);
|
--tiptap-active-fg: var(--base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tiptap-suggestions__item {
|
||||||
|
@apply border-l-2 border-solid border-base-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-suggestions__selected {
|
||||||
|
@apply border-primary;
|
||||||
|
}
|
||||||
|
|
||||||
.tiptap {
|
.tiptap {
|
||||||
@apply max-h-[350px] overflow-y-auto p-2 px-4;
|
@apply max-h-[350px] overflow-y-auto p-2 px-4;
|
||||||
}
|
}
|
||||||
@@ -382,14 +384,28 @@ progress[value]::-webkit-progress-value {
|
|||||||
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
|
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cw-full {
|
||||||
|
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
|
||||||
|
}
|
||||||
|
|
||||||
.cb {
|
.cb {
|
||||||
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keyboard open state adjustments */
|
||||||
|
|
||||||
|
body.keyboard-open .cb {
|
||||||
|
@apply bottom-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.keyboard-open .hide-on-keyboard {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* chat view */
|
/* chat view */
|
||||||
|
|
||||||
.chat__compose {
|
.chat__compose {
|
||||||
@apply cb cw fixed;
|
@apply cb cw fixed z-compose;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat__scroll-down {
|
.chat__scroll-down {
|
||||||
|
|||||||
@@ -1,449 +0,0 @@
|
|||||||
import * as nip19 from "nostr-tools/nip19"
|
|
||||||
import {get} from "svelte/store"
|
|
||||||
import {randomId, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
|
|
||||||
import type {Feed} from "@welshman/feeds"
|
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
|
||||||
import {
|
|
||||||
DELETE,
|
|
||||||
REPORT,
|
|
||||||
PROFILE,
|
|
||||||
INBOX_RELAYS,
|
|
||||||
RELAYS,
|
|
||||||
FOLLOWS,
|
|
||||||
REACTION,
|
|
||||||
AUTH_JOIN,
|
|
||||||
GROUP_JOIN,
|
|
||||||
GROUP_LEAVE,
|
|
||||||
GROUP_CREATE,
|
|
||||||
GROUP_EDIT_META,
|
|
||||||
GROUPS,
|
|
||||||
COMMENT,
|
|
||||||
isSignedEvent,
|
|
||||||
createEvent,
|
|
||||||
displayProfile,
|
|
||||||
normalizeRelayUrl,
|
|
||||||
makeList,
|
|
||||||
addToListPublicly,
|
|
||||||
removeFromListByPredicate,
|
|
||||||
getTag,
|
|
||||||
getListTags,
|
|
||||||
getRelayTags,
|
|
||||||
getRelayTagValues,
|
|
||||||
toNostrURI,
|
|
||||||
getRelaysFromList,
|
|
||||||
RelayMode,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import {Pool, PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
|
|
||||||
import {Router} from "@welshman/router"
|
|
||||||
import {
|
|
||||||
pubkey,
|
|
||||||
signer,
|
|
||||||
repository,
|
|
||||||
publishThunk,
|
|
||||||
profilesByPubkey,
|
|
||||||
relaySelectionsByPubkey,
|
|
||||||
tagEvent,
|
|
||||||
tagEventForReaction,
|
|
||||||
userRelaySelections,
|
|
||||||
userInboxRelaySelections,
|
|
||||||
nip44EncryptToSelf,
|
|
||||||
loadRelay,
|
|
||||||
clearStorage,
|
|
||||||
dropSession,
|
|
||||||
tagEventForComment,
|
|
||||||
tagEventForQuote,
|
|
||||||
thunkIsComplete,
|
|
||||||
} from "@welshman/app"
|
|
||||||
import type {Thunk} from "@welshman/app"
|
|
||||||
import {
|
|
||||||
tagRoom,
|
|
||||||
PROTECTED,
|
|
||||||
userMembership,
|
|
||||||
INDEXER_RELAYS,
|
|
||||||
ALERT,
|
|
||||||
NOTIFIER_PUBKEY,
|
|
||||||
NOTIFIER_RELAY,
|
|
||||||
userRoomsByUrl,
|
|
||||||
} from "@app/state"
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
|
|
||||||
export const getPubkeyHints = (pubkey: string) => {
|
|
||||||
const selections = relaySelectionsByPubkey.get().get(pubkey)
|
|
||||||
const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
|
|
||||||
const hints = relays.length ? relays : INDEXER_RELAYS
|
|
||||||
|
|
||||||
return hints
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPubkeyPetname = (pubkey: string) => {
|
|
||||||
const profile = profilesByPubkey.get().get(pubkey)
|
|
||||||
const display = displayProfile(profile)
|
|
||||||
|
|
||||||
return display
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getThunkError = (thunk: Thunk) =>
|
|
||||||
new Promise<string>(resolve => {
|
|
||||||
thunk.subscribe($thunk => {
|
|
||||||
for (const [relay, status] of Object.entries($thunk.status)) {
|
|
||||||
if (status === PublishStatus.Failure) {
|
|
||||||
resolve($thunk.details[relay])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thunkIsComplete($thunk)) {
|
|
||||||
resolve("")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
|
|
||||||
if (parent) {
|
|
||||||
const nevent = nip19.neventEncode({
|
|
||||||
id: parent.id,
|
|
||||||
kind: parent.kind,
|
|
||||||
author: parent.pubkey,
|
|
||||||
relays: Router.get().Event(parent).limit(3).getUrls(),
|
|
||||||
})
|
|
||||||
|
|
||||||
tags = [...tags, tagEventForQuote(parent)]
|
|
||||||
content = toNostrURI(nevent) + "\n\n" + content
|
|
||||||
}
|
|
||||||
|
|
||||||
return {content, tags}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log out
|
|
||||||
|
|
||||||
export const logout = async () => {
|
|
||||||
const $pubkey = pubkey.get()
|
|
||||||
|
|
||||||
if ($pubkey) {
|
|
||||||
dropSession($pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
await clearStorage()
|
|
||||||
|
|
||||||
localStorage.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synchronization
|
|
||||||
|
|
||||||
export const broadcastUserData = async (relays: string[]) => {
|
|
||||||
const authors = [pubkey.get()!]
|
|
||||||
const kinds = [RELAYS, INBOX_RELAYS, FOLLOWS, PROFILE]
|
|
||||||
const events = repository.query([{kinds, authors}])
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
if (isSignedEvent(event)) {
|
|
||||||
await publishThunk({event, relays}).result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NIP 29 stuff
|
|
||||||
|
|
||||||
export const nip29 = {
|
|
||||||
createRoom: (url: string, room: string) => {
|
|
||||||
const event = createEvent(GROUP_CREATE, {tags: [tagRoom(room, url)]})
|
|
||||||
|
|
||||||
return publishThunk({event, relays: [url]})
|
|
||||||
},
|
|
||||||
editMeta: (url: string, room: string, meta: Record<string, string>) => {
|
|
||||||
const event = createEvent(GROUP_EDIT_META, {
|
|
||||||
tags: [tagRoom(room, url), ...Object.entries(meta)],
|
|
||||||
})
|
|
||||||
|
|
||||||
return publishThunk({event, relays: [url]})
|
|
||||||
},
|
|
||||||
joinRoom: (url: string, room: string) => {
|
|
||||||
const event = createEvent(GROUP_JOIN, {tags: [tagRoom(room, url)]})
|
|
||||||
|
|
||||||
return publishThunk({event, relays: [url]})
|
|
||||||
},
|
|
||||||
leaveRoom: (url: string, room: string) => {
|
|
||||||
const event = createEvent(GROUP_LEAVE, {tags: [tagRoom(room, url)]})
|
|
||||||
|
|
||||||
return publishThunk({event, relays: [url]})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// List updates
|
|
||||||
|
|
||||||
export const addSpaceMembership = async (url: string) => {
|
|
||||||
const list = get(userMembership) || makeList({kind: GROUPS})
|
|
||||||
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
|
|
||||||
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const removeSpaceMembership = async (url: string) => {
|
|
||||||
const list = get(userMembership) || makeList({kind: GROUPS})
|
|
||||||
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
|
|
||||||
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
|
||||||
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const addRoomMembership = async (url: string, room: string, name: string) => {
|
|
||||||
const list = get(userMembership) || makeList({kind: GROUPS})
|
|
||||||
const newTags = [
|
|
||||||
["r", url],
|
|
||||||
["group", room, url, name],
|
|
||||||
]
|
|
||||||
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
|
|
||||||
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const removeRoomMembership = async (url: string, room: string) => {
|
|
||||||
const list = get(userMembership) || makeList({kind: GROUPS})
|
|
||||||
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
|
|
||||||
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
|
||||||
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
|
|
||||||
const list = get(userRelaySelections) || makeList({kind: RELAYS})
|
|
||||||
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
|
|
||||||
|
|
||||||
if (read && write) {
|
|
||||||
tags.push(["r", url])
|
|
||||||
} else if (read) {
|
|
||||||
tags.push(["r", url, "read"])
|
|
||||||
} else if (write) {
|
|
||||||
tags.push(["r", url, "write"])
|
|
||||||
}
|
|
||||||
|
|
||||||
return publishThunk({
|
|
||||||
event: createEvent(list.kind, {tags}),
|
|
||||||
relays: [
|
|
||||||
url,
|
|
||||||
...INDEXER_RELAYS,
|
|
||||||
...Router.get().FromUser().getUrls(),
|
|
||||||
...userRoomsByUrl.get().keys(),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
|
|
||||||
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
|
|
||||||
|
|
||||||
// Only update inbox policies if they already exist or we're adding them
|
|
||||||
if (enabled || getRelaysFromList(list).includes(url)) {
|
|
||||||
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
tags.push(["relay", url])
|
|
||||||
}
|
|
||||||
|
|
||||||
return publishThunk({
|
|
||||||
event: createEvent(list.kind, {tags}),
|
|
||||||
relays: [
|
|
||||||
...INDEXER_RELAYS,
|
|
||||||
...Router.get().FromUser().getUrls(),
|
|
||||||
...userRoomsByUrl.get().keys(),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relay access
|
|
||||||
|
|
||||||
export const checkRelayAccess = async (url: string, claim = "") => {
|
|
||||||
const socket = Pool.get().get(url)
|
|
||||||
|
|
||||||
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
|
|
||||||
|
|
||||||
const thunk = publishThunk({
|
|
||||||
event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
|
|
||||||
relays: [url],
|
|
||||||
})
|
|
||||||
|
|
||||||
const error = await getThunkError(thunk)
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
const message =
|
|
||||||
socket.auth.details?.replace(/^\w+: /, "") ||
|
|
||||||
error?.replace(/^\w+: /, "") ||
|
|
||||||
"join request rejected"
|
|
||||||
|
|
||||||
// If it's a strict NIP 29 relay don't worry about requesting access
|
|
||||||
// TODO: remove this if relay29 ever gets less strict
|
|
||||||
if (message !== "missing group (`h`) tag") {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkRelayProfile = async (url: string) => {
|
|
||||||
const relay = await loadRelay(url)
|
|
||||||
|
|
||||||
if (!relay?.profile) {
|
|
||||||
return "Sorry, we weren't able to find that relay."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkRelayConnection = async (url: string) => {
|
|
||||||
const socket = Pool.get().get(url)
|
|
||||||
|
|
||||||
socket.attemptToOpen()
|
|
||||||
|
|
||||||
await poll({
|
|
||||||
signal: AbortSignal.timeout(3000),
|
|
||||||
condition: () => socket.status === SocketStatus.Open,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (socket.status !== SocketStatus.Open) {
|
|
||||||
return `Failed to connect`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkRelayAuth = async (url: string, timeout = 3000) => {
|
|
||||||
const socket = Pool.get().get(url)
|
|
||||||
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
|
|
||||||
|
|
||||||
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
|
|
||||||
|
|
||||||
// Only raise an error if it's not a timeout.
|
|
||||||
// If it is, odds are the problem is with our signer, not the relay
|
|
||||||
if (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
|
|
||||||
return `Failed to authenticate (${socket.auth.details})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const attemptRelayAccess = async (url: string, claim = "") => {
|
|
||||||
const checks = [
|
|
||||||
() => checkRelayConnection(url),
|
|
||||||
() => checkRelayAccess(url, claim),
|
|
||||||
() => checkRelayAuth(url),
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const check of checks) {
|
|
||||||
const error = await check()
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
|
|
||||||
export const makeDelete = ({event}: {event: TrustedEvent}) => {
|
|
||||||
const tags = [["k", String(event.kind)], ...tagEvent(event)]
|
|
||||||
const groupTag = getTag("h", event.tags)
|
|
||||||
|
|
||||||
if (groupTag) {
|
|
||||||
tags.push(PROTECTED)
|
|
||||||
tags.push(groupTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
return createEvent(DELETE, {tags})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
|
|
||||||
publishThunk({event: makeDelete({event}), relays})
|
|
||||||
|
|
||||||
export type ReportParams = {
|
|
||||||
event: TrustedEvent
|
|
||||||
content: string
|
|
||||||
reason: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeReport = ({event, reason, content}: ReportParams) => {
|
|
||||||
const tags = [
|
|
||||||
["p", event.pubkey],
|
|
||||||
["e", event.id, reason],
|
|
||||||
]
|
|
||||||
|
|
||||||
return createEvent(REPORT, {content, tags})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const publishReport = ({
|
|
||||||
relays,
|
|
||||||
event,
|
|
||||||
reason,
|
|
||||||
content,
|
|
||||||
}: ReportParams & {relays: string[]}) =>
|
|
||||||
publishThunk({event: makeReport({event, reason, content}), relays})
|
|
||||||
|
|
||||||
export type ReactionParams = {
|
|
||||||
event: TrustedEvent
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeReaction = ({event, content}: ReactionParams) => {
|
|
||||||
const tags = tagEventForReaction(event)
|
|
||||||
const groupTag = getTag("h", event.tags)
|
|
||||||
|
|
||||||
if (groupTag) {
|
|
||||||
tags.push(PROTECTED)
|
|
||||||
tags.push(groupTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
return createEvent(REACTION, {content, tags})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
|
|
||||||
publishThunk({event: makeReaction(params), relays})
|
|
||||||
|
|
||||||
export type CommentParams = {
|
|
||||||
event: TrustedEvent
|
|
||||||
content: string
|
|
||||||
tags?: string[][]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeComment = ({event, content, tags = []}: CommentParams) =>
|
|
||||||
createEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
|
|
||||||
|
|
||||||
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
|
|
||||||
publishThunk({event: makeComment(params), relays})
|
|
||||||
|
|
||||||
export type AlertParams = {
|
|
||||||
feed: Feed
|
|
||||||
cron: string
|
|
||||||
email: string
|
|
||||||
bunker: string
|
|
||||||
secret: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeAlert = async ({cron, email, feed, bunker, secret, description}: AlertParams) => {
|
|
||||||
const tags = [
|
|
||||||
["feed", JSON.stringify(feed)],
|
|
||||||
["cron", cron],
|
|
||||||
["email", email],
|
|
||||||
["locale", LOCALE],
|
|
||||||
["timezone", TIMEZONE],
|
|
||||||
["description", description],
|
|
||||||
["channel", "email"],
|
|
||||||
[
|
|
||||||
"handler",
|
|
||||||
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
|
|
||||||
"wss://relay.nostr.band/",
|
|
||||||
"web",
|
|
||||||
],
|
|
||||||
]
|
|
||||||
|
|
||||||
if (bunker) {
|
|
||||||
tags.push(["nip46", secret, bunker])
|
|
||||||
}
|
|
||||||
|
|
||||||
return createEvent(ALERT, {
|
|
||||||
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
|
|
||||||
tags: [
|
|
||||||
["d", randomId()],
|
|
||||||
["p", NOTIFIER_PUBKEY],
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const publishAlert = async (params: AlertParams) =>
|
|
||||||
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
|
|
||||||
@@ -1,80 +1,65 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
|
import {randomInt, map, displayList, identity, TIMEZONE} from "@welshman/lib"
|
||||||
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
|
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
|
||||||
import type {Filter} from "@welshman/util"
|
import type {Filter} from "@welshman/util"
|
||||||
import type {Nip46ResponseWithResult} from "@welshman/signer"
|
|
||||||
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
|
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
|
||||||
import {pubkey} from "@welshman/app"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import InfoBunker from "@app/components/InfoBunker.svelte"
|
import {alertsById, userSpaceUrls} from "@app/core/state"
|
||||||
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
|
import {requestRelayClaim} from "@app/core/requests"
|
||||||
import {
|
import {createAlert} from "@app/core/commands"
|
||||||
GENERAL,
|
import {canSendPushNotifications} from "@app/util/push"
|
||||||
alerts,
|
import {pushToast} from "@app/util/toast"
|
||||||
getMembershipUrls,
|
|
||||||
getMembershipRoomsByUrl,
|
|
||||||
userMembership,
|
|
||||||
} from "@app/state"
|
|
||||||
import {loadAlertStatuses} from "@app/requests"
|
|
||||||
import {publishAlert} from "@app/commands"
|
|
||||||
import {pushToast} from "@app/toast"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
|
type Props = {
|
||||||
|
url?: string
|
||||||
|
channel?: string
|
||||||
|
notifyChat?: boolean
|
||||||
|
notifyThreads?: boolean
|
||||||
|
notifyCalendar?: boolean
|
||||||
|
hideSpaceField?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
url = "",
|
||||||
|
channel = "email",
|
||||||
|
notifyChat = true,
|
||||||
|
notifyThreads = true,
|
||||||
|
notifyCalendar = true,
|
||||||
|
hideSpaceField = false,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
const timezoneOffset = parseInt(TIMEZONE.split(":")?.[0] || "00")
|
||||||
const minute = randomInt(0, 59)
|
const minute = randomInt(0, 59)
|
||||||
const hour = (17 - timezoneOffset) % 24
|
const hour = (17 - timezoneOffset) % 24
|
||||||
const WEEKLY = `0 ${minute} ${hour} * * 1`
|
const WEEKLY = `0 ${minute} ${hour} * * 1`
|
||||||
const DAILY = `0 ${minute} ${hour} * * *`
|
const DAILY = `0 ${minute} ${hour} * * *`
|
||||||
|
|
||||||
let loading = false
|
let loading = $state(false)
|
||||||
let cron = WEEKLY
|
let cron = $state(WEEKLY)
|
||||||
let email = $alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || ""
|
let email = $state(
|
||||||
let relay = ""
|
map(a => getTagValue("email", a.tags), $alertsById.values()).filter(identity)[0] || "",
|
||||||
let bunker = ""
|
)
|
||||||
let secret = ""
|
|
||||||
let notifyThreads = true
|
|
||||||
let notifyCalendar = true
|
|
||||||
let notifyChat = false
|
|
||||||
let showBunker = false
|
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const controller = new BunkerConnectController({
|
|
||||||
onNostrConnect: (response: Nip46ResponseWithResult) => {
|
|
||||||
bunker = controller.broker.getBunkerUrl()
|
|
||||||
secret = controller.broker.params.clientSecret
|
|
||||||
showBunker = false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const connectBunker = () => {
|
|
||||||
showBunker = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideBunker = () => {
|
|
||||||
showBunker = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearBunker = () => {
|
|
||||||
bunker = ""
|
|
||||||
secret = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!email.includes("@")) {
|
if (channel === "email" && !email.includes("@")) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Please provide an email address",
|
message: "Please provide an email address",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!relay) {
|
if (!url) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Please select a space",
|
message: "Please select a space",
|
||||||
@@ -105,29 +90,37 @@
|
|||||||
|
|
||||||
if (notifyChat) {
|
if (notifyChat) {
|
||||||
display.push("chat")
|
display.push("chat")
|
||||||
filters.push({
|
filters.push({kinds: [MESSAGE]})
|
||||||
kinds: [MESSAGE],
|
|
||||||
"#h": [GENERAL, ...getMembershipRoomsByUrl(relay, $userMembership)],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
|
const claim = url ? await requestRelayClaim(url) : undefined
|
||||||
const description = `${cadence} alert for ${displayList(display)} on ${displayRelayUrl(relay)}, sent via email.`
|
|
||||||
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(relay))
|
|
||||||
const thunk = await publishAlert({cron, email, feed, bunker, secret, description})
|
|
||||||
|
|
||||||
await thunk.result
|
const {error} = await createAlert({
|
||||||
await loadAlertStatuses($pubkey!)
|
feed: makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url)),
|
||||||
|
claims: claim ? {[url]: claim} : {},
|
||||||
|
description: `for ${displayList(display)} on ${displayRelayUrl(url)}`,
|
||||||
|
email: channel === "email" ? {cron, email} : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
pushToast({message: "Your alert has been successfully created!"})
|
if (error) {
|
||||||
back()
|
pushToast({theme: "error", message: error})
|
||||||
|
} else {
|
||||||
|
pushToast({message: "Your alert has been successfully created!"})
|
||||||
|
back()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!canSendPushNotifications()) {
|
||||||
|
channel = "email"
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="column gap-4" onsubmit={preventDefault(submit)}>
|
<form class="column gap-4" onsubmit={preventDefault(submit)}>
|
||||||
@@ -135,14 +128,24 @@
|
|||||||
{#snippet title()}
|
{#snippet title()}
|
||||||
Add an Alert
|
Add an Alert
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
Enable notifications to keep up to date on activity you care about.
|
||||||
|
{/snippet}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
{#if showBunker}
|
{#if canSendPushNotifications()}
|
||||||
<div class="card2 flex flex-col items-center gap-4 bg-base-300">
|
<FieldInline>
|
||||||
<p>Scan using a nostr signer, or click to copy.</p>
|
{#snippet label()}
|
||||||
<BunkerConnect {controller} />
|
<p>Alert Type*</p>
|
||||||
<Button class="btn btn-neutral btn-sm" onclick={hideBunker}>Cancel</Button>
|
{/snippet}
|
||||||
</div>
|
{#snippet input()}
|
||||||
{:else}
|
<select bind:value={channel} class="select select-bordered">
|
||||||
|
<option value="email">Email Digest</option>
|
||||||
|
<option value="push">Push Notification</option>
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{/if}
|
||||||
|
{#if channel === "email"}
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
<p>Email Address*</p>
|
<p>Email Address*</p>
|
||||||
@@ -164,74 +167,51 @@
|
|||||||
</select>
|
</select>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
|
{/if}
|
||||||
|
{#if !hideSpaceField}
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
<p>Space*</p>
|
<p>Space*</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<select bind:value={relay} class="select select-bordered">
|
<select bind:value={url} class="select select-bordered">
|
||||||
<option value="" disabled selected>Choose a space URL</option>
|
<option value="" disabled selected>Choose a space URL</option>
|
||||||
{#each getMembershipUrls($userMembership) as url (url)}
|
{#each $userSpaceUrls as url (url)}
|
||||||
<option value={url}>{displayRelayUrl(url)}</option>
|
<option value={url}>{displayRelayUrl(url)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
<FieldInline>
|
{/if}
|
||||||
{#snippet label()}
|
<FieldInline>
|
||||||
<p>Notifications*</p>
|
{#snippet label()}
|
||||||
{/snippet}
|
<p>Notifications*</p>
|
||||||
{#snippet input()}
|
{/snippet}
|
||||||
<div class="flex items-center justify-end gap-4">
|
{#snippet input()}
|
||||||
<span class="flex gap-3">
|
<div class="flex items-center justify-end gap-4">
|
||||||
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
|
<span class="flex gap-3">
|
||||||
Threads
|
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
|
||||||
</span>
|
Threads
|
||||||
<span class="flex gap-3">
|
</span>
|
||||||
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
|
<span class="flex gap-3">
|
||||||
Calendar
|
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
|
||||||
</span>
|
Calendar
|
||||||
<span class="flex gap-3">
|
</span>
|
||||||
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
|
<span class="flex gap-3">
|
||||||
Chat
|
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
|
||||||
</span>
|
Chat
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</FieldInline>
|
|
||||||
<div class="card2 flex flex-col gap-3 bg-base-300">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<strong>Connect a Bunker</strong>
|
|
||||||
<span class="flex items-center gap-2 text-sm" class:text-primary={bunker}>
|
|
||||||
{#if bunker}
|
|
||||||
<Icon icon="check-circle" size={5} />
|
|
||||||
Connected
|
|
||||||
{:else}
|
|
||||||
<Icon icon="close-circle" size={5} />
|
|
||||||
Not Connected
|
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm">
|
{/snippet}
|
||||||
Required for receiving alerts about spaces with access controls. You can get one from your
|
</FieldInline>
|
||||||
<Button class="text-primary" onclick={() => pushModal(InfoBunker)}>remote signer app</Button
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
{#if bunker}
|
|
||||||
<Button class="btn btn-neutral btn-sm flex-grow" onclick={clearBunker}>Disconnect</Button>
|
|
||||||
{:else}
|
|
||||||
<Button class="btn btn-primary btn-sm w-full flex-grow" onclick={connectBunker}
|
|
||||||
>Connect</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon={AltArrowLeft} />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={loading || showBunker}>
|
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||||
<Spinner {loading}>Confirm</Spinner>
|
<Spinner {loading}>Confirm</Spinner>
|
||||||
<Icon icon="alt-arrow-right" />
|
<Icon icon={AltArrowRight} />
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import type {Alert} from "@app/state"
|
import type {Alert} from "@app/core/state"
|
||||||
import {NOTIFIER_RELAY} from "@app/state"
|
import {deleteAlert} from "@app/core/commands"
|
||||||
import {publishDelete} from "@app/commands"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {pushToast} from "@app/toast"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
alert: Alert
|
alert: Alert
|
||||||
@@ -12,7 +11,7 @@
|
|||||||
const {alert}: Props = $props()
|
const {alert}: Props = $props()
|
||||||
|
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY]})
|
deleteAlert(alert)
|
||||||
pushToast({message: "Your alert has been deleted!"})
|
pushToast({message: "Your alert has been deleted!"})
|
||||||
history.back()
|
history.back()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {parseJson, nthEq} from "@welshman/lib"
|
import {parseJson} from "@welshman/lib"
|
||||||
import {displayFeeds} from "@welshman/feeds"
|
import {displayFeeds} from "@welshman/feeds"
|
||||||
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
|
import {getTagValue, getTagValues} from "@welshman/util"
|
||||||
|
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import AlertDelete from "@app/components/AlertDelete.svelte"
|
import AlertDelete from "@app/components/AlertDelete.svelte"
|
||||||
import type {Alert} from "@app/state"
|
import AlertStatus from "@app/components/AlertStatus.svelte"
|
||||||
import {alertStatuses} from "@app/state"
|
import type {Alert} from "@app/core/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
alert: Alert
|
alert: Alert
|
||||||
@@ -15,8 +16,6 @@
|
|||||||
|
|
||||||
const {alert}: Props = $props()
|
const {alert}: Props = $props()
|
||||||
|
|
||||||
const address = $derived(getAddress(alert.event))
|
|
||||||
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
|
|
||||||
const cron = $derived(getTagValue("cron", alert.tags))
|
const cron = $derived(getTagValue("cron", alert.tags))
|
||||||
const channel = $derived(getTagValue("channel", alert.tags))
|
const channel = $derived(getTagValue("channel", alert.tags))
|
||||||
const feeds = $derived(getTagValues("feed", alert.tags))
|
const feeds = $derived(getTagValues("feed", alert.tags))
|
||||||
@@ -35,36 +34,9 @@
|
|||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<Button class="py-1" onclick={startDelete}>
|
<Button class="py-1" onclick={startDelete}>
|
||||||
<Icon icon="trash-bin-2" />
|
<Icon icon={TrashBin2} />
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex-inline gap-1">{description}</div>
|
<div class="flex-inline gap-1">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
{#if status}
|
<AlertStatus {alert} />
|
||||||
{@const statusText = getTagValue("status", status.tags) || "error"}
|
|
||||||
{#if statusText === "ok"}
|
|
||||||
<span
|
|
||||||
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
|
|
||||||
data-tip={getTagValue("message", status.tags)}>
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
{:else if statusText === "pending"}
|
|
||||||
<span
|
|
||||||
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
|
|
||||||
data-tip={getTagValue("message", status.tags)}>
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
|
|
||||||
data-tip={getTagValue("message", status.tags)}>
|
|
||||||
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
|
|
||||||
data-tip="The notification server did not respond to your request.">
|
|
||||||
Inactive
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {getAddress, getTagValue} from "@welshman/util"
|
||||||
|
import type {Alert} from "@app/core/state"
|
||||||
|
import {deriveAlertStatus} from "@app/core/state"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
alert: Alert
|
||||||
|
}
|
||||||
|
|
||||||
|
const {alert}: Props = $props()
|
||||||
|
|
||||||
|
const status = deriveAlertStatus(getAddress(alert.event))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $status}
|
||||||
|
{@const statusText = getTagValue("status", $status.tags) || "error"}
|
||||||
|
{#if statusText === "ok"}
|
||||||
|
<span
|
||||||
|
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
|
||||||
|
data-tip={getTagValue("message", $status.tags)}>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
{:else if statusText === "pending"}
|
||||||
|
<span
|
||||||
|
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
|
||||||
|
data-tip={getTagValue("message", $status.tags)}>
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
|
||||||
|
data-tip={getTagValue("message", $status.tags)}>
|
||||||
|
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
|
||||||
|
data-tip="The notification server did not respond to your request.">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
@@ -1,38 +1,155 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {sleep, filter} from "@welshman/lib"
|
||||||
import {pubkey} from "@welshman/app"
|
import {getTagValue, getAddress, RelayMode} from "@welshman/util"
|
||||||
|
import {isRelayFeed, findFeed} from "@welshman/feeds"
|
||||||
|
import {getPubkeyRelays, pubkey} from "@welshman/app"
|
||||||
|
import Inbox from "@assets/icons/inbox.svg?dataurl"
|
||||||
|
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||||
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import AlertAdd from "@app/components/AlertAdd.svelte"
|
import AlertAdd from "@app/components/AlertAdd.svelte"
|
||||||
import AlertItem from "@app/components/AlertItem.svelte"
|
import AlertItem from "@app/components/AlertItem.svelte"
|
||||||
import {loadAlertStatuses, loadAlerts} from "@app/requests"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {alerts} from "@app/state"
|
import {
|
||||||
|
dmAlert,
|
||||||
|
alertsById,
|
||||||
|
deriveAlertStatus,
|
||||||
|
getAlertFeed,
|
||||||
|
userSettingsValues,
|
||||||
|
} from "@app/core/state"
|
||||||
|
import {deleteAlert, createDmAlert} from "@app/core/commands"
|
||||||
|
import {clearBadges} from "../util/notifications"
|
||||||
|
|
||||||
const startAlert = () => pushModal(AlertAdd)
|
type Props = {
|
||||||
|
url?: string
|
||||||
|
channel?: string
|
||||||
|
hideSpaceField?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
|
||||||
loadAlertStatuses($pubkey!)
|
|
||||||
loadAlerts($pubkey!)
|
const dmStatus = $derived($dmAlert ? deriveAlertStatus(getAddress($dmAlert.event)) : undefined)
|
||||||
})
|
|
||||||
|
const filteredAlerts = $derived(
|
||||||
|
filter(alert => {
|
||||||
|
const feed = getAlertFeed(alert)
|
||||||
|
|
||||||
|
// Skip non-feeds and DM alerts
|
||||||
|
if (!feed || alert === $dmAlert) return false
|
||||||
|
|
||||||
|
// If we have a space url, only match feeds for this space
|
||||||
|
if (url) return findFeed(feed, f => isRelayFeed(f) && f.includes(url))
|
||||||
|
|
||||||
|
return true
|
||||||
|
}, $alertsById.values()),
|
||||||
|
)
|
||||||
|
|
||||||
|
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
|
||||||
|
|
||||||
|
const uncheckDmAlert = async (message: string) => {
|
||||||
|
await sleep(100)
|
||||||
|
|
||||||
|
directMessagesNotificationToggle.checked = false
|
||||||
|
pushToast({theme: "error", message})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDirectMessagesNotificationToggle = async () => {
|
||||||
|
if ($dmAlert) {
|
||||||
|
deleteAlert($dmAlert)
|
||||||
|
} else {
|
||||||
|
if (getPubkeyRelays($pubkey!, RelayMode.Messaging).length === 0) {
|
||||||
|
return uncheckDmAlert("Please set up your messaging relays before enabling alerts.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const {error} = await createDmAlert()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return uncheckDmAlert(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast({message: "Your alert has been successfully created!"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onShowBadgeOnUnreadToggle = async () => {
|
||||||
|
$userSettingsValues.show_notifications_badge = !$userSettingsValues.show_notifications_badge
|
||||||
|
|
||||||
|
if (!$userSettingsValues.show_notifications_badge) {
|
||||||
|
await clearBadges()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDirectMessagesNotificationSoundToggle = async () => {
|
||||||
|
$userSettingsValues.play_notification_sound = !$userSettingsValues.play_notification_sound
|
||||||
|
}
|
||||||
|
|
||||||
|
let directMessagesNotificationToggle: HTMLInputElement
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
|
<div class="col-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="card2 bg-alt flex flex-col gap-6 shadow-md">
|
||||||
<strong class="flex items-center gap-3">
|
<div class="flex items-center justify-between">
|
||||||
<Icon icon="inbox" />
|
<strong class="flex items-center gap-3">
|
||||||
Alerts
|
<Icon icon={Inbox} />
|
||||||
</strong>
|
Alerts
|
||||||
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
|
</strong>
|
||||||
<Icon icon="add-circle" />
|
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
|
||||||
Add Alert
|
<Icon icon={AddCircle} />
|
||||||
</Button>
|
Add Alert
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
{#each filteredAlerts as alert (alert.event.id)}
|
||||||
|
<AlertItem {alert} />
|
||||||
|
{:else}
|
||||||
|
<p class="text-center opacity-75 py-12">Nothing here yet!</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
|
||||||
{#each $alerts as alert (alert.event.id)}
|
<div class="flex items-center justify-between">
|
||||||
<AlertItem {alert} />
|
<strong class="flex items-center gap-3">
|
||||||
{:else}
|
<Icon icon={Bell} />
|
||||||
<p class="text-center opacity-75 py-12">No alerts found</p>
|
Notifications
|
||||||
{/each}
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<p>Notify me about new direct messages</p>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-primary"
|
||||||
|
bind:this={directMessagesNotificationToggle}
|
||||||
|
checked={Boolean($dmAlert)}
|
||||||
|
oninput={onDirectMessagesNotificationToggle} />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<p>Show badge for unread direct messages</p>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-primary"
|
||||||
|
checked={Boolean($userSettingsValues.show_notifications_badge)}
|
||||||
|
oninput={onShowBadgeOnUnreadToggle} />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<p>Play sound for new direct messages</p>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-primary"
|
||||||
|
checked={Boolean($userSettingsValues.play_notification_sound)}
|
||||||
|
oninput={onDirectMessagesNotificationSoundToggle} />
|
||||||
|
</div>
|
||||||
|
{#if $dmStatus}
|
||||||
|
{@const status = getTagValue("status", $dmStatus.tags) || "error"}
|
||||||
|
{#if status !== "ok"}
|
||||||
|
<div class="alert alert-error border border-solid border-error bg-transparent text-error">
|
||||||
|
<p>
|
||||||
|
{getTagValue("message", $dmStatus.tags) ||
|
||||||
|
"The notification server did not respond to your request."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
||||||
import EmailConfirm from "@app/components/EmailConfirm.svelte"
|
import EmailConfirm from "@app/components/EmailConfirm.svelte"
|
||||||
import PasswordReset from "@app/components/PasswordReset.svelte"
|
import PasswordReset from "@app/components/PasswordReset.svelte"
|
||||||
import {BURROW_URL} from "@app/state"
|
import {BURROW_URL} from "@app/core/state"
|
||||||
import {modals, pushModal} from "@app/modal"
|
import {modals, pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet
|
children: Snippet
|
||||||
|
|||||||
@@ -1,79 +1,25 @@
|
|||||||
<script module lang="ts">
|
|
||||||
import type {Nip46ResponseWithResult} from "@welshman/signer"
|
|
||||||
import {Nip46Broker, makeSecret} from "@welshman/signer"
|
|
||||||
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
|
|
||||||
|
|
||||||
export class BunkerConnectController {
|
|
||||||
url = $state("")
|
|
||||||
bunker = $state("")
|
|
||||||
loading = $state(false)
|
|
||||||
clientSecret = makeSecret()
|
|
||||||
abortController = new AbortController()
|
|
||||||
broker = new Nip46Broker({clientSecret: this.clientSecret, relays: SIGNER_RELAYS})
|
|
||||||
onNostrConnect: (response: Nip46ResponseWithResult) => void
|
|
||||||
|
|
||||||
constructor({onNostrConnect}: {onNostrConnect: (response: Nip46ResponseWithResult) => void}) {
|
|
||||||
this.onNostrConnect = onNostrConnect
|
|
||||||
}
|
|
||||||
|
|
||||||
async start() {
|
|
||||||
this.url = await this.broker.makeNostrconnectUrl({
|
|
||||||
perms: NIP46_PERMS,
|
|
||||||
url: PLATFORM_URL,
|
|
||||||
name: PLATFORM_NAME,
|
|
||||||
image: PLATFORM_LOGO,
|
|
||||||
})
|
|
||||||
|
|
||||||
let response
|
|
||||||
try {
|
|
||||||
response = await this.broker.waitForNostrconnect(this.url, this.abortController.signal)
|
|
||||||
} catch (errorResponse: any) {
|
|
||||||
if (errorResponse?.error) {
|
|
||||||
pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: `Received error from signer: ${errorResponse.error}`,
|
|
||||||
})
|
|
||||||
} else if (errorResponse) {
|
|
||||||
console.error(errorResponse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
this.loading = true
|
|
||||||
this.onNostrConnect(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.broker.cleanup()
|
|
||||||
this.abortController.abort()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount, onDestroy} from "svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import {slideAndFade} from "@lib/transition"
|
|
||||||
import QRCode from "@app/components/QRCode.svelte"
|
import QRCode from "@app/components/QRCode.svelte"
|
||||||
import {pushToast} from "@app/toast"
|
import type {Nip46Controller} from "@app/util/nip46"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
controller: BunkerConnectController
|
controller: Nip46Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
const {controller}: Props = $props()
|
const {controller}: Props = $props()
|
||||||
|
const {url, loading} = controller
|
||||||
onMount(() => {
|
|
||||||
controller.start()
|
|
||||||
})
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
controller.stop()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if controller.url}
|
{#if $url}
|
||||||
<div class="flex justify-center" out:slideAndFade>
|
{#if $loading}
|
||||||
<QRCode code={controller.url} />
|
<div class="flex justify-center">
|
||||||
</div>
|
<Spinner loading>Establishing connection...</Spinner>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<QRCode code={$url} />
|
||||||
|
<p class="text-sm opacity-75">Scan with your signer to log in, or click to copy.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {pushModal} from "@app/modal"
|
import {debounce} from "throttle-debounce"
|
||||||
import InfoBunker from "@app/components/InfoBunker.svelte"
|
import Scanner from "@lib/components/Scanner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import CpuBolt from "@assets/icons/cpu-bolt.svg?dataurl"
|
||||||
|
import QrCode from "@assets/icons/qr-code.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import InfoBunker from "@app/components/InfoBunker.svelte"
|
||||||
|
import type {Nip46Controller} from "@app/util/nip46"
|
||||||
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bunker: string
|
controller: Nip46Controller
|
||||||
loading: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {loading, bunker = $bindable("")}: Props = $props()
|
const {controller}: Props = $props()
|
||||||
|
const {loading, bunker} = controller
|
||||||
|
|
||||||
|
const toggleScanner = () => {
|
||||||
|
showScanner = !showScanner
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScan = debounce(1000, async (data: string) => {
|
||||||
|
showScanner = false
|
||||||
|
$bunker = data
|
||||||
|
})
|
||||||
|
|
||||||
|
let showScanner = $state(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
@@ -19,8 +35,11 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
<Icon icon="cpu" />
|
<Icon icon={CpuBolt} />
|
||||||
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
|
<input disabled={$loading} bind:value={$bunker} class="grow" placeholder="bunker://" />
|
||||||
|
<Button onclick={toggleScanner}>
|
||||||
|
<Icon icon={QrCode} />
|
||||||
|
</Button>
|
||||||
</label>
|
</label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#snippet info()}
|
||||||
@@ -30,3 +49,6 @@
|
|||||||
</p>
|
</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
|
{#if showScanner}
|
||||||
|
<Scanner onscan={onScan} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,60 +1,64 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
|
import {getTagValue} from "@welshman/util"
|
||||||
import {pubkey} from "@welshman/app"
|
import {pubkey} from "@welshman/app"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||||
import EventActivity from "@app/components/EventActivity.svelte"
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
import EventActions from "@app/components/EventActions.svelte"
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
||||||
import {publishDelete, publishReaction} from "@app/commands"
|
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||||
import {makeCalendarPath} from "@app/routes"
|
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
|
||||||
|
|
||||||
const {
|
type Props = {
|
||||||
url,
|
|
||||||
event,
|
|
||||||
showActivity = false,
|
|
||||||
}: {
|
|
||||||
url: string
|
url: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
|
showRoom?: boolean
|
||||||
showActivity?: boolean
|
showActivity?: boolean
|
||||||
} = $props()
|
}
|
||||||
|
|
||||||
|
const {url, event, showRoom, showActivity}: Props = $props()
|
||||||
|
|
||||||
|
const h = getTagValue("h", event.tags)
|
||||||
const path = makeCalendarPath(url, event.id)
|
const path = makeCalendarPath(url, event.id)
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
|
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
|
||||||
|
|
||||||
const onReactionClick = (content: string, events: TrustedEvent[]) => {
|
const deleteReaction = async (event: TrustedEvent) =>
|
||||||
const reaction = events.find(e => e.pubkey === $pubkey)
|
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||||
|
|
||||||
if (reaction) {
|
const createReaction = async (template: EventContent) =>
|
||||||
publishDelete({relays: [url], event: reaction})
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
} else {
|
|
||||||
publishReaction({event, content, relays: [url]})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
{#if h && showRoom}
|
||||||
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-left" />
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
<ThunkStatusOrDeleted {event} />
|
Posted in #<RoomName {h} {url} />
|
||||||
{#if showActivity}
|
</Link>
|
||||||
<EventActivity {url} {path} {event} />
|
{/if}
|
||||||
{/if}
|
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||||
<EventActions {url} {event} noun="Event">
|
<ThunkStatusOrDeleted {event} />
|
||||||
{#snippet customActions()}
|
{#if showActivity}
|
||||||
{#if event.pubkey === $pubkey}
|
<EventActivity {url} {path} {event} />
|
||||||
<li>
|
{/if}
|
||||||
<Button onclick={editEvent}>
|
<EventActions {url} {event} noun="Event">
|
||||||
<Icon size={4} icon="pen" />
|
{#snippet customActions()}
|
||||||
Edit Event
|
{#if event.pubkey === $pubkey}
|
||||||
</Button>
|
<li>
|
||||||
</li>
|
<Button onclick={editEvent}>
|
||||||
{/if}
|
<Icon size={4} icon={Pen2} />
|
||||||
{/snippet}
|
Edit Event
|
||||||
</EventActions>
|
</Button>
|
||||||
</div>
|
</li>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</EventActions>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
h?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url}: Props = $props()
|
const {url, h}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarEventForm {url}>
|
<CalendarEventForm {url} {h}>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
{#snippet title()}
|
{#snippet title()}
|
||||||
|
|||||||
@@ -8,13 +8,16 @@
|
|||||||
|
|
||||||
const {event}: Props = $props()
|
const {event}: Props = $props()
|
||||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
||||||
const startDate = $derived(secondsToDate(parseInt(meta.start)))
|
const start = $derived(parseInt(meta.start))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
{#if !isNaN(start)}
|
||||||
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
|
{@const startDate = secondsToDate(start)}
|
||||||
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
|
<div
|
||||||
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
|
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
|
||||||
<span class="text-xs opacity-75"
|
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
|
||||||
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
|
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
|
||||||
</div>
|
<span class="text-xs opacity-75"
|
||||||
|
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import {randomId, HOUR} from "@welshman/lib"
|
import {randomId, HOUR} from "@welshman/lib"
|
||||||
import {createEvent, EVENT_TIME} from "@welshman/util"
|
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
||||||
import {publishThunk} from "@welshman/app"
|
import {publishThunk} from "@welshman/app"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import {daysBetween} from "@lib/util"
|
import {daysBetween} from "@lib/util"
|
||||||
|
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||||
|
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
@@ -13,12 +16,14 @@
|
|||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {PROTECTED, GENERAL, tagRoom} from "@app/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
import {canEnforceNip70} from "@app/core/commands"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
h?: string
|
||||||
header: Snippet
|
header: Snippet
|
||||||
initialValues?: {
|
initialValues?: {
|
||||||
d: string
|
d: string
|
||||||
@@ -30,7 +35,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, header, initialValues}: Props = $props()
|
const {url, h, header, initialValues}: Props = $props()
|
||||||
|
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
|
||||||
@@ -63,20 +70,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
const event = createEvent(EVENT_TIME, {
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
content: ed.getText({blockSeparator: "\n"}).trim(),
|
const tags = [
|
||||||
tags: [
|
["d", initialValues?.d || randomId()],
|
||||||
["d", initialValues?.d || randomId()],
|
["title", title],
|
||||||
["title", title],
|
["location", location || ""],
|
||||||
["location", location || ""],
|
["start", start.toString()],
|
||||||
["start", start.toString()],
|
["end", end.toString()],
|
||||||
["end", end.toString()],
|
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
||||||
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
...ed.storage.nostr.getEditorTags(),
|
||||||
...ed.storage.nostr.getEditorTags(),
|
]
|
||||||
tagRoom(GENERAL, url),
|
|
||||||
PROTECTED,
|
if (await shouldProtect) {
|
||||||
],
|
tags.push(PROTECTED)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if (h) {
|
||||||
|
tags.push(["h", h])
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = makeEvent(EVENT_TIME, {content, tags})
|
||||||
|
|
||||||
pushToast({message: "Your event has been saved!"})
|
pushToast({message: "Your event has been saved!"})
|
||||||
publishThunk({event, relays: [url]})
|
publishThunk({event, relays: [url]})
|
||||||
@@ -126,7 +139,7 @@
|
|||||||
{#if $uploading}
|
{#if $uploading}
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="gallery-send" />
|
<Icon icon={GallerySend} />
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,14 +167,14 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
<Icon icon="map-point" />
|
<Icon icon={MapPoint} />
|
||||||
<input bind:value={location} class="grow" type="text" />
|
<input bind:value={location} class="grow" type="text" />
|
||||||
</label>
|
</label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon={AltArrowLeft} />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
|
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
formatTimestampAsTime,
|
formatTimestampAsTime,
|
||||||
} from "@welshman/lib"
|
} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -16,18 +17,20 @@
|
|||||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
||||||
const start = $derived(parseInt(meta.start))
|
const start = $derived(parseInt(meta.start))
|
||||||
const end = $derived(parseInt(meta.end))
|
const end = $derived(parseInt(meta.end))
|
||||||
const startDateDisplay = $derived(formatTimestampAsDate(start))
|
|
||||||
const endDateDisplay = $derived(formatTimestampAsDate(end))
|
|
||||||
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
||||||
<p class="text-xl">{meta.title || meta.name}</p>
|
<p class="text-xl">{meta.title || meta.name}</p>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
{#if !isNaN(start) && !isNaN(end)}
|
||||||
<Icon icon="clock-circle" size={4} />
|
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||||
<span class="sm:hidden">{formatTimestampAsDate(start)}</span>
|
{@const endDateDisplay = formatTimestampAsDate(end)}
|
||||||
{formatTimestampAsTime(start)} — {isSingleDay
|
{@const isSingleDay = startDateDisplay === endDateDisplay}
|
||||||
? formatTimestampAsTime(end)
|
<div class="flex items-center gap-2 text-sm">
|
||||||
: formatTimestamp(end)}
|
<Icon icon={ClockCircle} size={4} />
|
||||||
</div>
|
<span class="hidden sm:block">{formatTimestampAsDate(start)}</span>
|
||||||
|
{formatTimestampAsTime(start)} — {isSingleDay
|
||||||
|
? formatTimestampAsTime(end)
|
||||||
|
: formatTimestamp(end)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getTagValue} from "@welshman/util"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
||||||
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
import {makeCalendarPath} from "@app/routes"
|
import RoomLink from "@app/components/RoomLink.svelte"
|
||||||
|
import {makeCalendarPath} from "@app/util/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
@@ -12,13 +14,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const h = getTagValue("h", event.tags)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}>
|
<Link
|
||||||
|
class="col-3 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||||
|
href={makeCalendarPath(url, event.id)}>
|
||||||
<CalendarEventHeader {event} />
|
<CalendarEventHeader {event} />
|
||||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
|
{#if h}
|
||||||
|
in <RoomLink {url} {h} />
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<CalendarEventActions showActivity {url} {event} />
|
<CalendarEventActions showActivity {url} {event} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {fromPairs} from "@welshman/lib"
|
import {fromPairs} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||||
|
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
|
||||||
@@ -15,12 +17,12 @@
|
|||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
|
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<Icon icon="user-circle" size={4} />
|
<Icon icon={UserCircle} size={4} />
|
||||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
</span>
|
</span>
|
||||||
{#if meta.location}
|
{#if meta.location}
|
||||||
<span class="flex items-start gap-1">
|
<span class="flex items-start gap-1">
|
||||||
<Icon icon="map-point" class="mt-[2px]" size={4} />
|
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
||||||
<span class="break-words">{meta.location}</span>
|
<span class="break-words">{meta.location}</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {writable} from "svelte/store"
|
|
||||||
import type {EventContent} from "@welshman/util"
|
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
|
||||||
import {makeEditor} from "@app/editor"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url?: string
|
|
||||||
onSubmit: (event: EventContent) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const {onSubmit, url}: Props = $props()
|
|
||||||
|
|
||||||
const autofocus = !isMobile
|
|
||||||
|
|
||||||
const uploading = writable(false)
|
|
||||||
|
|
||||||
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
|
||||||
|
|
||||||
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if ($uploading) return
|
|
||||||
|
|
||||||
const ed = await editor
|
|
||||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
|
||||||
const tags = ed.storage.nostr.getEditorTags()
|
|
||||||
|
|
||||||
if (!content) return
|
|
||||||
|
|
||||||
onSubmit({content, tags})
|
|
||||||
|
|
||||||
ed.chain().clearContent().run()
|
|
||||||
}
|
|
||||||
|
|
||||||
const editor = makeEditor({url, autofocus, submit, uploading, aggressive: true})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
|
||||||
<Button
|
|
||||||
data-tip="Add an image"
|
|
||||||
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
|
|
||||||
disabled={$uploading}
|
|
||||||
onclick={uploadFiles}>
|
|
||||||
{#if $uploading}
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
|
||||||
{:else}
|
|
||||||
<Icon icon="gallery-send" />
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
<div class="chat-editor flex-grow overflow-hidden">
|
|
||||||
<EditorContent {editor} />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
|
||||||
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
|
||||||
disabled={$uploading}
|
|
||||||
onclick={submit}>
|
|
||||||
<Icon icon="plain" />
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {thunks, pubkey, deriveProfile, deriveProfileDisplay} from "@welshman/app"
|
|
||||||
import {isMobile} from "@lib/html"
|
|
||||||
import TapTarget from "@lib/components/TapTarget.svelte"
|
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import Content from "@app/components/Content.svelte"
|
|
||||||
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
|
||||||
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
|
|
||||||
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
|
|
||||||
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
|
|
||||||
import {colors} from "@app/state"
|
|
||||||
import {publishDelete, publishReaction} from "@app/commands"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
url: string
|
|
||||||
room: string
|
|
||||||
event: TrustedEvent
|
|
||||||
replyTo?: (event: TrustedEvent) => void
|
|
||||||
showPubkey?: boolean
|
|
||||||
inert?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const {url, room, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
|
|
||||||
|
|
||||||
const thunk = $thunks[event.id]
|
|
||||||
const today = formatTimestampAsDate(now())
|
|
||||||
const profile = deriveProfile(event.pubkey, [url])
|
|
||||||
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
|
|
||||||
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
|
||||||
|
|
||||||
const reply = () => replyTo!(event)
|
|
||||||
|
|
||||||
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
|
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
|
|
||||||
|
|
||||||
const onReactionClick = (content: string, events: TrustedEvent[]) => {
|
|
||||||
const reaction = events.find(e => e.pubkey === $pubkey)
|
|
||||||
|
|
||||||
if (reaction) {
|
|
||||||
publishDelete({relays: [url], event: reaction})
|
|
||||||
} else {
|
|
||||||
publishReaction({event, content, relays: [url]})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TapTarget
|
|
||||||
data-event={event.id}
|
|
||||||
onTap={inert ? null : onTap}
|
|
||||||
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
|
|
||||||
<div class="flex w-full gap-3 overflow-auto">
|
|
||||||
{#if showPubkey}
|
|
||||||
<Button onclick={openProfile} class="flex items-start">
|
|
||||||
<Avatar src={$profile?.picture} class="border border-solid border-base-content" size={8} />
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<div class="w-8 min-w-8 max-w-8"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="min-w-0 flex-grow pr-1">
|
|
||||||
{#if showPubkey}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
|
||||||
{$profileDisplay}
|
|
||||||
</Button>
|
|
||||||
<span class="text-xs opacity-50">
|
|
||||||
{#if formatTimestampAsDate(event.created_at) === today}
|
|
||||||
Today
|
|
||||||
{:else}
|
|
||||||
{formatTimestampAsDate(event.created_at)}
|
|
||||||
{/if}
|
|
||||||
at {formatTimestampAsTime(event.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="text-sm">
|
|
||||||
<Content {event} {url} />
|
|
||||||
{#if thunk}
|
|
||||||
<ThunkStatus {thunk} class="mt-2" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-2 ml-10 mt-1">
|
|
||||||
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right" />
|
|
||||||
</div>
|
|
||||||
{#if !isMobile}
|
|
||||||
<button
|
|
||||||
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
|
||||||
class:group-hover:opacity-100={!isMobile}>
|
|
||||||
<ChannelMessageEmojiButton {url} {room} {event} />
|
|
||||||
{#if replyTo}
|
|
||||||
<Button class="btn join-item btn-xs" onclick={reply}>
|
|
||||||
<Icon icon="reply" size={4} />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
<ChannelMessageMenuButton {url} {event} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</TapTarget>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {noop} from "@welshman/lib"
|
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
|
||||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import {publishReaction} from "@app/commands"
|
|
||||||
|
|
||||||
const {url, room, event} = $props()
|
|
||||||
|
|
||||||
// Tell svelte-check to shut up
|
|
||||||
noop(room)
|
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) =>
|
|
||||||
publishReaction({event, relays: [url], content: emoji.unicode})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<EmojiButton {onEmoji} class="btn join-item btn-xs">
|
|
||||||
<Icon icon="smile-circle" size={4} />
|
|
||||||
</EmojiButton>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {pubkey} from "@welshman/app"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
|
||||||
import EventReport from "@app/components/EventReport.svelte"
|
|
||||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
const {url, event, onClick} = $props()
|
|
||||||
|
|
||||||
const report = () => {
|
|
||||||
onClick()
|
|
||||||
pushModal(EventReport, {url, event})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showInfo = () => {
|
|
||||||
onClick()
|
|
||||||
pushModal(EventInfo, {url, event})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showDelete = () => {
|
|
||||||
onClick()
|
|
||||||
pushModal(EventDeleteConfirm, {url, event})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl">
|
|
||||||
<li>
|
|
||||||
<Button onclick={showInfo}>
|
|
||||||
<Icon size={4} icon="code-2" />
|
|
||||||
Message Details
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{#if event.pubkey === $pubkey}
|
|
||||||
<li>
|
|
||||||
<Button onclick={showDelete} class="text-error">
|
|
||||||
<Icon size={4} icon="trash-bin-2" />
|
|
||||||
Delete Message
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{:else}
|
|
||||||
<li>
|
|
||||||
<Button class="text-error" onclick={report}>
|
|
||||||
<Icon size={4} icon="danger" />
|
|
||||||
Report Content
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {pubkey} from "@welshman/app"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
|
||||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
|
||||||
import {publishReaction} from "@app/commands"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url: string
|
|
||||||
event: TrustedEvent
|
|
||||||
reply: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const {url, event, reply}: Props = $props()
|
|
||||||
|
|
||||||
const onEmoji = ((event: TrustedEvent, url: string, emoji: NativeEmoji) => {
|
|
||||||
history.back()
|
|
||||||
publishReaction({event, relays: [url], content: emoji.unicode})
|
|
||||||
}).bind(undefined, event, url)
|
|
||||||
|
|
||||||
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
|
||||||
|
|
||||||
const sendReply = () => {
|
|
||||||
history.back()
|
|
||||||
reply()
|
|
||||||
}
|
|
||||||
|
|
||||||
const showInfo = () => pushModal(EventInfo, {url, event}, {replaceState: true})
|
|
||||||
|
|
||||||
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="col-2">
|
|
||||||
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
|
|
||||||
<Icon size={4} icon="smile-circle" />
|
|
||||||
Send Reaction
|
|
||||||
</Button>
|
|
||||||
<Button class="btn btn-neutral w-full" onclick={sendReply}>
|
|
||||||
<Icon size={4} icon="reply" />
|
|
||||||
Send Reply
|
|
||||||
</Button>
|
|
||||||
<Button class="btn btn-neutral" onclick={showInfo}>
|
|
||||||
<Icon size={4} icon="code-2" />
|
|
||||||
Message Details
|
|
||||||
</Button>
|
|
||||||
{#if event.pubkey === $pubkey}
|
|
||||||
<Button class="btn btn-neutral text-error" onclick={showDelete}>
|
|
||||||
<Icon size={4} icon="trash-bin-2" />
|
|
||||||
Delete Message
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {GENERAL, channelsById, makeChannelId} from "@app/state"
|
|
||||||
|
|
||||||
const {url, room} = $props()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if room === GENERAL}
|
|
||||||
general
|
|
||||||
{:else}
|
|
||||||
{$channelsById.get(makeChannelId(url, room))?.name || room}
|
|
||||||
{/if}
|
|
||||||
+122
-68
@@ -1,16 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {int, nthNe, MINUTE, sortBy, remove, formatTimestampAsDate} from "@welshman/lib"
|
import {
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
int,
|
||||||
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
|
ms,
|
||||||
|
partition,
|
||||||
|
spec,
|
||||||
|
nthEq,
|
||||||
|
nthNe,
|
||||||
|
MINUTE,
|
||||||
|
sortBy,
|
||||||
|
remove,
|
||||||
|
enumerate,
|
||||||
|
formatTimestampAsDate,
|
||||||
|
} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
|
||||||
|
import {parse, isLink} from "@welshman/content"
|
||||||
|
import {
|
||||||
|
makeEvent,
|
||||||
|
tagsFromIMeta,
|
||||||
|
getTags,
|
||||||
|
DIRECT_MESSAGE,
|
||||||
|
DIRECT_MESSAGE_FILE,
|
||||||
|
} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
pubkey,
|
pubkey,
|
||||||
tagPubkey,
|
tagPubkey,
|
||||||
sendWrapped,
|
sendWrapped,
|
||||||
loadUsingOutbox,
|
mergeThunks,
|
||||||
inboxRelaySelectionsByPubkey,
|
loadMessagingRelayList,
|
||||||
|
messagingRelayListsByPubkey,
|
||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
|
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
@@ -22,34 +43,31 @@
|
|||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import ProfileList from "@app/components/ProfileList.svelte"
|
import ChatMembers from "@app/components/ChatMembers.svelte"
|
||||||
import ChatMessage from "@app/components/ChatMessage.svelte"
|
import ChatMessage from "@app/components/ChatMessage.svelte"
|
||||||
import ChatCompose from "@app/components/ChatCompose.svelte"
|
import ChatCompose from "@app/components/ChatCompose.svelte"
|
||||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||||
import {
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
INDEXER_RELAYS,
|
import {INDEXER_RELAYS, userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state"
|
||||||
userSettingValues,
|
import {pushModal} from "@app/util/modal"
|
||||||
deriveChat,
|
import {prependParent} from "@app/core/commands"
|
||||||
splitChatId,
|
import {pushToast} from "@app/util/toast"
|
||||||
PLATFORM_NAME,
|
|
||||||
} from "@app/state"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
import {prependParent} from "@app/commands"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id: string
|
pubkeys: string[]
|
||||||
info?: Snippet
|
info?: Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
const {id, info}: Props = $props()
|
const {pubkeys, info}: Props = $props()
|
||||||
|
|
||||||
const chat = deriveChat(id)
|
const chat = deriveChat(pubkeys)
|
||||||
const pubkeys = splitChatId(id)
|
|
||||||
const others = remove($pubkey!, pubkeys)
|
const others = remove($pubkey!, pubkeys)
|
||||||
const missingInboxes = $derived(pubkeys.filter(pk => !$inboxRelaySelectionsByPubkey.has(pk)))
|
const missingRelayLists = $derived(pubkeys.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
||||||
|
|
||||||
const showMembers = () =>
|
const showMembers = () =>
|
||||||
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
|
others.length === 1
|
||||||
|
? pushModal(ProfileDetail, {pubkey: others[0]})
|
||||||
|
: pushModal(ChatMembers, {pubkeys: others})
|
||||||
|
|
||||||
const replyTo = (event: TrustedEvent) => {
|
const replyTo = (event: TrustedEvent) => {
|
||||||
parent = event
|
parent = event
|
||||||
@@ -61,13 +79,62 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async (params: EventContent) => {
|
const onSubmit = async (params: EventContent) => {
|
||||||
// Remove p tags since they result in forking the conversation
|
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
|
||||||
const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)]
|
|
||||||
|
|
||||||
await sendWrapped({
|
// Remove p tags since they result in forking the conversation
|
||||||
pubkeys,
|
params.tags = params.tags.filter(nthNe(0, "p"))
|
||||||
template: createEvent(DIRECT_MESSAGE, prependParent(parent, {...params, tags})),
|
|
||||||
delay: $userSettingValues.send_delay,
|
// Add our reply quote to content
|
||||||
|
params = prependParent(parent, params)
|
||||||
|
|
||||||
|
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
|
||||||
|
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
|
||||||
|
const templates: EventTemplate[] = []
|
||||||
|
const buffer = []
|
||||||
|
|
||||||
|
const addTemplate = (kind: number, content: string, tags: string[][]) => {
|
||||||
|
content = content.trim()
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
templates.push(makeEvent(kind, {content, tags: [...tags, ...ptags]}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of parse(params)) {
|
||||||
|
const imeta = isLink(p)
|
||||||
|
? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()])))
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (isLink(p) && imeta) {
|
||||||
|
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||||
|
addTemplate(
|
||||||
|
DIRECT_MESSAGE_FILE,
|
||||||
|
p.value.url.toString(),
|
||||||
|
imeta.slice(1).filter(nthNe(0, "url")),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
buffer.push(p.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||||
|
|
||||||
|
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
|
||||||
|
// Sleep 1 second between each one to make sure timestamps are distinct
|
||||||
|
const thunks = Array.from(enumerate(templates)).map(([i, event]) =>
|
||||||
|
sendWrapped({
|
||||||
|
event,
|
||||||
|
recipients: pubkeys,
|
||||||
|
delay: $userSettingsValues.send_delay + ms(i),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
pushToast({
|
||||||
|
timeout: 30_000,
|
||||||
|
children: {
|
||||||
|
component: ThunkToast,
|
||||||
|
props: {thunk: mergeThunks(thunks)},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
clearParent()
|
clearParent()
|
||||||
@@ -98,7 +165,7 @@
|
|||||||
id,
|
id,
|
||||||
type: "note",
|
type: "note",
|
||||||
value: event,
|
value: event,
|
||||||
showPubkey: created_at - previousCreatedAt > int(15, MINUTE) || previousPubkey !== pubkey,
|
showPubkey: created_at - previousCreatedAt > int(2, MINUTE) || previousPubkey !== pubkey,
|
||||||
})
|
})
|
||||||
|
|
||||||
previousDate = date
|
previousDate = date
|
||||||
@@ -110,13 +177,8 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Don't use loadInboxRelaySelection because we want to force reload
|
|
||||||
for (const pubkey of others) {
|
for (const pubkey of others) {
|
||||||
loadUsingOutbox({
|
loadMessagingRelayList(pubkey, INDEXER_RELAYS, true)
|
||||||
pubkey,
|
|
||||||
kind: INBOX_RELAYS,
|
|
||||||
relays: INDEXER_RELAYS,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
@@ -141,19 +203,17 @@
|
|||||||
|
|
||||||
<PageBar>
|
<PageBar>
|
||||||
{#snippet title()}
|
{#snippet title()}
|
||||||
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
|
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
|
||||||
{#if others.length === 0}
|
{#if others.length === 0}
|
||||||
<div class="row-2">
|
<div class="row-2">
|
||||||
<ProfileCircle pubkey={$pubkey!} size={5} />
|
<ProfileCircle pubkey={$pubkey!} size={5} />
|
||||||
<ProfileName pubkey={$pubkey!} />
|
<ProfileName pubkey={$pubkey!} />
|
||||||
</div>
|
</div>
|
||||||
{:else if others.length === 1}
|
{:else if others.length === 1}
|
||||||
{@const pubkey = others[0]}
|
<div class="row-2">
|
||||||
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
|
<ProfileCircle pubkey={others[0]} size={5} />
|
||||||
<Button onclick={onClick} class="row-2">
|
<ProfileName pubkey={others[0]} />
|
||||||
<ProfileCircle {pubkey} size={5} />
|
</div>
|
||||||
<ProfileName {pubkey} />
|
|
||||||
</Button>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ProfileCircles pubkeys={others} size={5} />
|
<ProfileCircles pubkeys={others} size={5} />
|
||||||
@@ -168,55 +228,49 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if others.length > 2}
|
|
||||||
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
|
|
||||||
>Show all members</Button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
{#snippet action()}
|
||||||
<div>
|
{#if remove($pubkey, missingRelayLists).length > 0}
|
||||||
{#if remove($pubkey, missingInboxes).length > 0}
|
{@const count = remove($pubkey, missingRelayLists).length}
|
||||||
{@const count = remove($pubkey, missingInboxes).length}
|
{@const label = count > 1 ? "lists are" : "list is"}
|
||||||
{@const label = count > 1 ? "inboxes are" : "inbox is"}
|
<div
|
||||||
<div
|
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
|
||||||
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
|
data-tip="{count} messaging {label} not configured.">
|
||||||
data-tip="{count} {label} not configured.">
|
<Icon icon={Danger} />
|
||||||
<Icon icon="danger" />
|
{count}
|
||||||
{count}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageBar>
|
</PageBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col-reverse pt-4">
|
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
||||||
<div bind:this={dynamicPadding}></div>
|
<div bind:this={dynamicPadding}></div>
|
||||||
{#if missingInboxes.includes($pubkey!)}
|
{#if missingRelayLists.includes($pubkey!)}
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||||
<p class="row-2 text-lg text-error">
|
<p class="row-2 text-lg text-error">
|
||||||
<Icon icon="danger" />
|
<Icon icon={Danger} />
|
||||||
Your inbox is not configured.
|
Your messaging relays are not configured.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
|
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
|
||||||
your <Link class="link" href="/settings/relays">relay settings page</Link> to set up your inbox.
|
your <Link class="link" href="/settings/relays">relay settings page</Link> to receive messages.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if missingInboxes.length > 0}
|
{:else if missingRelayLists.length > 0}
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||||
<p class="row-2 text-lg text-error">
|
<p class="row-2 text-lg text-error">
|
||||||
<Icon icon="danger" />
|
<Icon icon={Danger} />
|
||||||
{missingInboxes.length}
|
{missingRelayLists.length} messaging
|
||||||
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
|
{missingRelayLists.length > 1 ? "lists are" : "list is"} not configured.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
|
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
|
||||||
sure everyone in this conversation has set up their inbox relays.
|
sure everyone in this conversation has set up their messaging relays.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,17 +2,18 @@
|
|||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import type {EventContent} from "@welshman/util"
|
import type {EventContent} from "@welshman/util"
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
|
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||||
|
import Plane from "@assets/icons/plane-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url?: string
|
|
||||||
onSubmit: (event: EventContent) => void
|
onSubmit: (event: EventContent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const {onSubmit, url}: Props = $props()
|
const {onSubmit}: Props = $props()
|
||||||
|
|
||||||
const autofocus = !isMobile
|
const autofocus = !isMobile
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@
|
|||||||
|
|
||||||
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
||||||
|
|
||||||
|
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if ($uploading) return
|
if ($uploading) return
|
||||||
|
|
||||||
@@ -35,16 +38,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const editor = makeEditor({
|
const editor = makeEditor({
|
||||||
url,
|
|
||||||
autofocus,
|
autofocus,
|
||||||
submit,
|
submit,
|
||||||
uploading,
|
uploading,
|
||||||
aggressive: true,
|
aggressive: true,
|
||||||
disableFileUpload: true,
|
encryptFiles: true,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
||||||
|
<Button
|
||||||
|
data-tip="Add an image"
|
||||||
|
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
|
||||||
|
disabled={$uploading}
|
||||||
|
onclick={uploadFiles}>
|
||||||
|
{#if $uploading}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Icon icon={GallerySend} />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
<div class="chat-editor flex-grow overflow-hidden">
|
<div class="chat-editor flex-grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {editor} />
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +66,6 @@
|
|||||||
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
||||||
disabled={$uploading}
|
disabled={$uploading}
|
||||||
onclick={submit}>
|
onclick={submit}>
|
||||||
<Icon icon="plain" />
|
<Icon icon={Plane} />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {displayProfileByPubkey} from "@welshman/app"
|
import {displayProfileByPubkey} from "@welshman/app"
|
||||||
import {slide} from "@lib/transition"
|
import {slide} from "@lib/transition"
|
||||||
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import NoteContent from "@app/components/NoteContent.svelte"
|
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
verb,
|
verb,
|
||||||
@@ -18,18 +19,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
|
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8"
|
||||||
transition:slide>
|
transition:slide>
|
||||||
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
|
<p class="text-xs text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
|
||||||
{#key event.id}
|
{#key event.id}
|
||||||
<NoteContent
|
<NoteContentMinimal trimParent {event} />
|
||||||
{event}
|
|
||||||
hideMediaAtDepth={0}
|
|
||||||
minLength={100}
|
|
||||||
maxLength={300}
|
|
||||||
expandMode="disabled" />
|
|
||||||
{/key}
|
{/key}
|
||||||
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
|
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
|
||||||
<Icon icon="close-circle" />
|
<Icon icon={CloseCircle} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {WRAP} from "@welshman/util"
|
|
||||||
import {repository} from "@welshman/app"
|
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
|
import {shouldUnwrap} from "@welshman/app"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import {canDecrypt, PLATFORM_NAME, ensureUnwrapped} from "@app/state"
|
import {PLATFORM_NAME} from "@app/core/state"
|
||||||
import {clearModals} from "@app/modal"
|
import {clearModals} from "@app/util/modal"
|
||||||
|
|
||||||
const {next} = $props()
|
const {next} = $props()
|
||||||
|
|
||||||
@@ -17,22 +18,13 @@
|
|||||||
|
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
|
|
||||||
const enableChat = async () => {
|
|
||||||
canDecrypt.set(true)
|
|
||||||
|
|
||||||
for (const event of repository.query([{kinds: [WRAP]}])) {
|
|
||||||
ensureUnwrapped(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
clearModals()
|
|
||||||
goto(nextUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await enableChat()
|
shouldUnwrap.set(true)
|
||||||
|
clearModals()
|
||||||
|
goto(nextUrl)
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
@@ -60,12 +52,12 @@
|
|||||||
</p>
|
</p>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon={AltArrowLeft} />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||||
<Spinner {loading}>Enable Messages</Spinner>
|
<Spinner {loading}>Enable Messages</Spinner>
|
||||||
<Icon icon="alt-arrow-right" />
|
<Icon icon={AltArrowRight} />
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {remove} from "@welshman/lib"
|
import {remove, formatTimestamp} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {pubkey, loadInboxRelaySelections} from "@welshman/app"
|
import {pubkey, loadMessagingRelayList} from "@welshman/app"
|
||||||
import {fade} from "@lib/transition"
|
import {fade} from "@lib/transition"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import ProfileName from "@app/components/ProfileName.svelte"
|
import ProfileName from "@app/components/ProfileName.svelte"
|
||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
import {makeChatPath} from "@app/routes"
|
import {makeChatPath} from "@app/util/routes"
|
||||||
import {notifications} from "@app/notifications"
|
import {notifications} from "@app/util/notifications"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string
|
id: string
|
||||||
@@ -22,12 +22,12 @@
|
|||||||
const {...props}: Props = $props()
|
const {...props}: Props = $props()
|
||||||
|
|
||||||
const others = remove($pubkey!, props.pubkeys)
|
const others = remove($pubkey!, props.pubkeys)
|
||||||
const active = $page.params.chat === props.id
|
const active = $derived($page.params.chat === props.id)
|
||||||
const path = makeChatPath(props.pubkeys)
|
const path = makeChatPath(props.pubkeys)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
for (const pk of others) {
|
for (const pk of others) {
|
||||||
loadInboxRelaySelections(pk)
|
loadMessagingRelayList(pk)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -59,8 +59,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
<p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||||
|
<span class="opacity-70">
|
||||||
|
{#if props.messages[0].pubkey === $pubkey}
|
||||||
|
You:
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
{props.messages[0].content}
|
{props.messages[0].content}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-xs opacity-70">
|
||||||
|
{formatTimestamp(props.messages[0].created_at)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import Profile from "@app/components/Profile.svelte"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pubkeys: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const {pubkeys}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="column gap-4">
|
||||||
|
<ModalHeader>
|
||||||
|
{#snippet title()}
|
||||||
|
<div>People in this conversation</div>
|
||||||
|
{/snippet}
|
||||||
|
</ModalHeader>
|
||||||
|
{#each pubkeys as pubkey (pubkey)}
|
||||||
|
<div class="card2 bg-alt">
|
||||||
|
<Profile {pubkey} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {RelayMode} from "@welshman/util"
|
||||||
|
import {waitForThunkCompletion, getPubkeyRelays, pubkey} from "@welshman/app"
|
||||||
|
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
|
||||||
|
import Check from "@assets/icons/check.svg?dataurl"
|
||||||
|
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||||
|
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import ChatStart from "@app/components/ChatStart.svelte"
|
||||||
|
import {setChecked} from "@app/util/notifications"
|
||||||
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
import {dmAlert} from "@app/core/state"
|
||||||
|
import {deleteAlert, createDmAlert} from "@app/core/commands"
|
||||||
|
|
||||||
|
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
|
||||||
|
|
||||||
|
const markAsRead = () => {
|
||||||
|
setChecked("/chat/*")
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableAlerts = async () => {
|
||||||
|
if (getPubkeyRelays($pubkey!, RelayMode.Messaging).length === 0) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Please set up your messaging relays before enabling alerts.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
enablingAlert = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {error} = await createDmAlert()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return pushToast({theme: "error", message: error})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
enablingAlert = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableAlerts = async () => {
|
||||||
|
disablingAlert = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForThunkCompletion(deleteAlert($dmAlert!))
|
||||||
|
} finally {
|
||||||
|
disablingAlert = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let enablingAlert = $state(false)
|
||||||
|
let disablingAlert = $state(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="col-2">
|
||||||
|
<Button class="btn btn-primary" onclick={startChat}>
|
||||||
|
<Icon size={5} icon={ChatSquare} />
|
||||||
|
Start chat
|
||||||
|
</Button>
|
||||||
|
<Button class="btn btn-neutral" onclick={markAsRead}>
|
||||||
|
<Icon size={5} icon={Check} />
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
{#if (!enablingAlert && $dmAlert) || disablingAlert}
|
||||||
|
<Button class="btn btn-neutral" onclick={disableAlerts} disabled={disablingAlert}>
|
||||||
|
{#if !disablingAlert}
|
||||||
|
<Icon size={4} icon={BellOff} />
|
||||||
|
{/if}
|
||||||
|
<Spinner loading={disablingAlert}>Disable alerts</Spinner>
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button class="btn btn-neutral" onclick={enableAlerts} disabled={enablingAlert}>
|
||||||
|
{#if !enablingAlert}
|
||||||
|
<Icon size={4} icon={Bell} />
|
||||||
|
{/if}
|
||||||
|
<Spinner loading={enablingAlert}>Enable alerts</Spinner>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import ChatStart from "@app/components/ChatStart.svelte"
|
|
||||||
import {setChecked} from "@app/notifications"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
|
|
||||||
|
|
||||||
const markAsRead = () => {
|
|
||||||
setChecked("/chat/*")
|
|
||||||
history.back()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="col-2">
|
|
||||||
<Button class="btn btn-primary" onclick={startChat}>
|
|
||||||
<Icon size={4} icon="add-circle" />
|
|
||||||
Start chat
|
|
||||||
</Button>
|
|
||||||
<Button class="btn btn-neutral" onclick={markAsRead}>
|
|
||||||
<Icon size={4} icon="check-circle" />
|
|
||||||
Mark all read
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {type Instance} from "tippy.js"
|
import {type Instance} from "tippy.js"
|
||||||
import {hash, formatTimestampAsTime} from "@welshman/lib"
|
import {hash, formatTimestampAsTime} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
|
import {thunks, mergeThunks, pubkey, deriveProfileDisplay, sendWrapped} from "@welshman/app"
|
||||||
import {isMobile} from "@lib/html"
|
import {isMobile} from "@lib/html"
|
||||||
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Tippy from "@lib/components/Tippy.svelte"
|
import Tippy from "@lib/components/Tippy.svelte"
|
||||||
import TapTarget from "@lib/components/TapTarget.svelte"
|
import TapTarget from "@lib/components/TapTarget.svelte"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
import ThunkFailure from "@app/components/ThunkFailure.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
|
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
|
||||||
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
|
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
|
||||||
import {colors} from "@app/state"
|
import {colors} from "@app/core/state"
|
||||||
import {makeDelete, makeReaction} from "@app/commands"
|
import {makeDelete, makeReaction} from "@app/core/commands"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
@@ -28,20 +29,18 @@
|
|||||||
|
|
||||||
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
|
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
|
||||||
|
|
||||||
const thunk = $thunks[event.id]
|
|
||||||
const isOwn = event.pubkey === $pubkey
|
const isOwn = event.pubkey === $pubkey
|
||||||
const profile = deriveProfile(event.pubkey)
|
|
||||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||||
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
|
||||||
|
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||||
|
|
||||||
const reply = () => replyTo(event)
|
const reply = () => replyTo(event)
|
||||||
|
|
||||||
const onReactionClick = async (content: string, events: TrustedEvent[]) => {
|
const deleteReaction = (event: TrustedEvent) =>
|
||||||
const reaction = events.find(e => e.pubkey === $pubkey)
|
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys})
|
||||||
const template = reaction ? makeDelete({event: reaction}) : makeReaction({event, content})
|
|
||||||
|
|
||||||
await sendWrapped({template, pubkeys})
|
const createReaction = (template: EventContent) =>
|
||||||
}
|
sendWrapped({event: makeReaction({event, protect: false, ...template}), recipients: pubkeys})
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if thunk}
|
{#if thunk}
|
||||||
<ThunkStatus {thunk} class="mt-1" />
|
<ThunkFailure showToastOnRetry {thunk} class="mt-1" />
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
data-event={event.id}
|
data-event={event.id}
|
||||||
@@ -88,7 +87,7 @@
|
|||||||
class="opacity-0 transition-all"
|
class="opacity-0 transition-all"
|
||||||
class:group-hover:opacity-100={!isMobile}
|
class:group-hover:opacity-100={!isMobile}
|
||||||
onclick={togglePopover}>
|
onclick={togglePopover}>
|
||||||
<Icon icon="menu-dots" size={4} />
|
<Icon icon={MenuDots} size={4} />
|
||||||
</button>
|
</button>
|
||||||
</Tippy>
|
</Tippy>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -100,8 +99,8 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if !isOwn}
|
{#if !isOwn}
|
||||||
<Button onclick={openProfile} class="flex items-center gap-1">
|
<Button onclick={openProfile} class="flex items-center gap-1">
|
||||||
<Avatar
|
<ProfileCircle
|
||||||
src={$profile?.picture}
|
pubkey={event.pubkey}
|
||||||
class="border border-solid border-base-content"
|
class="border border-solid border-base-content"
|
||||||
size={4} />
|
size={4} />
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -120,7 +119,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</TapTarget>
|
</TapTarget>
|
||||||
<div class="row-2 z-feature -mt-4 ml-4">
|
<div class="row-2 z-feature -mt-4 ml-4">
|
||||||
<ReactionSummary {event} {onReactionClick} noTooltip />
|
<ReactionSummary {event} {deleteReaction} {createReaction} noTooltip />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {sendWrapped} from "@welshman/app"
|
import {sendWrapped} from "@welshman/app"
|
||||||
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||||
import {makeReaction} from "@app/commands"
|
import {makeReaction} from "@app/core/commands"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
@@ -14,9 +15,12 @@
|
|||||||
const {event, pubkeys}: Props = $props()
|
const {event, pubkeys}: Props = $props()
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) =>
|
const onEmoji = (emoji: NativeEmoji) =>
|
||||||
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
|
sendWrapped({
|
||||||
|
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
||||||
|
recipients: pubkeys,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EmojiButton {onEmoji} class="btn join-item btn-xs">
|
<EmojiButton {onEmoji} class="btn join-item btn-xs">
|
||||||
<Icon icon="smile-circle" size={4} />
|
<Icon icon={SmileCircle} size={4} />
|
||||||
</EmojiButton>
|
</EmojiButton>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte"
|
import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte"
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||||
|
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||||
|
|
||||||
const {event, pubkeys, popover, replyTo} = $props()
|
const {event, pubkeys, popover, replyTo} = $props()
|
||||||
|
|
||||||
@@ -19,10 +21,10 @@
|
|||||||
<ChatMessageEmojiButton {event} {pubkeys} />
|
<ChatMessageEmojiButton {event} {pubkeys} />
|
||||||
{#if replyTo}
|
{#if replyTo}
|
||||||
<Button class="btn join-item btn-xs" onclick={reply}>
|
<Button class="btn join-item btn-xs" onclick={reply}>
|
||||||
<Icon size={4} icon="reply" />
|
<Icon size={4} icon={Reply} />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button class="btn join-item btn-xs" onclick={showInfo}>
|
<Button class="btn join-item btn-xs" onclick={showInfo}>
|
||||||
<Icon size={4} icon="code-2" />
|
<Icon size={4} icon={Code2} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,9 +6,13 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import {makeReaction} from "@app/commands"
|
import {makeReaction} from "@app/core/commands"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {clip} from "@app/toast"
|
import {clip} from "@app/util/toast"
|
||||||
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
|
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||||
|
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||||
|
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
pubkeys: string[]
|
pubkeys: string[]
|
||||||
@@ -20,7 +24,10 @@
|
|||||||
|
|
||||||
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
|
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
|
||||||
history.back()
|
history.back()
|
||||||
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
|
sendWrapped({
|
||||||
|
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
||||||
|
recipients: pubkeys,
|
||||||
|
})
|
||||||
}).bind(undefined, event, pubkeys)
|
}).bind(undefined, event, pubkeys)
|
||||||
|
|
||||||
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
||||||
@@ -39,20 +46,20 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
|
<Button class="btn btn-neutral" onclick={showInfo}>
|
||||||
<Icon size={4} icon="smile-circle" />
|
<Icon size={4} icon={Code2} />
|
||||||
Send Reaction
|
Message Info
|
||||||
</Button>
|
|
||||||
<Button class="btn btn-neutral w-full" onclick={sendReply}>
|
|
||||||
<Icon size={4} icon="reply" />
|
|
||||||
Send Reply
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="btn btn-neutral w-full" onclick={copyText}>
|
<Button class="btn btn-neutral w-full" onclick={copyText}>
|
||||||
<Icon size={4} icon="copy" />
|
<Icon size={4} icon={Copy} />
|
||||||
Copy Text
|
Copy Text
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="btn btn-neutral" onclick={showInfo}>
|
<Button class="btn btn-neutral w-full" onclick={sendReply}>
|
||||||
<Icon size={4} icon="code-2" />
|
<Icon size={4} icon={Reply} />
|
||||||
Message Details
|
Send Reply
|
||||||
|
</Button>
|
||||||
|
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
|
||||||
|
<Icon size={4} icon={SmileCircle} />
|
||||||
|
Send Reaction
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,56 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import {writable} from "svelte/store"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
|
import {tryCatch, uniq} from "@welshman/lib"
|
||||||
|
import {fromNostrURI} from "@welshman/util"
|
||||||
import {pubkey} from "@welshman/app"
|
import {pubkey} from "@welshman/app"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
||||||
import {makeChatPath} from "@app/routes"
|
import {makeChatPath} from "@app/util/routes"
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const onSubmit = () => goto(makeChatPath([...pubkeys, $pubkey!]))
|
const onSubmit = () => goto(makeChatPath([...pubkeys, $pubkey!]))
|
||||||
|
|
||||||
|
const addPubkey = (pubkey: string) => {
|
||||||
|
pubkeys = uniq([...pubkeys, pubkey])
|
||||||
|
term.set("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = writable("")
|
||||||
|
|
||||||
let pubkeys: string[] = $state([])
|
let pubkeys: string[] = $state([])
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
return term.subscribe(t => {
|
||||||
|
if (t.match(/^[0-9a-f]{64}$/)) {
|
||||||
|
addPubkey(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.match(/^(nostr:)?(npub1|nprofile1)/)) {
|
||||||
|
tryCatch(() => {
|
||||||
|
const {type, data} = nip19.decode(fromNostrURI(t))
|
||||||
|
|
||||||
|
if (type === "npub") {
|
||||||
|
addPubkey(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "nprofile") {
|
||||||
|
addPubkey(data.pubkey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
|
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
|
||||||
@@ -28,17 +64,17 @@
|
|||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<Field>
|
<Field>
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<ProfileMultiSelect autofocus bind:value={pubkeys} />
|
<ProfileMultiSelect autofocus bind:value={pubkeys} {term} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon={AltArrowLeft} />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={pubkeys.length === 0}>
|
<Button type="submit" class="btn btn-primary" disabled={pubkeys.length === 0}>
|
||||||
Create Chat
|
Create Chat
|
||||||
<Icon icon="alt-arrow-right" />
|
<Icon icon={AltArrowRight} />
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
|
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||||
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
|
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||||
|
import {makeThreadPath} from "@app/util/routes"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: any
|
||||||
|
event: any
|
||||||
|
showActivity?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, showActivity = false}: Props = $props()
|
||||||
|
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
|
const path = makeThreadPath(url, event.id)
|
||||||
|
|
||||||
|
const deleteReaction = async (event: TrustedEvent) =>
|
||||||
|
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||||
|
|
||||||
|
const createReaction = async (template: EventContent) =>
|
||||||
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||||
|
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||||
|
<ThunkStatusOrDeleted {event} />
|
||||||
|
{#if showActivity}
|
||||||
|
<EventActivity {url} {path} {event} />
|
||||||
|
{/if}
|
||||||
|
<EventActions {url} {event} noun="Comment" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||||
|
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||||
|
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
|
||||||
|
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
||||||
|
import GoalCreate from "@app/components/GoalCreate.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
onClick: () => void
|
||||||
|
h?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, h, onClick}: Props = $props()
|
||||||
|
|
||||||
|
const createGoal = () => pushModal(GoalCreate, {url, h})
|
||||||
|
|
||||||
|
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h})
|
||||||
|
|
||||||
|
const createThread = () => pushModal(ThreadCreate, {url, h})
|
||||||
|
|
||||||
|
let ul: Element
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
ul.addEventListener("click", onClick)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md" bind:this={ul}>
|
||||||
|
<li>
|
||||||
|
<Button onclick={createGoal}>
|
||||||
|
<Icon size={4} icon={StarFallMinimalistic} />
|
||||||
|
Create Funding Goal
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button onclick={createCalendarEvent}>
|
||||||
|
<Icon size={4} icon={CalendarMinimalistic} />
|
||||||
|
Create Calendar Event
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button onclick={createThread}>
|
||||||
|
<Icon size={4} icon={NotesMinimalistic} />
|
||||||
|
Create Thread
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
truncate,
|
truncate,
|
||||||
renderAsHtml,
|
renderAsHtml,
|
||||||
isText,
|
isText,
|
||||||
|
isEmoji,
|
||||||
isTopic,
|
isTopic,
|
||||||
isCode,
|
isCode,
|
||||||
isCashu,
|
isCashu,
|
||||||
@@ -17,11 +18,14 @@
|
|||||||
isAddress,
|
isAddress,
|
||||||
isNewline,
|
isNewline,
|
||||||
} from "@welshman/content"
|
} from "@welshman/content"
|
||||||
|
import type {Parsed} from "@welshman/content"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ContentToken from "@app/components/ContentToken.svelte"
|
import ContentToken from "@app/components/ContentToken.svelte"
|
||||||
|
import ContentEmoji from "@app/components/ContentEmoji.svelte"
|
||||||
import ContentCode from "@app/components/ContentCode.svelte"
|
import ContentCode from "@app/components/ContentCode.svelte"
|
||||||
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
|
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
|
||||||
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
|
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
|
||||||
@@ -29,16 +33,15 @@
|
|||||||
import ContentQuote from "@app/components/ContentQuote.svelte"
|
import ContentQuote from "@app/components/ContentQuote.svelte"
|
||||||
import ContentTopic from "@app/components/ContentTopic.svelte"
|
import ContentTopic from "@app/components/ContentTopic.svelte"
|
||||||
import ContentMention from "@app/components/ContentMention.svelte"
|
import ContentMention from "@app/components/ContentMention.svelte"
|
||||||
import {entityLink, userSettingValues} from "@app/state"
|
import {entityLink, userSettingsValues} from "@app/core/state"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: any
|
event: any
|
||||||
minLength?: number
|
minLength?: number
|
||||||
maxLength?: number
|
maxLength?: number
|
||||||
showEntire?: boolean
|
showEntire?: boolean
|
||||||
hideMediaAtDepth?: number
|
|
||||||
expandMode?: string
|
expandMode?: string
|
||||||
depth?: number
|
trimParent?: boolean
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +50,8 @@
|
|||||||
minLength = 500,
|
minLength = 500,
|
||||||
maxLength = 700,
|
maxLength = 700,
|
||||||
showEntire = $bindable(false),
|
showEntire = $bindable(false),
|
||||||
hideMediaAtDepth = 1,
|
|
||||||
expandMode = "block",
|
expandMode = "block",
|
||||||
depth = 0,
|
trimParent = false,
|
||||||
url,
|
url,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
@@ -62,13 +64,13 @@
|
|||||||
const isBlock = (i: number) => {
|
const isBlock = (i: number) => {
|
||||||
const parsed = fullContent[i]
|
const parsed = fullContent[i]
|
||||||
|
|
||||||
if (!parsed || hideMediaAtDepth <= depth) return false
|
if (!parsed) return false
|
||||||
|
|
||||||
if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) {
|
if (isLink(parsed) && $userSettingsValues.show_media && isStartAndEnd(i)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i)) {
|
if (isQuote(parsed) && isStartAndEnd(i)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,25 +92,47 @@
|
|||||||
|
|
||||||
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
|
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
|
||||||
|
|
||||||
const isStartOrEnd = (i: number) => isStart(i) || isEnd(i)
|
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
|
||||||
|
|
||||||
const ignoreWarning = () => {
|
const ignoreWarning = () => {
|
||||||
warning = null
|
warning = null
|
||||||
}
|
}
|
||||||
|
|
||||||
let warning = $state(
|
let warning = $state(
|
||||||
$userSettingValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
|
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
|
||||||
)
|
)
|
||||||
|
|
||||||
const shortContent = $derived(
|
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
|
||||||
showEntire
|
const result: T[] = []
|
||||||
? fullContent
|
|
||||||
: truncate(fullContent, {
|
for (const x of xs) {
|
||||||
minLength,
|
if (result.length === 0 && f(x)) {
|
||||||
maxLength,
|
continue
|
||||||
mediaLength: hideMediaAtDepth <= depth ? 20 : 200,
|
}
|
||||||
}),
|
|
||||||
)
|
result.push(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortContent = $derived.by(() => {
|
||||||
|
let result = fullContent
|
||||||
|
|
||||||
|
if (trimParent && result.length > 0 && isQuote(result[0])) {
|
||||||
|
result = dropWhile(p => isQuote(p) || isNewline(p), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showEntire) {
|
||||||
|
result = truncate(result, {
|
||||||
|
minLength,
|
||||||
|
maxLength,
|
||||||
|
mediaLength: 200,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
const hasEllipsis = $derived(shortContent.some(isEllipsis))
|
const hasEllipsis = $derived(shortContent.some(isEllipsis))
|
||||||
const expandInline = $derived(hasEllipsis && expandMode === "inline")
|
const expandInline = $derived(hasEllipsis && expandMode === "inline")
|
||||||
@@ -118,7 +142,7 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
{#if warning}
|
{#if warning}
|
||||||
<div class="card2 card2-sm bg-alt row-2">
|
<div class="card2 card2-sm bg-alt row-2">
|
||||||
<Icon icon="danger" />
|
<Icon icon={Danger} />
|
||||||
<p>
|
<p>
|
||||||
This note has been flagged by the author as "{warning}".<br />
|
This note has been flagged by the author as "{warning}".<br />
|
||||||
<Button class="link" onclick={ignoreWarning}>Show anyway</Button>
|
<Button class="link" onclick={ignoreWarning}>Show anyway</Button>
|
||||||
@@ -133,6 +157,8 @@
|
|||||||
<ContentNewline value={parsed.value} />
|
<ContentNewline value={parsed.value} />
|
||||||
{:else if isTopic(parsed)}
|
{:else if isTopic(parsed)}
|
||||||
<ContentTopic value={parsed.value} />
|
<ContentTopic value={parsed.value} />
|
||||||
|
{:else if isEmoji(parsed)}
|
||||||
|
<ContentEmoji value={parsed.value} />
|
||||||
{:else if isCode(parsed)}
|
{:else if isCode(parsed)}
|
||||||
<ContentCode
|
<ContentCode
|
||||||
value={parsed.value}
|
value={parsed.value}
|
||||||
@@ -147,9 +173,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else if isProfile(parsed)}
|
{:else if isProfile(parsed)}
|
||||||
<ContentMention value={parsed.value} {url} />
|
<ContentMention value={parsed.value} {url} />
|
||||||
{:else if isEvent(parsed) || isAddress(parsed)}
|
{:else if isQuote(parsed)}
|
||||||
{#if isBlock(i)}
|
{#if isBlock(i)}
|
||||||
<ContentQuote {depth} {url} {hideMediaAtDepth} value={parsed.value} {event} />
|
<ContentQuote {url} value={parsed.value} {event} />
|
||||||
{:else}
|
{:else}
|
||||||
<Link
|
<Link
|
||||||
external
|
external
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {ParsedEmojiValue} from "@welshman/content"
|
||||||
|
|
||||||
|
export let value: ParsedEmojiValue
|
||||||
|
|
||||||
|
const alt = `:${value.name}:`
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if value.url}
|
||||||
|
<img {alt} src={value.url} class="-mt-0.5 inline h-[1em] min-w-[1em] align-middle" />
|
||||||
|
{:else}
|
||||||
|
{alt}
|
||||||
|
{/if}
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {ellipsize, postJson} from "@welshman/lib"
|
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
||||||
import {dufflepud, imgproxy} from "@app/state"
|
import {isRelayUrl} from "@welshman/util"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import {dufflepud, PLATFORM_URL} from "@app/core/state"
|
||||||
|
import {makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
const {value, event} = $props()
|
const {value, event} = $props()
|
||||||
|
|
||||||
let hideImage = $state(false)
|
let hideImage = $state(false)
|
||||||
|
|
||||||
const url = value.url.toString()
|
const url = value.url.toString()
|
||||||
|
const [href, external] = call(() => {
|
||||||
|
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
||||||
|
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||||
|
|
||||||
|
return [url, true]
|
||||||
|
})
|
||||||
|
|
||||||
const loadPreview = async () => {
|
const loadPreview = async () => {
|
||||||
const json = await postJson(dufflepud("link/preview"), {url})
|
const json = await postJson(dufflepud("link/preview"), {url})
|
||||||
@@ -30,8 +38,8 @@
|
|||||||
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link external href={url} class="my-2 block">
|
<Link {external} {href} class="my-2 block">
|
||||||
<div class="overflow-hidden rounded-box leading-[0]">
|
<div class="overflow-hidden rounded-box">
|
||||||
{#if url.match(/\.(mov|webm|mp4)$/)}
|
{#if url.match(/\.(mov|webm|mp4)$/)}
|
||||||
<video controls src={url} class="max-h-96 object-contain object-center">
|
<video controls src={url} class="max-h-96 object-contain object-center">
|
||||||
<track kind="captions" />
|
<track kind="captions" />
|
||||||
@@ -49,18 +57,16 @@
|
|||||||
<div class="bg-alt flex max-w-xl flex-col leading-normal">
|
<div class="bg-alt flex max-w-xl flex-col leading-normal">
|
||||||
{#if preview.image && !hideImage}
|
{#if preview.image && !hideImage}
|
||||||
<img
|
<img
|
||||||
alt="Link preview"
|
alt=""
|
||||||
onerror={onError}
|
onerror={onError}
|
||||||
src={imgproxy(preview.image)}
|
src={preview.image}
|
||||||
class="bg-alt max-h-72 object-contain object-center" />
|
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
|
||||||
{/if}
|
|
||||||
{#if preview.title}
|
|
||||||
<div class="flex flex-col gap-2 p-4">
|
|
||||||
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
|
|
||||||
>{preview.title}</strong>
|
|
||||||
<p>{ellipsize(preview.description, 140)}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="flex flex-col gap-2 p-4">
|
||||||
|
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
>{preview.title || displayUrl(url)}</strong>
|
||||||
|
<p>{ellipsize(preview.description, 140)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:catch}
|
{:catch}
|
||||||
<p class="bg-alt p-12 text-center leading-normal">
|
<p class="bg-alt p-12 text-center leading-normal">
|
||||||
|
|||||||
@@ -1,49 +1,79 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy} from "svelte"
|
import {onMount, onDestroy} from "svelte"
|
||||||
import {now} from "@welshman/lib"
|
import {displayUrl} from "@welshman/lib"
|
||||||
import {BLOSSOM_AUTH, makeEvent, getTags, getTagValue, tagsFromIMeta} from "@welshman/util"
|
import {
|
||||||
|
getTags,
|
||||||
|
getBlob,
|
||||||
|
decryptFile,
|
||||||
|
getTagValue,
|
||||||
|
tagsFromIMeta,
|
||||||
|
makeBlossomAuthEvent,
|
||||||
|
} from "@welshman/util"
|
||||||
import {signer} from "@welshman/app"
|
import {signer} from "@welshman/app"
|
||||||
import {imgproxy} from "@app/state"
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
||||||
const {value, event, ...props} = $props()
|
const {value, event, ...props} = $props()
|
||||||
|
|
||||||
const url = value.url.toString()
|
const url = value.url.toString()
|
||||||
|
const meta =
|
||||||
// If we fail to fetch the image, try authenticating if we have a blossom hash
|
getTags("imeta", event.tags)
|
||||||
const onerror = async () => {
|
|
||||||
const meta = getTags("imeta", event.tags)
|
|
||||||
.map(tagsFromIMeta)
|
.map(tagsFromIMeta)
|
||||||
.find(meta => getTagValue("url", meta) === url)
|
.find(meta => getTagValue("url", meta) === url) || event.tags
|
||||||
const hash = meta ? getTagValue("x", meta) : undefined
|
|
||||||
|
|
||||||
|
// Fallback to filename if hash was omitted from the message for interoperability
|
||||||
|
const hash = getTagValue("x", meta) || url.split(/[\/\.]/).slice(-2)[0]
|
||||||
|
const key = getTagValue("decryption-key", meta)
|
||||||
|
const nonce = getTagValue("decryption-nonce", meta)
|
||||||
|
const algorithm = getTagValue("encryption-algorithm", meta)
|
||||||
|
|
||||||
|
const onError = async () => {
|
||||||
|
// If the image failed to load, try authenticating
|
||||||
if (hash && $signer) {
|
if (hash && $signer) {
|
||||||
const event = await signer.get().sign(
|
const server = new URL(url).origin
|
||||||
makeEvent(BLOSSOM_AUTH, {
|
const template = makeBlossomAuthEvent({action: "get", server, hashes: [hash]})
|
||||||
tags: [
|
const authEvent = await $signer.sign(template)
|
||||||
["t", "get"],
|
const res = await getBlob(server, hash, {authEvent})
|
||||||
["x", hash],
|
|
||||||
["expiration", String(now() + 30)],
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
src = URL.createObjectURL(await res.blob())
|
src = URL.createObjectURL(await res.blob())
|
||||||
|
} else {
|
||||||
|
hasError = true
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
hasError = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let src = $state(imgproxy(url))
|
let hasError = $state(false)
|
||||||
|
let src = $state("")
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// If we have an encryption algorithm, fetch and decrypt
|
||||||
|
if (algorithm === "aes-gcm" && key && nonce) {
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const ciphertext = new Uint8Array(await response.arrayBuffer())
|
||||||
|
const decryptedData = await decryptFile({ciphertext, key, nonce, algorithm})
|
||||||
|
|
||||||
|
src = URL.createObjectURL(new Blob([new Uint8Array(decryptedData)]))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
src = url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
URL.revokeObjectURL(src)
|
URL.revokeObjectURL(src)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img alt="" {src} {onerror} {...props} />
|
{#if hasError}
|
||||||
|
<a href={url} class="link-content whitespace-nowrap">
|
||||||
|
<Icon icon={LinkRound} size={3} class="inline-block" />
|
||||||
|
{displayUrl(url)}
|
||||||
|
</a>
|
||||||
|
{:else if src}
|
||||||
|
<img alt="" {src} onerror={onError} {...props} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayUrl} from "@welshman/lib"
|
import {call, displayUrl} from "@welshman/lib"
|
||||||
|
import {isRelayUrl} from "@welshman/util"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import {PLATFORM_URL} from "@app/core/state"
|
||||||
|
import {makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
const {value} = $props()
|
const {value} = $props()
|
||||||
|
|
||||||
const url = value.url.toString()
|
const url = value.url.toString()
|
||||||
|
const [href, external] = call(() => {
|
||||||
|
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
||||||
|
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||||
|
|
||||||
|
return [url, true]
|
||||||
|
})
|
||||||
|
|
||||||
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
|
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
|
||||||
</script>
|
</script>
|
||||||
@@ -16,12 +26,12 @@
|
|||||||
{#if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
{#if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
||||||
<!-- Use a real link so people can copy the href -->
|
<!-- Use a real link so people can copy the href -->
|
||||||
<a href={url} class="link-content whitespace-nowrap" onclick={preventDefault(expand)}>
|
<a href={url} class="link-content whitespace-nowrap" onclick={preventDefault(expand)}>
|
||||||
<Icon icon="link-round" size={3} class="inline-block" />
|
<Icon icon={LinkRound} size={3} class="inline-block" />
|
||||||
{displayUrl(url)}
|
{displayUrl(url)}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<Link external href={url} class="link-content whitespace-nowrap">
|
<Link {external} {href} class="link-content whitespace-nowrap">
|
||||||
<Icon icon="link-round" size={3} class="inline-block" />
|
<Icon icon={LinkRound} size={3} class="inline-block" />
|
||||||
{displayUrl(url)}
|
{displayUrl(url)}
|
||||||
</Link>
|
</Link>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {removeNil} from "@welshman/lib"
|
import {removeUndefined} from "@welshman/lib"
|
||||||
import type {ProfilePointer} from "@welshman/content"
|
import type {ProfilePointer} from "@welshman/content"
|
||||||
import {displayProfile} from "@welshman/util"
|
import {deriveProfileDisplay} from "@welshman/app"
|
||||||
import {deriveProfile} from "@welshman/app"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: ProfilePointer
|
value: ProfilePointer
|
||||||
@@ -14,11 +13,11 @@
|
|||||||
|
|
||||||
const {value, url}: Props = $props()
|
const {value, url}: Props = $props()
|
||||||
|
|
||||||
const profile = deriveProfile(value.pubkey, removeNil([url]))
|
const display = deriveProfileDisplay(value.pubkey, removeUndefined([url]))
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button onclick={openProfile} class="link-content">
|
<Button onclick={openProfile} class="link-content">
|
||||||
@{displayProfile($profile)}
|
@{$display}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {fromNostrURI} from "@welshman/util"
|
||||||
|
import {nthEq} from "@welshman/lib"
|
||||||
|
import {
|
||||||
|
parse,
|
||||||
|
truncate,
|
||||||
|
renderAsHtml,
|
||||||
|
isText,
|
||||||
|
isEmoji,
|
||||||
|
isTopic,
|
||||||
|
isCode,
|
||||||
|
isCashu,
|
||||||
|
isInvoice,
|
||||||
|
isLink,
|
||||||
|
isProfile,
|
||||||
|
isEvent,
|
||||||
|
isAddress,
|
||||||
|
isNewline,
|
||||||
|
} from "@welshman/content"
|
||||||
|
import type {Parsed} from "@welshman/content"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ContentToken from "@app/components/ContentToken.svelte"
|
||||||
|
import ContentEmoji from "@app/components/ContentEmoji.svelte"
|
||||||
|
import ContentCode from "@app/components/ContentCode.svelte"
|
||||||
|
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
|
||||||
|
import ContentNewline from "@app/components/ContentNewline.svelte"
|
||||||
|
import ContentTopic from "@app/components/ContentTopic.svelte"
|
||||||
|
import ContentMention from "@app/components/ContentMention.svelte"
|
||||||
|
import {entityLink, userSettingsValues} from "@app/core/state"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
event: any
|
||||||
|
trimParent?: boolean
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {event, trimParent = false, url}: Props = $props()
|
||||||
|
|
||||||
|
const fullContent = parse(event)
|
||||||
|
|
||||||
|
const isBoundary = (i: number) => {
|
||||||
|
const parsed = fullContent[i]
|
||||||
|
|
||||||
|
if (!parsed || isNewline(parsed)) return true
|
||||||
|
if (isText(parsed)) return Boolean(parsed.value.match(/^\s+$/))
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStart = (i: number) => isBoundary(i - 1)
|
||||||
|
|
||||||
|
const isEnd = (i: number) => isBoundary(i + 1)
|
||||||
|
|
||||||
|
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
|
||||||
|
|
||||||
|
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
|
||||||
|
|
||||||
|
const ignoreWarning = () => {
|
||||||
|
warning = null
|
||||||
|
}
|
||||||
|
|
||||||
|
let warning = $state(
|
||||||
|
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
|
||||||
|
const result: T[] = []
|
||||||
|
|
||||||
|
for (const x of xs) {
|
||||||
|
if (result.length === 0 && f(x)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortContent = $derived.by(() => {
|
||||||
|
let result = fullContent
|
||||||
|
|
||||||
|
if (trimParent && result.length > 0 && isQuote(result[0])) {
|
||||||
|
result = dropWhile(p => isQuote(p) || isNewline(p), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return truncate(result, {minLength: 200, maxLength: 300, mediaLength: 20})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
{#if warning}
|
||||||
|
<div class="card2 card2-sm bg-alt row-2">
|
||||||
|
<Icon icon={Danger} />
|
||||||
|
<p>
|
||||||
|
This note has been flagged by the author as "{warning}".<br />
|
||||||
|
<Button class="link" onclick={ignoreWarning}>Show anyway</Button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-hidden text-ellipsis break-words">
|
||||||
|
{#each shortContent as parsed, i}
|
||||||
|
{#if isNewline(parsed)}
|
||||||
|
<ContentNewline value={parsed.value} />
|
||||||
|
{:else if isTopic(parsed)}
|
||||||
|
<ContentTopic value={parsed.value} />
|
||||||
|
{:else if isEmoji(parsed)}
|
||||||
|
<ContentEmoji value={parsed.value} />
|
||||||
|
{:else if isCode(parsed)}
|
||||||
|
<ContentCode
|
||||||
|
value={parsed.value}
|
||||||
|
isBlock={isStartAndEnd(i) || parsed.value.includes("\n")} />
|
||||||
|
{:else if isCashu(parsed) || isInvoice(parsed)}
|
||||||
|
<ContentToken value={parsed.value} />
|
||||||
|
{:else if isLink(parsed)}
|
||||||
|
<ContentLinkInline value={parsed.value} />
|
||||||
|
{:else if isProfile(parsed)}
|
||||||
|
<ContentMention value={parsed.value} {url} />
|
||||||
|
{:else if isQuote(parsed)}
|
||||||
|
<Link
|
||||||
|
external
|
||||||
|
class="overflow-hidden text-ellipsis whitespace-nowrap underline"
|
||||||
|
href={entityLink(parsed.raw)}>
|
||||||
|
{fromNostrURI(parsed.raw).slice(0, 16) + "…"}
|
||||||
|
</Link>
|
||||||
|
{:else}
|
||||||
|
{@html renderAsHtml(parsed)}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,28 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {goto} from "$app/navigation"
|
|
||||||
import {nthEq} from "@welshman/lib"
|
|
||||||
import {Router} from "@welshman/router"
|
import {Router} from "@welshman/router"
|
||||||
import {tracker, repository} from "@welshman/app"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD, EVENT_TIME} from "@welshman/util"
|
import {Address, MESSAGE} from "@welshman/util"
|
||||||
import {scrollToEvent} from "@lib/html"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
import NoteContent from "@app/components/NoteContent.svelte"
|
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||||
import {deriveEvent, entityLink, ROOM} from "@app/state"
|
import {deriveEvent, entityLink} from "@app/core/state"
|
||||||
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
|
import {goToEvent} from "@app/util/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: any
|
value: any
|
||||||
hideMediaAtDepth: number
|
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
depth: number
|
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const {value, event, depth, hideMediaAtDepth, url}: Props = $props()
|
const {value, event, url}: Props = $props()
|
||||||
|
|
||||||
const {id, identifier, kind, pubkey, relays = []} = value
|
const {id, identifier, kind, pubkey, relays = []} = value
|
||||||
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
|
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
|
||||||
@@ -37,67 +31,28 @@
|
|||||||
? nip19.neventEncode({id, relays: mergedRelays})
|
? nip19.neventEncode({id, relays: mergedRelays})
|
||||||
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
|
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
|
||||||
|
|
||||||
const openMessage = (url: string, room: string, id: string) => {
|
|
||||||
const event = repository.getEvent(id)
|
|
||||||
|
|
||||||
if (event) {
|
|
||||||
goto(makeRoomPath(url, room))
|
|
||||||
scrollToEvent(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Boolean(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onclick = () => {
|
const onclick = () => {
|
||||||
if ($quote) {
|
if ($quote) {
|
||||||
if ($quote.kind === DIRECT_MESSAGE) {
|
goToEvent($quote)
|
||||||
return scrollToEvent($quote.id)
|
} else {
|
||||||
}
|
window.open(entityLink(entity))
|
||||||
|
|
||||||
const [url] = tracker.getRelays($quote.id)
|
|
||||||
const room = $quote.tags.find(nthEq(0, ROOM))?.[1]
|
|
||||||
|
|
||||||
if (url && room) {
|
|
||||||
if ($quote.kind === THREAD) {
|
|
||||||
return goto(makeThreadPath(url, $quote.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($quote.kind === EVENT_TIME) {
|
|
||||||
return goto(makeCalendarPath(url, $quote.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($quote.kind === MESSAGE) {
|
|
||||||
return scrollToEvent($quote.id) || openMessage(url, room, $quote.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const kind = $quote.tags.find(nthEq(0, "K"))?.[1]
|
|
||||||
const id = $quote.tags.find(nthEq(0, "E"))?.[1]
|
|
||||||
|
|
||||||
if (id && kind) {
|
|
||||||
if (parseInt(kind) === THREAD) {
|
|
||||||
return goto(makeThreadPath(url, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parseInt(kind) === EVENT_TIME) {
|
|
||||||
return goto(makeCalendarPath(url, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parseInt(kind) === MESSAGE) {
|
|
||||||
return scrollToEvent(id) || openMessage(url, room, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(entityLink(entity))
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button class="my-2 block max-w-full text-left" {onclick}>
|
<Button class="my-2 block w-full max-w-full text-left" {onclick}>
|
||||||
{#if $quote}
|
{#if $quote}
|
||||||
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
|
{#if $quote.kind === MESSAGE}
|
||||||
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
|
<div
|
||||||
</NoteCard>
|
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
||||||
|
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
|
||||||
|
<NoteContentMinimal trimParent {url} event={$quote} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
|
||||||
|
<NoteContentMinimal {url} event={$quote} />
|
||||||
|
</NoteCard>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="rounded-box p-4">
|
<div class="rounded-box p-4">
|
||||||
<Spinner loading>Loading event...</Spinner>
|
<Spinner loading>Loading event...</Spinner>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import {clip} from "@app/toast"
|
import {clip} from "@app/util/toast"
|
||||||
|
|
||||||
const {value} = $props()
|
const {value} = $props()
|
||||||
|
|
||||||
@@ -9,6 +10,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button onclick={copy} class="link-content">
|
<Button onclick={copy} class="link-content">
|
||||||
<Icon icon="bolt" size={3} class="inline-block translate-y-px" />
|
<Icon icon={Bolt} size={3} class="inline-block translate-y-px" />
|
||||||
{value.slice(0, 16)}...
|
{value.slice(0, 16)}...
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {formatTimestamp} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||||
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
|
import {goToEvent} from "@app/util/routes"
|
||||||
|
import {displayRoom} from "@app/core/state"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
h?: string
|
||||||
|
events: TrustedEvent[]
|
||||||
|
latest: TrustedEvent
|
||||||
|
earliest: TrustedEvent
|
||||||
|
participants: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, h, events, latest, earliest, participants}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button class="card2 bg-alt shadow-lg" onclick={() => goToEvent(earliest)}>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<ProfileCircle pubkey={earliest.pubkey} size={10} />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70">
|
||||||
|
{#if h}
|
||||||
|
<span class="truncate font-medium text-blue-400">
|
||||||
|
#{displayRoom(url, h)}
|
||||||
|
</span>
|
||||||
|
<span class="opacity-50">•</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-nowrap">{formatTimestamp(earliest.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<NoteContentMinimal event={earliest} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-13 flex items-center justify-between">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
<span class="text-sm opacity-70">
|
||||||
|
{events.length}
|
||||||
|
{events.length === 1 ? "message" : "messages"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ProfileCircles pubkeys={participants} size={6} />
|
||||||
|
<span class="text-sm opacity-70">
|
||||||
|
{participants.length}
|
||||||
|
{participants.length === 1 ? "participant" : "participants"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if latest !== earliest}
|
||||||
|
<Button class="card2 bg-alt" onclick={() => goToEvent(latest)}>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70">
|
||||||
|
<ProfileCircle pubkey={latest.pubkey} size={5} />
|
||||||
|
<span class="font-medium">Latest reply:</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs opacity-50">
|
||||||
|
{formatTimestamp(latest.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<NoteContentMinimal event={latest} />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import LogInPassword from "@app/components/LogInPassword.svelte"
|
import LogInPassword from "@app/components/LogInPassword.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
import {BURROW_URL} from "@app/state"
|
import {BURROW_URL} from "@app/core/state"
|
||||||
|
|
||||||
const {email, confirm_token} = $props()
|
const {email, confirm_token} = $props()
|
||||||
|
|
||||||
|
|||||||
@@ -3,35 +3,53 @@
|
|||||||
import type {Instance} from "tippy.js"
|
import type {Instance} from "tippy.js"
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Tippy from "@lib/components/Tippy.svelte"
|
import Tippy from "@lib/components/Tippy.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ZapButton from "@app/components/ZapButton.svelte"
|
||||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||||
import EventMenu from "@app/components/EventMenu.svelte"
|
import EventMenu from "@app/components/EventMenu.svelte"
|
||||||
import {publishReaction} from "@app/commands"
|
import {ENABLE_ZAPS} from "@app/core/state"
|
||||||
|
import {publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
noun: string
|
noun: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
|
hideZap?: boolean
|
||||||
customActions?: Snippet
|
customActions?: Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, noun, event, customActions}: Props = $props()
|
const {url, noun, event, hideZap, customActions}: Props = $props()
|
||||||
|
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const showPopover = () => popover?.show()
|
const showPopover = () => popover?.show()
|
||||||
|
|
||||||
const hidePopover = () => popover?.hide()
|
const hidePopover = () => popover?.hide()
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) =>
|
const onEmoji = async (emoji: NativeEmoji) =>
|
||||||
publishReaction({event, content: emoji.unicode, relays: [url]})
|
publishReaction({
|
||||||
|
event,
|
||||||
|
content: emoji.unicode,
|
||||||
|
relays: [url],
|
||||||
|
protect: await shouldProtect,
|
||||||
|
})
|
||||||
|
|
||||||
let popover: Instance | undefined = $state()
|
let popover: Instance | undefined = $state()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button class="join rounded-full">
|
<Button class="join rounded-full">
|
||||||
|
{#if ENABLE_ZAPS && !hideZap}
|
||||||
|
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
|
||||||
|
<Icon icon={Bolt} size={4} />
|
||||||
|
</ZapButton>
|
||||||
|
{/if}
|
||||||
<EmojiButton {onEmoji} class="btn join-item btn-neutral btn-xs">
|
<EmojiButton {onEmoji} class="btn join-item btn-neutral btn-xs">
|
||||||
<Icon icon="smile-circle" size={4} />
|
<Icon icon={SmileCircle} size={4} />
|
||||||
</EmojiButton>
|
</EmojiButton>
|
||||||
<Tippy
|
<Tippy
|
||||||
bind:popover
|
bind:popover
|
||||||
@@ -39,7 +57,7 @@
|
|||||||
props={{url, noun, event, customActions, onClick: hidePopover}}
|
props={{url, noun, event, customActions, onClick: hidePopover}}
|
||||||
params={{trigger: "manual", interactive: true}}>
|
params={{trigger: "manual", interactive: true}}>
|
||||||
<Button class="btn join-item btn-neutral btn-xs" onclick={showPopover}>
|
<Button class="btn join-item btn-neutral btn-xs" onclick={showPopover}>
|
||||||
<Icon icon="menu-dots" size={4} />
|
<Icon icon={MenuDots} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tippy>
|
</Tippy>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,16 +3,17 @@
|
|||||||
import {max, formatTimestampRelative} from "@welshman/lib"
|
import {max, formatTimestampRelative} from "@welshman/lib"
|
||||||
import {COMMENT} from "@welshman/util"
|
import {COMMENT} from "@welshman/util"
|
||||||
import {load} from "@welshman/net"
|
import {load} from "@welshman/net"
|
||||||
import {deriveEvents} from "@welshman/store"
|
import {deriveArray, deriveEventsById} from "@welshman/store"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {repository} from "@welshman/app"
|
import {repository} from "@welshman/app"
|
||||||
import {notifications} from "@app/notifications"
|
import {notifications} from "@app/util/notifications"
|
||||||
|
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
||||||
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props()
|
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props()
|
||||||
|
|
||||||
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
|
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
|
||||||
const replies = deriveEvents(repository, {filters})
|
const replies = deriveArray(deriveEventsById({repository, filters}))
|
||||||
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full">
|
<div class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full">
|
||||||
<Icon icon="reply" />
|
<Icon icon={Reply} />
|
||||||
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
|
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
|
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import {publishDelete} from "@app/commands"
|
import {publishDelete, canEnforceNip70} from "@app/core/commands"
|
||||||
import {clearModals} from "@app/modal"
|
import {clearModals} from "@app/util/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
@@ -11,8 +11,10 @@
|
|||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
await publishDelete({event, relays: [url]})
|
await publishDelete({event, relays: [url], protect: await shouldProtect})
|
||||||
|
|
||||||
clearModals()
|
clearModals()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {Router} from "@welshman/router"
|
import {Router} from "@welshman/router"
|
||||||
|
import {LOCALE, secondsToDate} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
|
import {tracker} from "@welshman/app"
|
||||||
|
import FileText from "@assets/icons/file-text.svg?dataurl"
|
||||||
|
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||||
|
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import {clip} from "@app/toast"
|
import {clip} from "@app/util/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url?: string
|
url?: string
|
||||||
@@ -17,11 +23,17 @@
|
|||||||
|
|
||||||
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
||||||
const nevent1 = nip19.neventEncode({...event, relays})
|
const nevent1 = nip19.neventEncode({...event, relays})
|
||||||
|
const seenOn = tracker.getRelays(event.id)
|
||||||
const npub1 = nip19.npubEncode(event.pubkey)
|
const npub1 = nip19.npubEncode(event.pubkey)
|
||||||
const json = JSON.stringify(event, null, 2)
|
const json = JSON.stringify(event, null, 2)
|
||||||
const copyLink = () => clip(nevent1)
|
const copyLink = () => clip(nevent1)
|
||||||
const copyPubkey = () => clip(npub1)
|
const copyPubkey = () => clip(npub1)
|
||||||
const copyJson = () => clip(json)
|
const copyJson = () => clip(json)
|
||||||
|
|
||||||
|
const formatter = new Intl.DateTimeFormat(LOCALE, {
|
||||||
|
dateStyle: "long",
|
||||||
|
timeStyle: "long",
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="column gap-4">
|
<div class="column gap-4">
|
||||||
@@ -33,16 +45,24 @@
|
|||||||
<div>The full details of this event are shown below.</div>
|
<div>The full details of this event are shown below.</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Created At</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<p>{formatter.format(secondsToDate(event.created_at))}</p>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
<p>Event Link</p>
|
<p>Event Link</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
<Icon icon="file" />
|
<Icon icon={FileText} />
|
||||||
<input type="text" class="ellipsize min-w-0 grow" value={nevent1} />
|
<input type="text" class="ellipsize min-w-0 grow" value={nevent1} />
|
||||||
<Button onclick={copyLink} class="flex items-center">
|
<Button onclick={copyLink} class="flex items-center">
|
||||||
<Icon icon="copy" />
|
<Icon icon={Copy} />
|
||||||
</Button>
|
</Button>
|
||||||
</label>
|
</label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -53,19 +73,35 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
<Icon icon="user-circle" />
|
<Icon icon={UserCircle} />
|
||||||
<input type="text" class="ellipsize min-w-0 grow" value={npub1} />
|
<input type="text" class="ellipsize min-w-0 grow" value={npub1} />
|
||||||
<Button onclick={copyPubkey} class="flex items-center">
|
<Button onclick={copyPubkey} class="flex items-center">
|
||||||
<Icon icon="copy" />
|
<Icon icon={Copy} />
|
||||||
</Button>
|
</Button>
|
||||||
</label>
|
</label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
|
{#if !url && seenOn.size > 0}
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Seen On</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each seenOn as url, i (url)}
|
||||||
|
<span class="bg-alt badge flex gap-1">
|
||||||
|
{displayRelayUrl(url)}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{/if}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
|
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
|
||||||
<p class="absolute right-2 top-2 flex flex-grow items-center justify-between">
|
<p class="absolute right-2 top-2 flex flex-grow items-center justify-between">
|
||||||
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
|
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
|
||||||
<Icon icon="copy" /> Copy
|
<Icon icon={Copy} /> Copy
|
||||||
</Button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {COMMENT} from "@welshman/util"
|
import {COMMENT, ManagementMethod} from "@welshman/util"
|
||||||
import {pubkey} from "@welshman/app"
|
import {pubkey, repository, relaysByUrl, manageRelay} from "@welshman/app"
|
||||||
|
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||||
|
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||||
|
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||||
|
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||||
|
import {setKey} from "@lib/implicit"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import EventReport from "@app/components/EventReport.svelte"
|
import Report from "@app/components/Report.svelte"
|
||||||
import EventShare from "@app/components/EventShare.svelte"
|
import EventShare from "@app/components/EventShare.svelte"
|
||||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {hasNip29, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||||
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
import {makeSpaceChatPath} from "@app/util/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
@@ -23,15 +33,43 @@
|
|||||||
const {url, noun, event, onClick, customActions}: Props = $props()
|
const {url, noun, event, onClick, customActions}: Props = $props()
|
||||||
|
|
||||||
const isRoot = event.kind !== COMMENT
|
const isRoot = event.kind !== COMMENT
|
||||||
|
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||||
|
|
||||||
const report = () => pushModal(EventReport, {url, event})
|
const report = () => pushModal(Report, {url, event})
|
||||||
|
|
||||||
const showInfo = () => pushModal(EventInfo, {url, event})
|
const showInfo = () => pushModal(EventInfo, {url, event})
|
||||||
|
|
||||||
const share = () => pushModal(EventShare, {url, event})
|
const share = async () => {
|
||||||
|
if (hasNip29($relaysByUrl.get(url))) {
|
||||||
|
pushModal(EventShare, {url, event})
|
||||||
|
} else {
|
||||||
|
setKey("share", event)
|
||||||
|
goto(makeSpaceChatPath(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
||||||
|
|
||||||
|
const showAdminDelete = () =>
|
||||||
|
pushModal(Confirm, {
|
||||||
|
title: `Delete ${noun}`,
|
||||||
|
message: `Are you sure you want to delete this ${noun.toLowerCase()} from the space?`,
|
||||||
|
confirm: async () => {
|
||||||
|
const {error} = await manageRelay(url, {
|
||||||
|
method: ManagementMethod.BanEvent,
|
||||||
|
params: [event.id],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
pushToast({theme: "error", message: error})
|
||||||
|
} else {
|
||||||
|
pushToast({message: "Event has successfully been deleted!"})
|
||||||
|
repository.removeEvent(event.id)
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
let ul: Element
|
let ul: Element
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -39,18 +77,18 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl" bind:this={ul}>
|
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md" bind:this={ul}>
|
||||||
{#if isRoot}
|
{#if isRoot}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={share}>
|
<Button onclick={share}>
|
||||||
<Icon size={4} icon="share-circle" />
|
<Icon size={4} icon={ShareCircle} />
|
||||||
Share to Chat
|
Share to Chat
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={showInfo}>
|
<Button onclick={showInfo}>
|
||||||
<Icon size={4} icon="code-2" />
|
<Icon size={4} icon={Code2} />
|
||||||
{noun} Details
|
{noun} Details
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
@@ -58,16 +96,24 @@
|
|||||||
{#if event.pubkey === $pubkey}
|
{#if event.pubkey === $pubkey}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={showDelete} class="text-error">
|
<Button onclick={showDelete} class="text-error">
|
||||||
<Icon size={4} icon="trash-bin-2" />
|
<Icon size={4} icon={TrashBin2} />
|
||||||
Delete {noun}
|
Delete {noun}
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li>
|
<li>
|
||||||
<Button class="text-error" onclick={report}>
|
<Button class="text-error" onclick={report}>
|
||||||
<Icon size={4} icon="danger" />
|
<Icon size={4} icon={Danger} />
|
||||||
Report Content
|
Report Content
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
{#if $userIsAdmin}
|
||||||
|
<li>
|
||||||
|
<Button class="text-error" onclick={showAdminDelete}>
|
||||||
|
<Icon size={4} icon={TrashBin2} />
|
||||||
|
Delete {noun}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -3,17 +3,20 @@
|
|||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
|
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {publishComment} from "@app/commands"
|
import {publishComment, canEnforceNip70} from "@app/core/commands"
|
||||||
import {tagRoom, GENERAL, PROTECTED} from "@app/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
const {url, event, onClose, onSubmit} = $props()
|
const {url, event, onClose, onSubmit} = $props()
|
||||||
|
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
|
||||||
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
||||||
@@ -23,7 +26,11 @@
|
|||||||
|
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
const tags = [...ed.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
|
const tags = ed.storage.nostr.getEditorTags()
|
||||||
|
|
||||||
|
if (await shouldProtect) {
|
||||||
|
tags.push(PROTECTED)
|
||||||
|
}
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
@@ -75,7 +82,7 @@
|
|||||||
{#if $uploading}
|
{#if $uploading}
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="paperclip" size={3} />
|
<Icon icon={Paperclip} size={3} />
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {getTag, REPORT} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {deriveEvents} from "@welshman/store"
|
|
||||||
import {pubkey, repository} from "@welshman/app"
|
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import Profile from "@app/components/Profile.svelte"
|
|
||||||
import {publishDelete} from "@app/commands"
|
|
||||||
|
|
||||||
const {url, event} = $props()
|
|
||||||
|
|
||||||
const reports = deriveEvents(repository, {
|
|
||||||
filters: [{kinds: [REPORT], "#e": [event.id]}],
|
|
||||||
})
|
|
||||||
|
|
||||||
const back = () => history.back()
|
|
||||||
|
|
||||||
const deleteReport = (report: TrustedEvent) => {
|
|
||||||
publishDelete({event: report, relays: [url]})
|
|
||||||
|
|
||||||
if ($reports.length === 0) {
|
|
||||||
history.back()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getReason = (tags: string[][]) => getTag("e", tags)?.[2] || "other"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="column gap-4">
|
|
||||||
<ModalHeader>
|
|
||||||
{#snippet title()}
|
|
||||||
<div>Report Details</div>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet info()}
|
|
||||||
<div>All reports for this event are shown below.</div>
|
|
||||||
{/snippet}
|
|
||||||
</ModalHeader>
|
|
||||||
{#each $reports as report (report.id)}
|
|
||||||
{@const reason = getReason(report.tags)}
|
|
||||||
{@const remove = () => deleteReport(report)}
|
|
||||||
<div class="column gap-2">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<div>
|
|
||||||
<Profile pubkey={report.pubkey} {url} />
|
|
||||||
<span>Reported this event as "{reason}"</span>
|
|
||||||
</div>
|
|
||||||
{#if report.pubkey === $pubkey}
|
|
||||||
<Button class="btn-default btn" onclick={remove}>Delete Report</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if report.content}
|
|
||||||
<p>"{report.content}"</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<Button class="btn btn-primary" onclick={back}>Got it</Button>
|
|
||||||
</div>
|
|
||||||
@@ -2,14 +2,16 @@
|
|||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||||
|
import {setKey} from "@lib/implicit"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import ChannelName from "@app/components/ChannelName.svelte"
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import {channelsByUrl} from "@app/state"
|
import {roomsByUrl} from "@app/core/state"
|
||||||
import {makeRoomPath} from "@app/routes"
|
import {makeRoomPath} from "@app/util/routes"
|
||||||
import {setKey} from "@app/implicit"
|
|
||||||
|
|
||||||
const {url, noun, event}: {url: string; noun: string; event: TrustedEvent} = $props()
|
const {url, noun, event}: {url: string; noun: string; event: TrustedEvent} = $props()
|
||||||
|
|
||||||
@@ -20,8 +22,8 @@
|
|||||||
goto(makeRoomPath(url, selection), {replaceState: true})
|
goto(makeRoomPath(url, selection), {replaceState: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleRoom = (room: string) => {
|
const toggleRoom = (h: string) => {
|
||||||
selection = room === selection ? "" : room
|
selection = h === selection ? "" : h
|
||||||
}
|
}
|
||||||
|
|
||||||
let selection = $state("")
|
let selection = $state("")
|
||||||
@@ -37,25 +39,25 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="grid grid-cols-3 gap-2">
|
||||||
{#each $channelsByUrl.get(url) || [] as channel (channel.room)}
|
{#each $roomsByUrl.get(url) || [] as room (room.h)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn"
|
class="btn"
|
||||||
class:btn-neutral={selection !== channel.room}
|
class:btn-neutral={selection !== room.h}
|
||||||
class:btn-primary={selection === channel.room}
|
class:btn-primary={selection === room.h}
|
||||||
onclick={() => toggleRoom(channel.room)}>
|
onclick={() => toggleRoom(room.h)}>
|
||||||
#<ChannelName {...channel} />
|
#<RoomName {...room} />
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon={AltArrowLeft} />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={!selection}>
|
<Button type="submit" class="btn btn-primary" disabled={!selection}>
|
||||||
Share {noun}
|
Share {noun}
|
||||||
<Icon icon="alt-arrow-right" />
|
<Icon icon={AltArrowRight} />
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
|
import {getTagValue} from "@welshman/util"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
|
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||||
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
|
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||||
|
import {makeGoalPath, makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
showRoom?: boolean
|
||||||
|
showActivity?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, showRoom, showActivity}: Props = $props()
|
||||||
|
|
||||||
|
const path = makeGoalPath(url, event.id)
|
||||||
|
const h = getTagValue("h", event.tags)
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
|
const deleteReaction = async (event: TrustedEvent) =>
|
||||||
|
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||||
|
|
||||||
|
const createReaction = async (template: EventContent) =>
|
||||||
|
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||||
|
{#if h && showRoom}
|
||||||
|
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||||
|
Posted in #<RoomName {h} {url} />
|
||||||
|
</Link>
|
||||||
|
{/if}
|
||||||
|
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||||
|
<ThunkStatusOrDeleted {event} />
|
||||||
|
{#if showActivity}
|
||||||
|
<EventActivity {url} {path} {event} />
|
||||||
|
{/if}
|
||||||
|
<EventActions {url} {event} hideZap noun="Goal" />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {writable} from "svelte/store"
|
||||||
|
import {makeEvent, ZAP_GOAL} from "@welshman/util"
|
||||||
|
import {publishThunk} from "@welshman/app"
|
||||||
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
|
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||||
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
|
import {pushToast} from "@app/util/toast"
|
||||||
|
import {PROTECTED} from "@app/core/state"
|
||||||
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {canEnforceNip70} from "@app/core/commands"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
h?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, h}: Props = $props()
|
||||||
|
|
||||||
|
const shouldProtect = canEnforceNip70(url)
|
||||||
|
|
||||||
|
const uploading = writable(false)
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if ($uploading) return
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Please provide a title for your funding goal.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ed = await editor
|
||||||
|
const summary = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
|
|
||||||
|
if (!summary.trim()) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Please provide details about your funding goal.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = [
|
||||||
|
...ed.storage.nostr.getEditorTags(),
|
||||||
|
["summary", summary],
|
||||||
|
["amount", String(amount)],
|
||||||
|
["relays", url],
|
||||||
|
]
|
||||||
|
|
||||||
|
if (await shouldProtect) {
|
||||||
|
tags.push(PROTECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h) {
|
||||||
|
tags.push(["h", h])
|
||||||
|
}
|
||||||
|
|
||||||
|
publishThunk({
|
||||||
|
relays: [url],
|
||||||
|
event: makeEvent(ZAP_GOAL, {content, tags}),
|
||||||
|
})
|
||||||
|
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
|
||||||
|
|
||||||
|
let content = $state("")
|
||||||
|
let amount = $state(1000)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="column gap-4" onsubmit={preventDefault(submit)}>
|
||||||
|
<ModalHeader>
|
||||||
|
{#snippet title()}
|
||||||
|
<div>Create a Funding Goal</div>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
<div>Request contributions for your fundraiser.</div>
|
||||||
|
{/snippet}
|
||||||
|
</ModalHeader>
|
||||||
|
<div class="col-8 relative">
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Title*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
autofocus={!isMobile}
|
||||||
|
bind:value={content}
|
||||||
|
class="grow"
|
||||||
|
type="text"
|
||||||
|
placeholder="What do funds go towards?" />
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<div class="relative">
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Details*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<div class="note-editor flex-grow overflow-hidden">
|
||||||
|
<EditorContent {editor} />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
data-tip="Add an image"
|
||||||
|
class="tooltip tooltip-left absolute bottom-1 right-2"
|
||||||
|
onclick={selectFiles}>
|
||||||
|
{#if $uploading}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Icon icon={Paperclip} size={3} />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
Goal Amount (sats)*
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<div class="flex flex-grow justify-end">
|
||||||
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
|
<Icon icon={Bolt} />
|
||||||
|
<input bind:value={amount} type="number" class="w-28" />
|
||||||
|
<p class="opacity-50">sats</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<input
|
||||||
|
class="range range-primary -mt-2"
|
||||||
|
type="range"
|
||||||
|
min="1000"
|
||||||
|
max="100000"
|
||||||
|
step="1000"
|
||||||
|
bind:value={amount} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="btn btn-primary">Create Goal</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getTagValue} from "@welshman/util"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Content from "@app/components/Content.svelte"
|
||||||
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
import GoalActions from "@app/components/GoalActions.svelte"
|
||||||
|
import GoalSummary from "@app/components/GoalSummary.svelte"
|
||||||
|
import RoomLink from "@app/components/RoomLink.svelte"
|
||||||
|
import {makeGoalPath} from "@app/util/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const summary = getTagValue("summary", event.tags)
|
||||||
|
const h = getTagValue("h", event.tags)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Link class="col-2 card2 bg-alt w-full cursor-pointer shadow-md" href={makeGoalPath(url, event.id)}>
|
||||||
|
<p class="text-2xl">{event.content}</p>
|
||||||
|
<Content
|
||||||
|
event={{content: summary, tags: event.tags}}
|
||||||
|
{url}
|
||||||
|
expandMode="inline"
|
||||||
|
minLength={50}
|
||||||
|
maxLength={300} />
|
||||||
|
<GoalSummary {url} {event} />
|
||||||
|
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||||
|
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||||
|
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
|
{#if h}
|
||||||
|
in <RoomLink {url} {h} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<GoalActions showActivity {url} {event} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {now, DAY, uniq, sum} from "@welshman/lib"
|
||||||
|
import type {Zap, TrustedEvent} from "@welshman/util"
|
||||||
|
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
|
||||||
|
import {deriveItemsByKey, deriveArray} from "@welshman/store"
|
||||||
|
import {repository, getValidZap} from "@welshman/app"
|
||||||
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import ZapButton from "@app/components/ZapButton.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url?: string
|
||||||
|
event: TrustedEvent
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, ...props}: Props = $props()
|
||||||
|
|
||||||
|
const zaps = deriveArray(
|
||||||
|
deriveItemsByKey<Zap>({
|
||||||
|
repository,
|
||||||
|
getKey: zap => zap.response.id,
|
||||||
|
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||||
|
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const goalAmount = parseInt(getTagValue("amount", event.tags) || "0")
|
||||||
|
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
|
||||||
|
const contributorsCount = $derived(uniq($zaps.map(zap => zap.request.pubkey)).length)
|
||||||
|
const daysOld = Math.ceil((now() - event.created_at) / DAY)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8 {props.class}">
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div>
|
||||||
|
<p class="text-xl text-primary">{zapAmount} sats</p>
|
||||||
|
<p class="text-sm opacity-75">funded of {goalAmount} sats</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xl">{contributorsCount}</p>
|
||||||
|
<p class="text-sm opacity-75">{contributorsCount === 1 ? "contributor" : "contributors"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xl">{daysOld}</p>
|
||||||
|
<p class="text-sm opacity-75">{daysOld === 1 ? "day" : "days"} old</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-primary" value={zapAmount} max={goalAmount}></progress>
|
||||||
|
<ZapButton {url} {event} class="btn btn-primary lg:m-auto lg:px-20">
|
||||||
|
<Icon icon={Bolt} />
|
||||||
|
Contribute to this goal
|
||||||
|
</ZapButton>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {createSearch} from "@welshman/app"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
|
|
||||||
|
const iconModules = import.meta.glob("@assets/icons/*.svg", {
|
||||||
|
query: "?dataurl",
|
||||||
|
eager: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const icons = Object.entries(iconModules)
|
||||||
|
.map(([path, module]) => {
|
||||||
|
const name = path.split("/").pop()?.replace(".svg", "") || ""
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
url: (module as any).default,
|
||||||
|
searchText: name.replace(/[-_]/g, " ").toLowerCase(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(icon => icon.name && !icon.name.startsWith("icon-") && icon.name !== "index")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
const iconSearch = createSearch(icons, {
|
||||||
|
getValue: icon => icon.name,
|
||||||
|
fuseOptions: {
|
||||||
|
keys: ["name", "searchText"],
|
||||||
|
threshold: 0.4,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSelect: (iconUrl: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {onSelect}: Props = $props()
|
||||||
|
|
||||||
|
let searchTerm = $state("")
|
||||||
|
|
||||||
|
const filteredIcons = $derived(searchTerm ? iconSearch.searchOptions(searchTerm) : icons)
|
||||||
|
|
||||||
|
const handleSelect = (iconUrl: string) => {
|
||||||
|
onSelect(iconUrl)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-96 rounded-box bg-base-100 p-4 shadow-2xl">
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<Icon icon={Magnifier} />
|
||||||
|
<input bind:value={searchTerm} class="grow" type="text" placeholder="Search icons..." />
|
||||||
|
</label>
|
||||||
|
<div class="mt-2 max-h-80 overflow-y-auto">
|
||||||
|
<div class="grid grid-cols-8 gap-2 p-2">
|
||||||
|
{#each filteredIcons as icon}
|
||||||
|
<button
|
||||||
|
class="flex aspect-square items-center justify-center rounded-box transition-colors hover:bg-primary hover:text-primary-content"
|
||||||
|
onclick={() => handleSelect(icon.url)}
|
||||||
|
title={icon.name}>
|
||||||
|
<Icon icon={icon.url} class="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import {PLATFORM_NAME} from "@app/state"
|
import {PLATFORM_NAME} from "@app/core/state"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="column gap-4">
|
<div class="column gap-4">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import {PLATFORM_NAME} from "@app/state"
|
import {PLATFORM_NAME} from "@app/core/state"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="column gap-4">
|
<div class="column gap-4">
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {session} from "@welshman/app"
|
import {session} from "@welshman/app"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import ProfileEject from "@app/components/ProfileEject.svelte"
|
import ProfileEject from "@app/components/ProfileEject.svelte"
|
||||||
import {PLATFORM_NAME} from "@app/state"
|
import {PLATFORM_NAME} from "@app/core/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
@@ -27,8 +29,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
On <Link external href="https://nostr.com/">Nostr</Link>, <strong>you</strong> control your own
|
On <Link external href="https://nostr.com/">Nostr</Link>, <strong>you</strong> control your own
|
||||||
identity and social data, through the magic of crytography. The basic idea is that you have a
|
identity and social data, through the magic of cryptography. The basic idea is that you have a
|
||||||
<strong>public key</strong>, which acts as your user id, and a
|
<strong>public key</strong>, which acts as your user ID, and a
|
||||||
<strong>private key</strong> which allows you to prove your identity.
|
<strong>private key</strong> which allows you to prove your identity.
|
||||||
</p>
|
</p>
|
||||||
{#if $session?.email}
|
{#if $session?.email}
|
||||||
@@ -39,11 +41,11 @@
|
|||||||
<p>If you'd like to switch to self-custody, please click below to get started.</p>
|
<p>If you'd like to switch to self-custody, please click below to get started.</p>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon={AltArrowLeft} />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="btn btn-primary" onclick={startEject}>
|
<Button class="btn btn-primary" onclick={startEject}>
|
||||||
<Icon icon="check-circle" />
|
<Icon icon={CheckCircle} />
|
||||||
I want to hold my own keys
|
I want to hold my own keys
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import {PLATFORM_NAME} from "@app/state"
|
import {PLATFORM_NAME} from "@app/core/state"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="column gap-4">
|
<div class="column gap-4">
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="column gap-4">
|
||||||
|
<ModalHeader>
|
||||||
|
{#snippet title()}
|
||||||
|
<div>What are digital signatures?</div>
|
||||||
|
{/snippet}
|
||||||
|
</ModalHeader>
|
||||||
|
<p>
|
||||||
|
Most online services ask their users to trust them that they're being honest, and they usually
|
||||||
|
are. However, traditional social media platforms have the ability to <strong
|
||||||
|
>create forged content</strong> that can appear to be genuinely authored, but which are actually
|
||||||
|
counterfeit.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
On <Link external href="https://nostr.com/">Nostr</Link>, all your content is authenticated
|
||||||
|
using <strong>digital signatures</strong>, which cryptographically tie a particular person to a
|
||||||
|
given post or message.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The result is that you don't normally have to trust service providers not to tamper with the
|
||||||
|
information flowing through the network — instead, your client software can prove that a given
|
||||||
|
piece of data is authentic.
|
||||||
|
</p>
|
||||||
|
<Button class="btn btn-primary" onclick={back}>Got it</Button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {deriveZapperForPubkey} from "@welshman/app"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
|
||||||
|
const {pubkey} = $props()
|
||||||
|
|
||||||
|
const zapper = deriveZapperForPubkey(pubkey)
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="column gap-4">
|
||||||
|
<ModalHeader>
|
||||||
|
{#snippet title()}
|
||||||
|
<div>Unable to Zap</div>
|
||||||
|
{/snippet}
|
||||||
|
</ModalHeader>
|
||||||
|
<p>
|
||||||
|
Zapping <ProfileLink {pubkey} class="!text-primary" /> isn't possible right now because
|
||||||
|
{#if $zapper}
|
||||||
|
their zap receiver isn't correctly set up.
|
||||||
|
{:else}
|
||||||
|
they don't currently have a zap receiver set up.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
+35
-22
@@ -1,7 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {randomId} from "@welshman/lib"
|
import {randomId, call} from "@welshman/lib"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation, compressFile} from "@lib/html"
|
||||||
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
|
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import {uploadFile} from "@app/core/commands"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
file?: File | undefined
|
file?: File | undefined
|
||||||
@@ -24,14 +28,14 @@
|
|||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDrop = (e: any) => {
|
const onDrop = async (e: any) => {
|
||||||
active = false
|
active = false
|
||||||
|
|
||||||
file = e.dataTransfer.files[0]
|
file = await compressFile(e.dataTransfer.files[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
const onChange = (e: any) => {
|
const onChange = async (e: any) => {
|
||||||
file = e.target.files[0]
|
file = await compressFile(e.target.files[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClear = () => {
|
const onClear = () => {
|
||||||
@@ -44,20 +48,29 @@
|
|||||||
let initialUrl = $state(url)
|
let initialUrl = $state(url)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (file) {
|
call(async () => {
|
||||||
const reader = new FileReader()
|
if (file) {
|
||||||
|
const {result} = await uploadFile(file)
|
||||||
|
|
||||||
reader.addEventListener(
|
if (result?.url) {
|
||||||
"load",
|
url = result.url
|
||||||
() => {
|
} else {
|
||||||
url = reader.result as string
|
const reader = new FileReader()
|
||||||
},
|
|
||||||
false,
|
reader.addEventListener(
|
||||||
)
|
"load",
|
||||||
reader.readAsDataURL(file)
|
() => {
|
||||||
} else {
|
url = reader.result as string
|
||||||
url = initialUrl
|
},
|
||||||
}
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
url = initialUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -84,14 +97,14 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onmousedown={stopPropagation(onClear)}
|
onmousedown={stopPropagation(onClear)}
|
||||||
ontouchstart={stopPropagation(onClear)}>
|
ontouchstart={stopPropagation(onClear)}>
|
||||||
<Icon icon="close-circle" class="scale-150 !bg-base-300" />
|
<Icon icon={CloseCircle} class="scale-150 !bg-base-300" />
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="add-circle" class="scale-150 !bg-base-300" />
|
<Icon icon={AddCircle} class="scale-150 !bg-base-300" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !url}
|
{#if !url}
|
||||||
<Icon icon="gallery-send" size={7} />
|
<Icon icon={GallerySend} size={7} />
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Login from "@assets/icons/login-3.svg?dataurl"
|
||||||
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
@@ -6,8 +8,8 @@
|
|||||||
import CardButton from "@lib/components/CardButton.svelte"
|
import CardButton from "@lib/components/CardButton.svelte"
|
||||||
import LogIn from "@app/components/LogIn.svelte"
|
import LogIn from "@app/components/LogIn.svelte"
|
||||||
import SignUp from "@app/components/SignUp.svelte"
|
import SignUp from "@app/components/SignUp.svelte"
|
||||||
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/state"
|
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/core/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
const logIn = () => pushModal(LogIn)
|
const logIn = () => pushModal(LogIn)
|
||||||
|
|
||||||
@@ -21,9 +23,9 @@
|
|||||||
<p class="text-center">The chat app built for self-hosted communities.</p>
|
<p class="text-center">The chat app built for self-hosted communities.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onclick={logIn}>
|
<Button onclick={logIn}>
|
||||||
<CardButton class="!btn-primary">
|
<CardButton class="btn-primary">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<div><Icon icon="login-2" size={7} /></div>
|
<div><Icon icon={Login} size={7} /></div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet title()}
|
{#snippet title()}
|
||||||
<div>Log in</div>
|
<div>Log in</div>
|
||||||
@@ -33,10 +35,10 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</CardButton>
|
</CardButton>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onclick={signUp}>
|
<Button onclick={signUp} class="btn-neutral">
|
||||||
<CardButton>
|
<CardButton>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<div><Icon icon="add-circle" size={7} /></div>
|
<div><Icon icon={AddCircle} size={7} /></div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet title()}
|
{#snippet title()}
|
||||||
<div>Create an account</div>
|
<div>Create an account</div>
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
|
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
|
||||||
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
|
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
|
||||||
|
import Widget from "@assets/icons/widget-2.svg?dataurl"
|
||||||
|
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
|
||||||
|
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
|
||||||
|
import Compass from "@assets/icons/compass-big.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
@@ -10,11 +14,10 @@
|
|||||||
import InfoNostr from "@app/components/InfoNostr.svelte"
|
import InfoNostr from "@app/components/InfoNostr.svelte"
|
||||||
import LogInBunker from "@app/components/LogInBunker.svelte"
|
import LogInBunker from "@app/components/LogInBunker.svelte"
|
||||||
import LogInPassword from "@app/components/LogInPassword.svelte"
|
import LogInPassword from "@app/components/LogInPassword.svelte"
|
||||||
import {pushModal, clearModals} from "@app/modal"
|
import {pushModal, clearModals} from "@app/util/modal"
|
||||||
import {PLATFORM_NAME, BURROW_URL} from "@app/state"
|
import {PLATFORM_NAME, BURROW_URL} from "@app/core/state"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {loadUserData} from "@app/requests"
|
import {setChecked} from "@app/util/notifications"
|
||||||
import {setChecked} from "@app/notifications"
|
|
||||||
|
|
||||||
let signers: any[] = $state([])
|
let signers: any[] = $state([])
|
||||||
let loading: string | undefined = $state()
|
let loading: string | undefined = $state()
|
||||||
@@ -23,9 +26,7 @@
|
|||||||
|
|
||||||
const signUp = () => pushModal(SignUp)
|
const signUp = () => pushModal(SignUp)
|
||||||
|
|
||||||
const onSuccess = async (session: Session, relays: string[] = []) => {
|
const onSuccess = async (session: Session) => {
|
||||||
await loadUserData(session.pubkey, relays)
|
|
||||||
|
|
||||||
addSession(session)
|
addSession(session)
|
||||||
pushToast({message: "Successfully logged in!"})
|
pushToast({message: "Successfully logged in!"})
|
||||||
setChecked("*")
|
setChecked("*")
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
{#if loading === "nip07"}
|
{#if loading === "nip07"}
|
||||||
<span class="loading loading-spinner mr-3"></span>
|
<span class="loading loading-spinner mr-3"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="widget" />
|
<Icon icon={Widget} />
|
||||||
{/if}
|
{/if}
|
||||||
Log in with Extension
|
Log in with Extension
|
||||||
</Button>
|
</Button>
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
{#if loading === "password"}
|
{#if loading === "password"}
|
||||||
<span class="loading loading-spinner mr-3"></span>
|
<span class="loading loading-spinner mr-3"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="key" />
|
<Icon icon={Key} />
|
||||||
{/if}
|
{/if}
|
||||||
Log in with Password
|
Log in with Password
|
||||||
</Button>
|
</Button>
|
||||||
@@ -125,7 +126,7 @@
|
|||||||
onclick={loginWithBunker}
|
onclick={loginWithBunker}
|
||||||
{disabled}
|
{disabled}
|
||||||
class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
|
class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
|
||||||
<Icon icon="cpu" />
|
<Icon icon={Cpu} />
|
||||||
Log in with Remote Signer
|
Log in with Remote Signer
|
||||||
</Button>
|
</Button>
|
||||||
{#if BURROW_URL && hasSigner}
|
{#if BURROW_URL && hasSigner}
|
||||||
@@ -133,7 +134,7 @@
|
|||||||
{#if loading === "password"}
|
{#if loading === "password"}
|
||||||
<span class="loading loading-spinner mr-3"></span>
|
<span class="loading loading-spinner mr-3"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="key" />
|
<Icon icon={Key} />
|
||||||
{/if}
|
{/if}
|
||||||
Log in with Password
|
Log in with Password
|
||||||
</Button>
|
</Button>
|
||||||
@@ -144,7 +145,7 @@
|
|||||||
{disabled}
|
{disabled}
|
||||||
href="https://nostrapps.com#signers"
|
href="https://nostrapps.com#signers"
|
||||||
class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
|
class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
|
||||||
<Icon icon="compass" />
|
<Icon icon={Compass} />
|
||||||
Browse Signer Apps
|
Browse Signer Apps
|
||||||
</Link>
|
</Link>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user