forked from coracle/flotilla
Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 68ebd32e15 | |||
| e94aa3c119 | |||
| 4d10fe7cc0 | |||
| 841928783b | |||
| 6e5e1a0846 | |||
| d57f4747a6 | |||
| 94a0077b09 | |||
| f2eb04adff | |||
| d4d5979a35 | |||
| dde6e54657 | |||
| 698a7513b8 | |||
| ea3f5a6779 | |||
| f5fce8e2e7 | |||
| 46b5c01c49 | |||
| dd069329ee | |||
| c1b52b66ff | |||
| 5873e8aa60 | |||
| c582082816 | |||
| 6ddba63ff9 | |||
| 5a7750a91b | |||
| 8c71b7d9b9 | |||
| b5a28c71ad | |||
| ccdd18a863 | |||
| 2244ecad9b | |||
| da2457da9f | |||
| c18b29e7d6 | |||
| 3a954201ce | |||
| c8bc8ee8bf | |||
| 8c3e52ce8c | |||
| 303b8967e9 | |||
| f3debe6c02 | |||
| 374ca7f265 | |||
| 91689e5b90 | |||
| a64eaba45c | |||
| 394a1e7d30 | |||
| d5b1fab1e7 | |||
| 10a1e6e640 | |||
| 84af4d2d8e | |||
| acddff79f0 | |||
| 489707b9b2 | |||
| 33902dbefe | |||
| 1b318a7a52 | |||
| b6a4b38d14 | |||
| a3eb6d52c0 | |||
| d2c537d275 | |||
| 9eefd6600d | |||
| ad034b1641 | |||
| d94860014c | |||
| 33af39ee93 | |||
| 1d56a2193d | |||
| 75905e4652 | |||
| d07b9cde5f | |||
| d8a9cc5a7e | |||
| 863d11352f | |||
| b4cc770cdf | |||
| 901e56a625 | |||
| 479fed34f7 | |||
| 81d7b08aed | |||
| a582b1ea73 | |||
| 1c0b2a09df | |||
| 3a42a1b560 | |||
| db203bf00d | |||
| ffb36af734 | |||
| b399fa8dcc | |||
| 5bba5959f7 | |||
| 2ad65e394e | |||
| 345b20bf5d | |||
| b9fb251b32 | |||
| dd9a9c0df2 | |||
| 115b5f9fbe | |||
| 3ad7dcfeb4 | |||
| 60d107aed2 | |||
| 08d8d45ecb | |||
| c40e8ce1a7 | |||
| 993bf8d2e6 | |||
| c3c65c3970 | |||
| a5b868cd56 | |||
| 8fcc56a408 | |||
| c8dfbc936b | |||
| f1e76a1ed1 | |||
| 6ecc3e6770 | |||
| b05c408977 | |||
| e484c3cb00 | |||
| 69d0e11ba4 |
@@ -3,4 +3,6 @@
|
|||||||
--ignore-dir=build
|
--ignore-dir=build
|
||||||
--ignore-dir=ios/DerivedData
|
--ignore-dir=ios/DerivedData
|
||||||
--ignore-dir=ios/App/App/public
|
--ignore-dir=ios/App/App/public
|
||||||
|
--ignore-file=match:.svg
|
||||||
|
--ignore-file=match:package-lock.json
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
android
|
||||||
|
ios
|
||||||
|
build
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
VITE_DEFAULT_PUBKEYS=fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322
|
|
||||||
VITE_BURROW_URL=
|
|
||||||
VITE_PLATFORM_URL=https://flotilla.social
|
|
||||||
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
|
||||||
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
|
||||||
VITE_PLATFORM_NAME=Flotilla
|
|
||||||
VITE_PLATFORM_LOGO=static/flotilla.png
|
|
||||||
VITE_PLATFORM_RELAY=
|
|
||||||
VITE_PLATFORM_ACCENT="#7161FF"
|
|
||||||
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
|
|
||||||
VITE_GLITCHTIP_API_KEY=
|
|
||||||
GLITCHTIP_AUTH_TOKEN=
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
|
||||||
|
VITE_BURROW_URL=
|
||||||
|
VITE_PLATFORM_URL=https://flotilla.social
|
||||||
|
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
||||||
|
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
||||||
|
VITE_PLATFORM_NAME=Flotilla
|
||||||
|
VITE_PLATFORM_LOGO=static/flotilla.png
|
||||||
|
VITE_PLATFORM_RELAYS=
|
||||||
|
VITE_PLATFORM_ACCENT="#7161FF"
|
||||||
|
VITE_PLATFORM_SECONDARY="#EB5E28"
|
||||||
|
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
|
||||||
|
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_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
|
||||||
|
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
|
||||||
|
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
||||||
|
VITE_GLITCHTIP_API_KEY=
|
||||||
|
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
|
||||||
+4
-1
@@ -1,5 +1,5 @@
|
|||||||
# Env
|
# Env
|
||||||
.env.local
|
.env
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
@@ -56,8 +56,11 @@ out/
|
|||||||
.gradle/
|
.gradle/
|
||||||
local.properties
|
local.properties
|
||||||
proguard/
|
proguard/
|
||||||
|
google-services.json
|
||||||
|
GoogleService-Info.plist
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
|
.roo
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -1,2 +1,8 @@
|
|||||||
npm run lint
|
pnpm run lint
|
||||||
npm run check
|
pnpm run check
|
||||||
|
|
||||||
|
if [[ ! -z $(cat package.json | grep 'link:') ]]; then
|
||||||
|
echo "Some packages are linked to local files!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|||||||
+116
@@ -1,5 +1,121 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
* Fix add relay button
|
||||||
|
* Fix safe inset areas
|
||||||
|
* Better rendering for errors from relays
|
||||||
|
* Improve remote signer login
|
||||||
|
|
||||||
|
# 1.0.1
|
||||||
|
|
||||||
|
* Fix relay images in nav
|
||||||
|
* Fix relay nav overflow
|
||||||
|
|
||||||
|
# 1.0.0
|
||||||
|
|
||||||
|
* Add alerts via Anchor
|
||||||
|
* Fix nip46 signer connect
|
||||||
|
* Allow use of cleartext relays on native builds
|
||||||
|
* Fix some modal state bugs caused by svelte 5
|
||||||
|
* Detect blossom support on community relays
|
||||||
|
* Use user blossom server list in settings
|
||||||
|
* Fix some feed bugs
|
||||||
|
* Improve thunk indicator
|
||||||
|
* Update storage adapters
|
||||||
|
* Fix modal flash
|
||||||
|
* Switch to pnpm
|
||||||
|
* Improve calendar windowing
|
||||||
|
|
||||||
|
# 0.2.14
|
||||||
|
|
||||||
|
* Add calendar event editing
|
||||||
|
|
||||||
|
# 0.2.13
|
||||||
|
|
||||||
|
* Fix android keyboard issue
|
||||||
|
|
||||||
|
# 0.2.12
|
||||||
|
|
||||||
|
* Fix keyboard covering chat input
|
||||||
|
* Fix thread replies
|
||||||
|
* Make error reporting and analytics optional
|
||||||
|
* Replace long press with tap target
|
||||||
|
* Fix time input
|
||||||
|
* Fix nevent hints for url-specific stuff
|
||||||
|
* Fix confirm and reactions on mobile
|
||||||
|
* Add reply to chat on mobile
|
||||||
|
* Fix profile suggestions
|
||||||
|
|
||||||
|
# 0.2.11
|
||||||
|
|
||||||
|
* Add in-app signup flow on ios
|
||||||
|
* Add profile deletion
|
||||||
|
|
||||||
|
# 0.2.10
|
||||||
|
|
||||||
|
* Improve space discovery
|
||||||
|
|
||||||
|
# 0.2.9
|
||||||
|
|
||||||
|
* Add NIP 01 signup flow on mobile
|
||||||
|
|
||||||
# 0.2.8
|
# 0.2.8
|
||||||
|
|
||||||
* Show spinner when joining a room
|
* Show spinner when joining a room
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Flotilla is a Discord-like Nostr client that operates on the concept of "relays as groups/spaces." Built with SvelteKit 2.5 and Svelte 5, it provides messaging, threads, calendar events, and social features across Nostr relays.
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### Finding Code
|
||||||
|
- Prefer navigating from one file to the next following imports when possible
|
||||||
|
- If search is necessary, use `ack`, not `grep` or `rg`.
|
||||||
|
|
||||||
|
### Nostr Event Handling
|
||||||
|
- Prefer seconds to milliseconds when handling nostr events.
|
||||||
|
|
||||||
|
### Styling Conventions
|
||||||
|
- When styling html, prefer flex/gap classes over margin or space-y classes.
|
||||||
|
|
||||||
|
### Room/space memberships
|
||||||
|
|
||||||
|
Memberships are surfaced as "bookmarks" to the user.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {membershipsByPubkey, getMembershipUrls} from '@app/state'
|
||||||
|
|
||||||
|
const spaces = getMembershipUrls($membershipsByPubkey.get(pubkey))
|
||||||
|
const rooms = getMembershipRooms($membershipsByPubkey.get(pubkey))
|
||||||
|
```
|
||||||
+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"]
|
||||||
|
|
||||||
@@ -2,25 +2,17 @@
|
|||||||
|
|
||||||
A discord-like nostr client based on the idea of "relays as groups".
|
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 draft NIP: https://github.com/coracle-social/nips/blob/relay-chat/xx.md
|
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:
|
|
||||||
|
|
||||||
- `npm install`
|
|
||||||
- `npm 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. This is only used for build-time population of meta tags.
|
||||||
- `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.
|
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.
|
||||||
|
|
||||||
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
|
|
||||||
npm i
|
|
||||||
|
|
||||||
# Optionally create and populate .env.local to suit your use case
|
|
||||||
|
|
||||||
# Build the app
|
|
||||||
NODE_OPTIONS=--max_old_space_size=16384 npm 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 `npm run dev` to get a dev server, and `npm run check:watch` to watch for typescript errors. When you're ready to commit, run `npm run format && npm run lint` and fix any errors that come up.
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ android {
|
|||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "social.flotilla"
|
applicationId "social.flotilla"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdk rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdk rootProject.ext.targetSdkVersion
|
||||||
versionCode 9
|
versionCode 23
|
||||||
versionName "0.2.7"
|
versionName "1.2.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,7 +9,11 @@ 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-keyboard')
|
||||||
|
implementation project(':capacitor-push-notifications')
|
||||||
|
implementation project(':capawesome-capacitor-badge')
|
||||||
implementation project(':nostr-signer-capacitor-plugin')
|
implementation project(':nostr-signer-capacitor-plugin')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:label="@string/title_activity_main"
|
android:label="@string/title_activity_main"
|
||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -32,4 +34,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,9 +1,21 @@
|
|||||||
// 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/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/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.2.0/node_modules/@capacitor-community/safe-area/android')
|
||||||
|
|
||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
include ':capacitor-push-notifications'
|
||||||
|
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications/android')
|
||||||
|
|
||||||
|
include ':capawesome-capacitor-badge'
|
||||||
|
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/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/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.2.0/node_modules/nostr-signer-capacitor-plugin/android')
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ ext {
|
|||||||
compileSdkVersion = 35
|
compileSdkVersion = 35
|
||||||
targetSdkVersion = 35
|
targetSdkVersion = 35
|
||||||
androidxActivityVersion = '1.9.2'
|
androidxActivityVersion = '1.9.2'
|
||||||
androidxAppCompatVersion = '1.7.0'
|
//https://github.com/ionic-team/capacitor/issues/7866
|
||||||
|
// androidxAppCompatVersion = '1.7.0'
|
||||||
|
androidxAppCompatVersion = '1.6.1'
|
||||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||||
androidxCoreVersion = '1.15.0'
|
androidxCoreVersion = '1.15.0'
|
||||||
androidxFragmentVersion = '1.8.4'
|
androidxFragmentVersion = '1.8.4'
|
||||||
@@ -13,4 +15,4 @@ ext {
|
|||||||
androidxJunitVersion = '1.2.1'
|
androidxJunitVersion = '1.2.1'
|
||||||
androidxEspressoCoreVersion = '3.6.1'
|
androidxEspressoCoreVersion = '3.6.1'
|
||||||
cordovaAndroidVersion = '10.1.1'
|
cordovaAndroidVersion = '10.1.1'
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Fetch tags and set to env vars
|
||||||
|
git fetch --prune --unshallow --tags || true
|
||||||
|
git describe --tags --abbrev=0 || true
|
||||||
|
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
|
||||||
|
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
CI=0 pnpm i
|
||||||
|
|
||||||
|
# Rebuild sharp
|
||||||
|
pnpm rebuild
|
||||||
|
|
||||||
|
# The build runs out of memory at times
|
||||||
|
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
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
|
fi
|
||||||
|
|
||||||
if [ -f .env.local ]; then
|
if [ -f .env ]; then
|
||||||
source .env.local
|
source .env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Avoid overwriting env vars provided directly
|
# Avoid overwriting env vars provided directly
|
||||||
|
|||||||
+10
-2
@@ -10,11 +10,19 @@ const config: CapacitorConfig = {
|
|||||||
plugins: {
|
plugins: {
|
||||||
SplashScreen: {
|
SplashScreen: {
|
||||||
androidSplashResourceName: "splash"
|
androidSplashResourceName: "splash"
|
||||||
}
|
},
|
||||||
|
Keyboard: {
|
||||||
|
style: "DARK",
|
||||||
|
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: {
|
||||||
// url: "http://192.168.1.250:1847",
|
// url: "http://192.168.1.115:1847",
|
||||||
// cleartext: true
|
// cleartext: true
|
||||||
// },
|
// },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
/* 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>"; };
|
||||||
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>"; };
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
504EC2FB1FED79650016851F = {
|
504EC2FB1FED79650016851F = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */,
|
||||||
504EC3061FED79650016851F /* App */,
|
504EC3061FED79650016851F /* App */,
|
||||||
504EC3051FED79650016851F /* Products */,
|
504EC3051FED79650016851F /* Products */,
|
||||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||||
@@ -349,16 +351,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 = 2;
|
CURRENT_PROJECT_VERSION = 16;
|
||||||
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 = 0.2.8;
|
MARKETING_VERSION = 1.2.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 +377,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 = 2;
|
CURRENT_PROJECT_VERSION = 16;
|
||||||
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 = 0.2.8;
|
MARKETING_VERSION = 1.2.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,8 @@
|
|||||||
|
<?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>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+9
-5
@@ -1,4 +1,4 @@
|
|||||||
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
|
require_relative '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios/scripts/pods_helpers'
|
||||||
|
|
||||||
platform :ios, '14.0'
|
platform :ios, '14.0'
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
@@ -9,10 +9,14 @@ 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/@capacitor/ios'
|
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorCordova', :path => '../../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 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area'
|
||||||
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/nostr-signer-capacitor-plugin'
|
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
|
||||||
|
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
|
||||||
|
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications'
|
||||||
|
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge'
|
||||||
|
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'Flotilla Chat' do
|
target 'Flotilla Chat' do
|
||||||
|
|||||||
Generated
-15453
File diff suppressed because it is too large
Load Diff
+36
-16
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flotilla",
|
"name": "flotilla",
|
||||||
"version": "0.2.8",
|
"version": "1.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
|
"@eslint/js": "^9.26.0",
|
||||||
"@sentry/cli": "^2.40.0",
|
"@sentry/cli": "^2.40.0",
|
||||||
"@sveltejs/kit": "^2.5.27",
|
"@sveltejs/kit": "^2.5.27",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
@@ -37,29 +38,36 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor-community/safe-area": "7.0.0-alpha.1",
|
||||||
"@capacitor/android": "^7.0.0",
|
"@capacitor/android": "^7.0.0",
|
||||||
"@capacitor/app": "^7.0.0",
|
"@capacitor/app": "^7.0.0",
|
||||||
"@capacitor/cli": "^7.0.0",
|
"@capacitor/cli": "^7.0.0",
|
||||||
"@capacitor/core": "^7.0.1",
|
"@capacitor/core": "^7.0.1",
|
||||||
"@capacitor/ios": "^7.0.0",
|
"@capacitor/ios": "^7.0.0",
|
||||||
"@noble/curves": "^1.5.0",
|
"@capacitor/keyboard": "^7.0.0",
|
||||||
"@noble/hashes": "^1.4.0",
|
"@capacitor/push-notifications": "^7.0.1",
|
||||||
|
"@capawesome/capacitor-badge": "^7.0.1",
|
||||||
|
"@getalby/sdk": "^5.1.0",
|
||||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||||
"@sentry/browser": "^8.35.0",
|
"@sentry/browser": "^8.35.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.4",
|
"@sveltejs/adapter-static": "^3.0.4",
|
||||||
|
"@tiptap/core": "^2.12.0",
|
||||||
"@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.6",
|
||||||
"@welshman/app": "~0.0.42",
|
"@welshman/app": "^0.4.0",
|
||||||
"@welshman/content": "~0.0.18",
|
"@welshman/content": "^0.4.0",
|
||||||
"@welshman/dvm": "~0.0.14",
|
"@welshman/editor": "^0.4.0",
|
||||||
"@welshman/editor": "~0.0.15",
|
"@welshman/feeds": "^0.4.0",
|
||||||
"@welshman/feeds": "~0.0.30",
|
"@welshman/lib": "^0.4.0",
|
||||||
"@welshman/lib": "~0.0.41",
|
"@welshman/net": "^0.4.0",
|
||||||
"@welshman/net": "~0.0.47",
|
"@welshman/relay": "^0.4.0",
|
||||||
"@welshman/signer": "~0.0.20",
|
"@welshman/router": "^0.4.0",
|
||||||
"@welshman/store": "~0.0.16",
|
"@welshman/signer": "^0.4.0",
|
||||||
"@welshman/util": "~0.0.61",
|
"@welshman/store": "^0.4.0",
|
||||||
|
"@welshman/util": "^0.4.0",
|
||||||
|
"compressorjs": "^1.2.1",
|
||||||
"daisyui": "^4.12.10",
|
"daisyui": "^4.12.10",
|
||||||
"date-picker-svelte": "^2.13.0",
|
"date-picker-svelte": "^2.13.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
@@ -67,9 +75,21 @@
|
|||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"husky": "^9.1.6",
|
"husky": "^9.1.6",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
"nostr-signer-capacitor-plugin": "coracle-social/nostr-signer-capacitor-plugin#9fbe4f8",
|
"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.5",
|
||||||
"qrcode": "^1.5.4"
|
"qr-scanner": "^1.4.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"throttle-debounce": "^5.0.2",
|
||||||
|
"tippy.js": "^6.3.7"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"ignoredBuiltDependencies": [
|
||||||
|
"@sentry/cli",
|
||||||
|
"esbuild"
|
||||||
|
],
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"sharp"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+10090
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,
|
||||||
|
|||||||
+120
-38
@@ -1,3 +1,5 @@
|
|||||||
|
@import "@welshman/editor/index.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@@ -44,6 +46,14 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: Lato;
|
font-family: Lato;
|
||||||
|
--sait: env(safe-area-inset-top);
|
||||||
|
--saib: env(safe-area-inset-bottom);
|
||||||
|
--sail: env(safe-area-inset-left);
|
||||||
|
--sair: 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,56 +64,80 @@
|
|||||||
--secondary-content: oklch(var(--sc));
|
--secondary-content: oklch(var(--sc));
|
||||||
}
|
}
|
||||||
|
|
||||||
:root,
|
/* safe area insets */
|
||||||
body,
|
|
||||||
html {
|
|
||||||
@apply bg-base-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ios */
|
@layer components {
|
||||||
|
.pt-sai {
|
||||||
|
padding-top: var(--sait);
|
||||||
|
}
|
||||||
|
|
||||||
.sait {
|
.pr-sai {
|
||||||
padding-top: env(safe-area-inset-top);
|
padding-right: var(--sair);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sair {
|
.pb-sai {
|
||||||
padding-right: env(safe-area-inset-right);
|
padding-bottom: var(--saib);
|
||||||
}
|
}
|
||||||
|
|
||||||
.saib {
|
.pl-sai {
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-left: var(--sail);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sail {
|
.px-sai {
|
||||||
padding-left: env(safe-area-inset-left);
|
@apply pl-sai pr-sai;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saix {
|
.py-sai {
|
||||||
@apply sail sair;
|
@apply pt-sai pb-sai;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saiy {
|
.p-sai {
|
||||||
@apply sait saib;
|
@apply py-sai px-sai;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sai {
|
.mt-sai {
|
||||||
@apply saiy saix;
|
padding-top: var(--sait);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-sai {
|
.mr-sai {
|
||||||
top: env(safe-area-inset-top);
|
padding-right: var(--sair);
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-sai {
|
.mb-sai {
|
||||||
right: env(safe-area-inset-right);
|
padding-bottom: var(--saib);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-sai {
|
.ml-sai {
|
||||||
bottom: env(safe-area-inset-bottom);
|
padding-left: var(--sail);
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-sai {
|
.mx-sai {
|
||||||
left: env(safe-area-inset-left);
|
@apply ml-sai mr-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-sai {
|
||||||
|
@apply mt-sai mb-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-sai {
|
||||||
|
@apply my-sai mx-sai;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-sai {
|
||||||
|
top: var(--sait);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-sai {
|
||||||
|
right: var(--sair);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sai {
|
||||||
|
bottom: var(--saib);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-sai {
|
||||||
|
left: var(--sail);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* utilities */
|
/* utilities */
|
||||||
@@ -257,6 +291,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;
|
||||||
}
|
}
|
||||||
@@ -292,6 +334,16 @@ html {
|
|||||||
color: var(--base-content);
|
color: var(--base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* content rendered by welshman/content */
|
||||||
|
|
||||||
|
.welshman-content a {
|
||||||
|
@apply link;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welshman-content-error a {
|
||||||
|
@apply underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* date input */
|
/* date input */
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
@@ -323,3 +375,33 @@ emoji-picker {
|
|||||||
--input-font-color: var(--base-content);
|
--input-font-color: var(--base-content);
|
||||||
--outline-color: var(--base-100);
|
--outline-color: var(--base-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* progress */
|
||||||
|
|
||||||
|
progress[value]::-webkit-progress-value {
|
||||||
|
transition: width 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* content width for fixed elements */
|
||||||
|
|
||||||
|
.cw {
|
||||||
|
@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 {
|
||||||
|
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* chat view */
|
||||||
|
|
||||||
|
.chat__compose {
|
||||||
|
@apply cb cw fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__scroll-down {
|
||||||
|
@apply fixed bottom-28 right-4 md:bottom-16;
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<meta name="theme-color" content="{ACCENT}" />
|
<meta name="theme-color" content="{ACCENT}" />
|
||||||
<meta name="description" content="{DESCRIPTION}" />
|
<meta name="description" content="{DESCRIPTION}" />
|
||||||
<meta name="og:url" content="{URL}" />
|
<meta name="og:url" content="{URL}" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint prefer-rest-params: 0 */
|
/* eslint prefer-rest-params: 0 */
|
||||||
|
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
import {getSetting} from "@app/state"
|
||||||
|
|
||||||
const w = window as any
|
const w = window as any
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ w.plausible =
|
|||||||
|
|
||||||
export const setupAnalytics = () => {
|
export const setupAnalytics = () => {
|
||||||
page.subscribe($page => {
|
page.subscribe($page => {
|
||||||
if ($page.route) {
|
if ($page.route && getSetting("report_usage")) {
|
||||||
w.plausible("pageview", {u: $page.route.id})
|
w.plausible("pageview", {u: $page.route.id})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+170
-212
@@ -1,6 +1,9 @@
|
|||||||
|
import {nwc} from "@getalby/sdk"
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {get} from "svelte/store"
|
import {get} from "svelte/store"
|
||||||
import {ctx, sample, uniq, sleep, chunk, equals} from "@welshman/lib"
|
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
|
||||||
|
import type {Feed} from "@welshman/feeds"
|
||||||
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
DELETE,
|
DELETE,
|
||||||
REPORT,
|
REPORT,
|
||||||
@@ -10,14 +13,14 @@ import {
|
|||||||
FOLLOWS,
|
FOLLOWS,
|
||||||
REACTION,
|
REACTION,
|
||||||
AUTH_JOIN,
|
AUTH_JOIN,
|
||||||
GROUP_JOIN,
|
ROOMS,
|
||||||
GROUP_LEAVE,
|
|
||||||
GROUP_CREATE,
|
|
||||||
GROUP_EDIT_META,
|
|
||||||
GROUPS,
|
|
||||||
COMMENT,
|
COMMENT,
|
||||||
|
ALERT_EMAIL,
|
||||||
|
ALERT_WEB,
|
||||||
|
ALERT_IOS,
|
||||||
|
ALERT_ANDROID,
|
||||||
isSignedEvent,
|
isSignedEvent,
|
||||||
createEvent,
|
makeEvent,
|
||||||
displayProfile,
|
displayProfile,
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
makeList,
|
makeList,
|
||||||
@@ -26,50 +29,40 @@ import {
|
|||||||
getTag,
|
getTag,
|
||||||
getListTags,
|
getListTags,
|
||||||
getRelayTags,
|
getRelayTags,
|
||||||
isShareableRelayUrl,
|
|
||||||
getRelayTagValues,
|
getRelayTagValues,
|
||||||
toNostrURI,
|
toNostrURI,
|
||||||
|
getRelaysFromList,
|
||||||
|
RelayMode,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent, EventContent, EventTemplate, List} from "@welshman/util"
|
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
|
||||||
import type {SubscribeRequestWithHandlers} from "@welshman/net"
|
import {Router} from "@welshman/router"
|
||||||
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
|
|
||||||
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
|
|
||||||
import {
|
import {
|
||||||
pubkey,
|
pubkey,
|
||||||
signer,
|
signer,
|
||||||
repository,
|
repository,
|
||||||
publishThunk,
|
publishThunk,
|
||||||
publishThunks,
|
|
||||||
loadProfile,
|
|
||||||
loadInboxRelaySelections,
|
|
||||||
profilesByPubkey,
|
profilesByPubkey,
|
||||||
relaySelectionsByPubkey,
|
relaySelectionsByPubkey,
|
||||||
getWriteRelayUrls,
|
|
||||||
loadFollows,
|
|
||||||
loadMutes,
|
|
||||||
tagEvent,
|
tagEvent,
|
||||||
tagEventForReaction,
|
tagEventForReaction,
|
||||||
getRelayUrls,
|
|
||||||
userRelaySelections,
|
userRelaySelections,
|
||||||
userInboxRelaySelections,
|
userInboxRelaySelections,
|
||||||
nip44EncryptToSelf,
|
nip44EncryptToSelf,
|
||||||
loadRelay,
|
loadRelay,
|
||||||
addSession,
|
|
||||||
clearStorage,
|
clearStorage,
|
||||||
dropSession,
|
dropSession,
|
||||||
tagEventForComment,
|
tagEventForComment,
|
||||||
tagEventForQuote,
|
tagEventForQuote,
|
||||||
|
getThunkError,
|
||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
import type {Thunk} from "@welshman/app"
|
|
||||||
import {
|
import {
|
||||||
tagRoom,
|
wallet,
|
||||||
|
getWebLn,
|
||||||
PROTECTED,
|
PROTECTED,
|
||||||
userMembership,
|
userMembership,
|
||||||
INDEXER_RELAYS,
|
INDEXER_RELAYS,
|
||||||
NIP46_PERMS,
|
NOTIFIER_PUBKEY,
|
||||||
loadMembership,
|
NOTIFIER_RELAY,
|
||||||
loadSettings,
|
|
||||||
getDefaultPubkeys,
|
|
||||||
userRoomsByUrl,
|
userRoomsByUrl,
|
||||||
} from "@app/state"
|
} from "@app/state"
|
||||||
|
|
||||||
@@ -77,7 +70,7 @@ import {
|
|||||||
|
|
||||||
export const getPubkeyHints = (pubkey: string) => {
|
export const getPubkeyHints = (pubkey: string) => {
|
||||||
const selections = relaySelectionsByPubkey.get().get(pubkey)
|
const selections = relaySelectionsByPubkey.get().get(pubkey)
|
||||||
const relays = selections ? getWriteRelayUrls(selections) : []
|
const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
|
||||||
const hints = relays.length ? relays : INDEXER_RELAYS
|
const hints = relays.length ? relays : INDEXER_RELAYS
|
||||||
|
|
||||||
return hints
|
return hints
|
||||||
@@ -90,22 +83,13 @@ export const getPubkeyPetname = (pubkey: string) => {
|
|||||||
return display
|
return display
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getThunkError = async (thunk: Thunk) => {
|
|
||||||
const result = await thunk.result
|
|
||||||
const [{status, message}] = Object.values(result) as any
|
|
||||||
|
|
||||||
if (status !== PublishStatus.Success) {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
|
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const nevent = nip19.neventEncode({
|
const nevent = nip19.neventEncode({
|
||||||
id: parent.id,
|
id: parent.id,
|
||||||
kind: parent.kind,
|
kind: parent.kind,
|
||||||
author: parent.pubkey,
|
author: parent.pubkey,
|
||||||
relays: ctx.app.router.Event(parent).limit(3).getUrls(),
|
relays: Router.get().Event(parent).limit(3).getUrls(),
|
||||||
})
|
})
|
||||||
|
|
||||||
tags = [...tags, tagEventForQuote(parent)]
|
tags = [...tags, tagEventForQuote(parent)]
|
||||||
@@ -115,38 +99,6 @@ export const prependParent = (parent: TrustedEvent | undefined, {content, tags}:
|
|||||||
return {content, tags}
|
return {content, tags}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log in
|
|
||||||
|
|
||||||
export const loginWithNip46 = async ({
|
|
||||||
relays,
|
|
||||||
signerPubkey,
|
|
||||||
clientSecret = makeSecret(),
|
|
||||||
connectSecret = "",
|
|
||||||
}: {
|
|
||||||
relays: string[]
|
|
||||||
signerPubkey: string
|
|
||||||
clientSecret?: string
|
|
||||||
connectSecret?: string
|
|
||||||
}) => {
|
|
||||||
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
|
|
||||||
const result = await broker.connect(connectSecret, NIP46_PERMS)
|
|
||||||
|
|
||||||
// TODO: remove ack result
|
|
||||||
if (!["ack", connectSecret].includes(result)) return false
|
|
||||||
|
|
||||||
const pubkey = await broker.getPublicKey()
|
|
||||||
|
|
||||||
if (!pubkey) return false
|
|
||||||
|
|
||||||
await loadUserData(pubkey)
|
|
||||||
|
|
||||||
const handler = {relays, pubkey: signerPubkey}
|
|
||||||
|
|
||||||
addSession({method: "nip46", pubkey, secret: clientSecret, handler})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log out
|
// Log out
|
||||||
|
|
||||||
export const logout = async () => {
|
export const logout = async () => {
|
||||||
@@ -161,47 +113,6 @@ export const logout = async () => {
|
|||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loaders
|
|
||||||
|
|
||||||
export const loadUserData = (
|
|
||||||
pubkey: string,
|
|
||||||
request: Partial<SubscribeRequestWithHandlers> = {},
|
|
||||||
) => {
|
|
||||||
const promise = Promise.race([
|
|
||||||
sleep(3000),
|
|
||||||
Promise.all([
|
|
||||||
loadInboxRelaySelections(pubkey, request),
|
|
||||||
loadMembership(pubkey, request),
|
|
||||||
loadSettings(pubkey, request),
|
|
||||||
loadProfile(pubkey, request),
|
|
||||||
loadFollows(pubkey, request),
|
|
||||||
loadMutes(pubkey, request),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Load followed profiles slowly in the background without clogging other stuff up. Only use a single
|
|
||||||
// indexer relay to avoid too many redundant validations, which slow things down and eat bandwidth
|
|
||||||
promise.then(async () => {
|
|
||||||
for (const pubkeys of chunk(50, getDefaultPubkeys())) {
|
|
||||||
const relays = sample(1, INDEXER_RELAYS)
|
|
||||||
|
|
||||||
await sleep(1000)
|
|
||||||
|
|
||||||
for (const pubkey of pubkeys) {
|
|
||||||
loadMembership(pubkey, {relays})
|
|
||||||
loadProfile(pubkey, {relays})
|
|
||||||
loadFollows(pubkey, {relays})
|
|
||||||
loadMutes(pubkey, {relays})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
export const discoverRelays = (lists: List[]) =>
|
|
||||||
Promise.all(uniq(lists.flatMap(getRelayUrls)).filter(isShareableRelayUrl).map(loadRelay))
|
|
||||||
|
|
||||||
// Synchronization
|
// Synchronization
|
||||||
|
|
||||||
export const broadcastUserData = async (relays: string[]) => {
|
export const broadcastUserData = async (relays: string[]) => {
|
||||||
@@ -216,77 +127,42 @@ export const broadcastUserData = async (relays: string[]) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// List updates
|
||||||
|
|
||||||
export const addSpaceMembership = async (url: string) => {
|
export const addSpaceMembership = async (url: string) => {
|
||||||
const list = get(userMembership) || makeList({kind: GROUPS})
|
const list = get(userMembership) || makeList({kind: ROOMS})
|
||||||
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
|
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
|
||||||
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const removeSpaceMembership = async (url: string) => {
|
export const removeSpaceMembership = async (url: string) => {
|
||||||
const list = get(userMembership) || makeList({kind: GROUPS})
|
const list = get(userMembership) || makeList({kind: ROOMS})
|
||||||
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
|
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
|
||||||
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
||||||
const relays = uniq([
|
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||||
url,
|
|
||||||
...ctx.app.router.FromUser().getUrls(),
|
|
||||||
...getRelayTagValues(event.tags),
|
|
||||||
])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addRoomMembership = async (url: string, room: string, name: string) => {
|
export const addRoomMembership = async (url: string, room: string) => {
|
||||||
const list = get(userMembership) || makeList({kind: GROUPS})
|
const list = get(userMembership) || makeList({kind: ROOMS})
|
||||||
const newTags = [
|
const newTags = [
|
||||||
["r", url],
|
["r", url],
|
||||||
["group", room, url, name],
|
["group", room, url],
|
||||||
]
|
]
|
||||||
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
|
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
|
||||||
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const removeRoomMembership = async (url: string, room: string) => {
|
export const removeRoomMembership = async (url: string, room: string) => {
|
||||||
const list = get(userMembership) || makeList({kind: GROUPS})
|
const list = get(userMembership) || makeList({kind: ROOMS})
|
||||||
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
|
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
|
||||||
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
||||||
const relays = uniq([
|
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||||
url,
|
|
||||||
...ctx.app.router.FromUser().getUrls(),
|
|
||||||
...getRelayTagValues(event.tags),
|
|
||||||
])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
@@ -304,11 +180,11 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return publishThunk({
|
return publishThunk({
|
||||||
event: createEvent(list.kind, {tags}),
|
event: makeEvent(list.kind, {tags}),
|
||||||
relays: [
|
relays: [
|
||||||
url,
|
url,
|
||||||
...INDEXER_RELAYS,
|
...INDEXER_RELAYS,
|
||||||
...ctx.app.router.FromUser().getUrls(),
|
...Router.get().FromUser().getUrls(),
|
||||||
...userRoomsByUrl.get().keys(),
|
...userRoomsByUrl.get().keys(),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -318,7 +194,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
|
|||||||
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
|
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
|
||||||
|
|
||||||
// Only update inbox policies if they already exist or we're adding them
|
// Only update inbox policies if they already exist or we're adding them
|
||||||
if (enabled || getRelayUrls(list).includes(url)) {
|
if (enabled || getRelaysFromList(list).includes(url)) {
|
||||||
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
|
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
@@ -326,10 +202,10 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return publishThunk({
|
return publishThunk({
|
||||||
event: createEvent(list.kind, {tags}),
|
event: makeEvent(list.kind, {tags}),
|
||||||
relays: [
|
relays: [
|
||||||
...INDEXER_RELAYS,
|
...INDEXER_RELAYS,
|
||||||
...ctx.app.router.FromUser().getUrls(),
|
...Router.get().FromUser().getUrls(),
|
||||||
...userRoomsByUrl.get().keys(),
|
...userRoomsByUrl.get().keys(),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -338,29 +214,40 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
|
|||||||
|
|
||||||
// Relay access
|
// Relay access
|
||||||
|
|
||||||
export const checkRelayAccess = async (url: string, claim = "") => {
|
export const attemptAuth = (url: string) =>
|
||||||
const connection = ctx.net.pool.get(url)
|
Pool.get()
|
||||||
|
.get(url)
|
||||||
|
.auth.attemptAuth(e => signer.get()?.sign(e))
|
||||||
|
|
||||||
await connection.auth.attempt(5000)
|
export const checkRelayAccess = async (url: string, claim = "") => {
|
||||||
|
const socket = Pool.get().get(url)
|
||||||
|
|
||||||
|
await attemptAuth(url)
|
||||||
|
|
||||||
const thunk = publishThunk({
|
const thunk = publishThunk({
|
||||||
event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
|
event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
|
||||||
relays: [url],
|
relays: [url],
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await thunk.result
|
const error = await getThunkError(thunk)
|
||||||
|
|
||||||
if (result[url].status === PublishStatus.Failure) {
|
if (error) {
|
||||||
const message =
|
const message =
|
||||||
connection.auth.message?.replace(/^.*: /, "") ||
|
socket.auth.details?.replace(/^\w+: /, "") ||
|
||||||
result[url].message?.replace(/^.*: /, "") ||
|
error?.replace(/^\w+: /, "") ||
|
||||||
"join request rejected"
|
"join request rejected"
|
||||||
|
|
||||||
// If it's a strict NIP 29 relay don't worry about requesting access
|
// If it's a strict NIP 29 relay don't worry about requesting access
|
||||||
// TODO: remove this if relay29 ever gets less strict
|
// TODO: remove this if relay29 ever gets less strict
|
||||||
if (message !== "missing group (`h`) tag") {
|
if (message === "missing group (`h`) tag") return
|
||||||
return `Failed to join relay (${message})`
|
|
||||||
}
|
// Ignore messages about the relay ignoring ours
|
||||||
|
if (error?.startsWith("mute: ")) return
|
||||||
|
|
||||||
|
// Ignore rejected empty claims
|
||||||
|
if (!claim && error?.includes("invite code")) return
|
||||||
|
|
||||||
|
return message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,25 +260,30 @@ export const checkRelayProfile = async (url: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const checkRelayConnection = async (url: string) => {
|
export const checkRelayConnection = async (url: string) => {
|
||||||
const connection = ctx.net.pool.get(url)
|
const socket = Pool.get().get(url)
|
||||||
|
|
||||||
await connection.socket.open()
|
socket.attemptToOpen()
|
||||||
|
|
||||||
if (connection.socket.status !== SocketStatus.Open) {
|
await poll({
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
condition: () => socket.status === SocketStatus.Open,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (socket.status !== SocketStatus.Open) {
|
||||||
return `Failed to connect`
|
return `Failed to connect`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkRelayAuth = async (url: string, timeout = 3000) => {
|
export const checkRelayAuth = async (url: string, timeout = 3000) => {
|
||||||
const connection = ctx.net.pool.get(url)
|
const socket = Pool.get().get(url)
|
||||||
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
|
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
|
||||||
|
|
||||||
await connection.auth.attempt(timeout)
|
await attemptAuth(url)
|
||||||
|
|
||||||
// Only raise an error if it's not a timeout.
|
// 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 it is, odds are the problem is with our signer, not the relay
|
||||||
if (!okStatuses.includes(connection.auth.status) && connection.auth.message) {
|
if (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
|
||||||
return `Failed to authenticate (${connection.auth.message})`
|
return `Failed to authenticate (${socket.auth.details})`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,42 +305,26 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
export const sendWrapped = async ({
|
export const makeDelete = ({event, tags = []}: {event: TrustedEvent; tags?: string[][]}) => {
|
||||||
template,
|
const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags]
|
||||||
pubkeys,
|
|
||||||
delay,
|
|
||||||
}: {
|
|
||||||
template: EventTemplate
|
|
||||||
pubkeys: string[]
|
|
||||||
delay?: number
|
|
||||||
}) => {
|
|
||||||
const nip59 = Nip59.fromSigner(signer.get()!)
|
|
||||||
|
|
||||||
return publishThunks(
|
|
||||||
await Promise.all(
|
|
||||||
uniq(pubkeys).map(async recipient => ({
|
|
||||||
event: await nip59.wrap(recipient, stamp(template)),
|
|
||||||
relays: ctx.app.router.PubkeyInbox(recipient).getUrls(),
|
|
||||||
delay,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeDelete = ({event}: {event: TrustedEvent}) => {
|
|
||||||
const tags = [["k", String(event.kind)], ...tagEvent(event)]
|
|
||||||
const groupTag = getTag("h", event.tags)
|
const groupTag = getTag("h", event.tags)
|
||||||
|
|
||||||
if (groupTag) {
|
if (groupTag) {
|
||||||
tags.push(PROTECTED)
|
thisTags.push(PROTECTED, groupTag)
|
||||||
tags.push(groupTag)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return createEvent(DELETE, {tags})
|
return makeEvent(DELETE, {tags: thisTags})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
|
export const publishDelete = ({
|
||||||
publishThunk({event: makeDelete({event}), relays})
|
relays,
|
||||||
|
event,
|
||||||
|
tags = [],
|
||||||
|
}: {
|
||||||
|
relays: string[]
|
||||||
|
event: TrustedEvent
|
||||||
|
tags?: string[][]
|
||||||
|
}) => publishThunk({event: makeDelete({event, tags}), relays})
|
||||||
|
|
||||||
export type ReportParams = {
|
export type ReportParams = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
@@ -462,7 +338,7 @@ export const makeReport = ({event, reason, content}: ReportParams) => {
|
|||||||
["e", event.id, reason],
|
["e", event.id, reason],
|
||||||
]
|
]
|
||||||
|
|
||||||
return createEvent(REPORT, {content, tags})
|
return makeEvent(REPORT, {content, tags})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const publishReport = ({
|
export const publishReport = ({
|
||||||
@@ -476,10 +352,12 @@ export const publishReport = ({
|
|||||||
export type ReactionParams = {
|
export type ReactionParams = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
content: string
|
content: string
|
||||||
|
tags?: string[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeReaction = ({event, content}: ReactionParams) => {
|
export const makeReaction = ({content, event, tags: paramTags = []}: ReactionParams) => {
|
||||||
const tags = tagEventForReaction(event)
|
const tags = [...paramTags, ...tagEventForReaction(event)]
|
||||||
|
|
||||||
const groupTag = getTag("h", event.tags)
|
const groupTag = getTag("h", event.tags)
|
||||||
|
|
||||||
if (groupTag) {
|
if (groupTag) {
|
||||||
@@ -487,7 +365,7 @@ export const makeReaction = ({event, content}: ReactionParams) => {
|
|||||||
tags.push(groupTag)
|
tags.push(groupTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
return createEvent(REACTION, {content, tags})
|
return makeEvent(REACTION, {content, tags})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
|
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
|
||||||
@@ -500,7 +378,87 @@ export type CommentParams = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const makeComment = ({event, content, tags = []}: CommentParams) =>
|
export const makeComment = ({event, content, tags = []}: CommentParams) =>
|
||||||
createEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
|
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
|
||||||
|
|
||||||
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
|
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
|
||||||
publishThunk({event: makeComment(params), relays})
|
publishThunk({event: makeComment(params), relays})
|
||||||
|
|
||||||
|
export type AlertParams = {
|
||||||
|
feed: Feed
|
||||||
|
description: string
|
||||||
|
claims: Record<string, string>
|
||||||
|
email?: {
|
||||||
|
cron: string
|
||||||
|
email: string
|
||||||
|
handler: string[]
|
||||||
|
}
|
||||||
|
web?: {
|
||||||
|
endpoint: string
|
||||||
|
p256dh: string
|
||||||
|
auth: string
|
||||||
|
}
|
||||||
|
ios?: {
|
||||||
|
device_token: string
|
||||||
|
bundle_identifier: string
|
||||||
|
}
|
||||||
|
android?: {
|
||||||
|
device_token: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeAlert = async (params: AlertParams) => {
|
||||||
|
const tags = [
|
||||||
|
["feed", JSON.stringify(params.feed)],
|
||||||
|
["locale", LOCALE],
|
||||||
|
["timezone", TIMEZONE],
|
||||||
|
["description", params.description],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [relay, claim] of Object.entries(params.claims)) {
|
||||||
|
tags.push(["claim", relay, claim])
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind: number
|
||||||
|
if (params.email) {
|
||||||
|
kind = ALERT_EMAIL
|
||||||
|
tags.push(...Object.entries(params.email).map(flatten))
|
||||||
|
} else if (params.web) {
|
||||||
|
kind = ALERT_WEB
|
||||||
|
tags.push(...Object.entries(params.web).map(flatten))
|
||||||
|
} else if (params.ios) {
|
||||||
|
kind = ALERT_IOS
|
||||||
|
tags.push(...Object.entries(params.ios).map(flatten))
|
||||||
|
} else if (params.android) {
|
||||||
|
kind = ALERT_ANDROID
|
||||||
|
tags.push(...Object.entries(params.android).map(flatten))
|
||||||
|
} else {
|
||||||
|
throw new Error("Alert has invalid params")
|
||||||
|
}
|
||||||
|
|
||||||
|
return makeEvent(kind, {
|
||||||
|
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]})
|
||||||
|
|
||||||
|
export const payInvoice = async (invoice: string) => {
|
||||||
|
const $wallet = get(wallet)
|
||||||
|
|
||||||
|
if (!$wallet) {
|
||||||
|
throw new Error("No wallet is connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wallet.type === "nwc") {
|
||||||
|
return new nwc.NWCClient($wallet.info).payInvoice({invoice})
|
||||||
|
} else if ($wallet.type === "webln") {
|
||||||
|
return getWebLn()
|
||||||
|
.enable()
|
||||||
|
.then(() => getWebLn().sendPayment(invoice))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import {preventDefault} from "@lib/html"
|
||||||
|
import {decrypt} from "@welshman/signer"
|
||||||
|
import {randomInt, parseJson, fromPairs, displayList, TIMEZONE, identity} from "@welshman/lib"
|
||||||
|
import {
|
||||||
|
displayRelayUrl,
|
||||||
|
getTagValue,
|
||||||
|
getAddress,
|
||||||
|
THREAD,
|
||||||
|
MESSAGE,
|
||||||
|
EVENT_TIME,
|
||||||
|
COMMENT,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {Filter} from "@welshman/util"
|
||||||
|
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
|
||||||
|
import {pubkey, signer, getThunkError} from "@welshman/app"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import {
|
||||||
|
alerts,
|
||||||
|
getMembershipUrls,
|
||||||
|
userMembership,
|
||||||
|
NOTIFIER_PUBKEY,
|
||||||
|
NOTIFIER_RELAY,
|
||||||
|
} from "@app/state"
|
||||||
|
import {loadAlertStatuses, requestRelayClaim} from "@app/requests"
|
||||||
|
import {publishAlert, attemptAuth} from "@app/commands"
|
||||||
|
import type {AlertParams} from "@app/commands"
|
||||||
|
import {platform, platformName, canSendPushNotifications, getPushInfo} from "@app/push"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
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.slice(3)) / 100
|
||||||
|
const minute = randomInt(0, 59)
|
||||||
|
const hour = (17 - timezoneOffset) % 24
|
||||||
|
const WEEKLY = `0 ${minute} ${hour} * * 1`
|
||||||
|
const DAILY = `0 ${minute} ${hour} * * *`
|
||||||
|
|
||||||
|
let loading = $state(false)
|
||||||
|
let cron = $state(WEEKLY)
|
||||||
|
let claim = $state("")
|
||||||
|
let email = $state($alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || "")
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (channel === "email" && !email.includes("@")) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Please provide an email address",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Please select a space",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!notifyThreads && !notifyCalendar && !notifyChat) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Please select something to be notified about",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: Filter[] = []
|
||||||
|
const display: string[] = []
|
||||||
|
|
||||||
|
if (notifyThreads) {
|
||||||
|
display.push("threads")
|
||||||
|
filters.push({kinds: [THREAD]})
|
||||||
|
filters.push({kinds: [COMMENT], "#k": [String(THREAD)]})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notifyCalendar) {
|
||||||
|
display.push("calendar events")
|
||||||
|
filters.push({kinds: [EVENT_TIME]})
|
||||||
|
filters.push({kinds: [COMMENT], "#k": [String(EVENT_TIME)]})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notifyChat) {
|
||||||
|
display.push("chat")
|
||||||
|
filters.push({kinds: [MESSAGE]})
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const claims = claim ? {[url]: claim} : {}
|
||||||
|
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url))
|
||||||
|
const description = `for ${displayList(display)} on ${displayRelayUrl(url)}`
|
||||||
|
const params: AlertParams = {feed, claims, description}
|
||||||
|
|
||||||
|
if (channel === "email") {
|
||||||
|
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
|
||||||
|
|
||||||
|
params.description = `${cadence} alert ${description}, sent via email.`
|
||||||
|
params.email = {
|
||||||
|
cron,
|
||||||
|
email,
|
||||||
|
handler: [
|
||||||
|
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
|
||||||
|
"wss://relay.nostr.band/",
|
||||||
|
"web",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
params[platform] = await getPushInfo()
|
||||||
|
params.description = `${platformName} push notification ${description}.`
|
||||||
|
} catch (e: any) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: String(e),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we don't do this we'll get an event rejection
|
||||||
|
await attemptAuth(NOTIFIER_RELAY)
|
||||||
|
|
||||||
|
const thunk = await publishAlert(params)
|
||||||
|
const error = await getThunkError(thunk)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: `Failed to send your alert to the notification server (${error}).`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch our new status to make sure it's active
|
||||||
|
const address = getAddress(thunk.event)
|
||||||
|
const statusEvents = await loadAlertStatuses($pubkey!)
|
||||||
|
const statusEvent = statusEvents.find(event => getTagValue("d", event.tags) === address)
|
||||||
|
const statusTags = statusEvent
|
||||||
|
? parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, statusEvent.content))
|
||||||
|
: []
|
||||||
|
const {status = "error", message = "Your alert was not activated"}: Record<string, string> =
|
||||||
|
fromPairs(statusTags)
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
return pushToast({theme: "error", message})
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast({message: "Your alert has been successfully created!"})
|
||||||
|
back()
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!canSendPushNotifications()) {
|
||||||
|
channel = "email"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
requestRelayClaim(url).then(code => {
|
||||||
|
if (code) {
|
||||||
|
claim = code
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="column gap-4" onsubmit={preventDefault(submit)}>
|
||||||
|
<ModalHeader>
|
||||||
|
{#snippet title()}
|
||||||
|
Add an Alert
|
||||||
|
{/snippet}
|
||||||
|
</ModalHeader>
|
||||||
|
{#if canSendPushNotifications()}
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Alert Type*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<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>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Email Address*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<input placeholder="email@example.com" bind:value={email} />
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Frequency*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select bind:value={cron} class="select select-bordered">
|
||||||
|
<option value={WEEKLY}>Weekly</option>
|
||||||
|
<option value={DAILY}>Daily</option>
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{/if}
|
||||||
|
{#if !hideSpaceField}
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Space*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select bind:value={url} class="select select-bordered">
|
||||||
|
<option value="" disabled selected>Choose a space URL</option>
|
||||||
|
{#each getMembershipUrls($userMembership) as url (url)}
|
||||||
|
<option value={url}>{displayRelayUrl(url)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{/if}
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Notifications*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<div class="flex items-center justify-end gap-4">
|
||||||
|
<span class="flex gap-3">
|
||||||
|
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
|
||||||
|
Threads
|
||||||
|
</span>
|
||||||
|
<span class="flex gap-3">
|
||||||
|
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
|
||||||
|
Calendar
|
||||||
|
</span>
|
||||||
|
<span class="flex gap-3">
|
||||||
|
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
|
||||||
|
Chat
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Invite Code</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<input bind:value={claim} />
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
<p>
|
||||||
|
To get notifications from private spaces, please provide an invite code which grants access
|
||||||
|
to the space.
|
||||||
|
</p>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon="alt-arrow-left" />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||||
|
<Spinner {loading}>Confirm</Spinner>
|
||||||
|
<Icon icon="alt-arrow-right" />
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
|
import type {Alert} from "@app/state"
|
||||||
|
import {NOTIFIER_RELAY, NOTIFIER_PUBKEY} from "@app/state"
|
||||||
|
import {publishDelete} from "@app/commands"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
alert: Alert
|
||||||
|
}
|
||||||
|
|
||||||
|
const {alert}: Props = $props()
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY], tags: [["p", NOTIFIER_PUBKEY]]})
|
||||||
|
pushToast({message: "Your alert has been deleted!"})
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Confirm {confirm} message="You'll no longer receive messages for this alert." />
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {parseJson} from "@welshman/lib"
|
||||||
|
import {displayFeeds} from "@welshman/feeds"
|
||||||
|
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import AlertDelete from "@app/components/AlertDelete.svelte"
|
||||||
|
import type {Alert} from "@app/state"
|
||||||
|
import {deriveAlertStatus} from "@app/state"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
alert: Alert
|
||||||
|
}
|
||||||
|
|
||||||
|
const {alert}: Props = $props()
|
||||||
|
|
||||||
|
const status = deriveAlertStatus(getAddress(alert.event))
|
||||||
|
const cron = $derived(getTagValue("cron", alert.tags))
|
||||||
|
const channel = $derived(getTagValue("channel", alert.tags))
|
||||||
|
const feeds = $derived(getTagValues("feed", alert.tags))
|
||||||
|
const description = $derived(
|
||||||
|
getTagValue("description", alert.tags) ||
|
||||||
|
[
|
||||||
|
`${cron?.endsWith("1") ? "Weekly" : "Daily"} alert for events`,
|
||||||
|
displayFeeds(feeds.map(parseJson)),
|
||||||
|
`sent via ${channel}.`,
|
||||||
|
].join(" "),
|
||||||
|
)
|
||||||
|
|
||||||
|
const startDelete = () => pushModal(AlertDelete, {alert})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<Button class="py-1" onclick={startDelete}>
|
||||||
|
<Icon icon="trash-bin-2" />
|
||||||
|
</Button>
|
||||||
|
<div class="flex-inline gap-1">{description}</div>
|
||||||
|
</div>
|
||||||
|
{#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}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {getTagValue} from "@welshman/util"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import AlertAdd from "@app/components/AlertAdd.svelte"
|
||||||
|
import AlertItem from "@app/components/AlertItem.svelte"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {alerts} from "@app/state"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url?: string
|
||||||
|
channel?: string
|
||||||
|
hideSpaceField?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
|
||||||
|
|
||||||
|
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
|
||||||
|
|
||||||
|
const filteredAlerts = $derived(
|
||||||
|
url ? $alerts.filter(a => getTagValue("feed", a.tags)?.includes(url)) : $alerts,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<strong class="flex items-center gap-3">
|
||||||
|
<Icon icon="inbox" />
|
||||||
|
Alerts
|
||||||
|
</strong>
|
||||||
|
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
|
||||||
|
<Icon icon="add-circle" />
|
||||||
|
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>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<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">
|
||||||
|
import {onMount, onDestroy} from "svelte"
|
||||||
|
import {slideAndFade} from "@lib/transition"
|
||||||
|
import QRCode from "@app/components/QRCode.svelte"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
controller: BunkerConnectController
|
||||||
|
}
|
||||||
|
|
||||||
|
const {controller}: Props = $props()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
controller.start()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
controller.stop()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if controller.url}
|
||||||
|
<div class="flex justify-center" out:slideAndFade>
|
||||||
|
<QRCode code={controller.url} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import InfoBunker from "@app/components/InfoBunker.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bunker: string
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {loading, bunker = $bindable("")}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Bunker Link*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<Icon icon="cpu" />
|
||||||
|
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
<p>
|
||||||
|
A login link provided by a nostr signing app.
|
||||||
|
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
|
||||||
|
</p>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {pubkey} from "@welshman/app"
|
import {pubkey} from "@welshman/app"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.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 {publishDelete, publishReaction} from "@app/commands"
|
import {publishDelete, publishReaction} from "@app/commands"
|
||||||
import {makeCalendarPath} from "@app/routes"
|
import {makeCalendarPath} from "@app/routes"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
url,
|
url,
|
||||||
@@ -20,24 +24,32 @@
|
|||||||
|
|
||||||
const path = makeCalendarPath(url, event.id)
|
const path = makeCalendarPath(url, event.id)
|
||||||
|
|
||||||
const onReactionClick = (content: string, events: TrustedEvent[]) => {
|
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
|
||||||
const reaction = events.find(e => e.pubkey === $pubkey)
|
|
||||||
|
|
||||||
if (reaction) {
|
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
|
||||||
publishDelete({relays: [url], event: reaction})
|
|
||||||
} else {
|
const createReaction = (template: EventContent) =>
|
||||||
publishReaction({event, content, relays: [url]})
|
publishReaction({...template, event, relays: [url]})
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<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">
|
||||||
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-left" />
|
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||||
<ThunkStatusOrDeleted {event} />
|
<ThunkStatusOrDeleted {event} />
|
||||||
{#if showActivity}
|
{#if showActivity}
|
||||||
<EventActivity {url} {path} {event} />
|
<EventActivity {url} {path} {event} />
|
||||||
{/if}
|
{/if}
|
||||||
<EventActions {url} {event} noun="Event" />
|
<EventActions {url} {event} noun="Event">
|
||||||
|
{#snippet customActions()}
|
||||||
|
{#if event.pubkey === $pubkey}
|
||||||
|
<li>
|
||||||
|
<Button onclick={editEvent}>
|
||||||
|
<Icon size={4} icon="pen" />
|
||||||
|
Edit Event
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</EventActions>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,164 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {writable} from "svelte/store"
|
|
||||||
import {randomId, HOUR} from "@welshman/lib"
|
|
||||||
import {createEvent, EVENT_TIME} from "@welshman/util"
|
|
||||||
import {publishThunk} from "@welshman/app"
|
|
||||||
import {preventDefault} from "@lib/html"
|
|
||||||
import {daysBetween} from "@lib/util"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Field from "@lib/components/Field.svelte"
|
|
||||||
import Button from "@lib/components/Button.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 CalendarEventForm from "@app/components/CalendarEventForm.svelte"
|
||||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
|
||||||
import {PROTECTED, GENERAL, tagRoom} from "@app/state"
|
|
||||||
import {makeEditor} from "@app/editor"
|
|
||||||
import {pushToast} from "@app/toast"
|
|
||||||
|
|
||||||
const {url} = $props()
|
type Props = {
|
||||||
|
url: string
|
||||||
const uploading = writable(false)
|
|
||||||
|
|
||||||
const back = () => history.back()
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
if ($uploading) return
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: "Please provide a title.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!start || !end) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: "Please provide start and end times.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (start >= end) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: "End time must be later than start time.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = createEvent(EVENT_TIME, {
|
|
||||||
content: editor.getText({blockSeparator: "\n"}).trim(),
|
|
||||||
tags: [
|
|
||||||
["d", randomId()],
|
|
||||||
["title", title],
|
|
||||||
["location", location],
|
|
||||||
["start", start.toString()],
|
|
||||||
["end", end.toString()],
|
|
||||||
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
|
||||||
...editor.storage.nostr.getEditorTags(),
|
|
||||||
tagRoom(GENERAL, url),
|
|
||||||
PROTECTED,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
pushToast({message: "Your event has been published!"})
|
|
||||||
publishThunk({event, relays: [url]})
|
|
||||||
history.back()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = makeEditor({submit, uploading})
|
const {url}: Props = $props()
|
||||||
|
|
||||||
let title = $state("")
|
|
||||||
let location = $state("")
|
|
||||||
let start: number | undefined = $state()
|
|
||||||
let end: number | undefined = $state()
|
|
||||||
let endDirty = false
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!endDirty && start) {
|
|
||||||
end = start + HOUR
|
|
||||||
} else if (end) {
|
|
||||||
endDirty = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="column gap-4" onsubmit={preventDefault(submit)}>
|
<CalendarEventForm {url}>
|
||||||
<ModalHeader>
|
{#snippet header()}
|
||||||
{#snippet title()}
|
<ModalHeader>
|
||||||
<div>Create an Event</div>
|
{#snippet title()}
|
||||||
{/snippet}
|
<div>Create an Event</div>
|
||||||
{#snippet info()}
|
{/snippet}
|
||||||
<div>Invite other group members to events online or in real life.</div>
|
{#snippet info()}
|
||||||
{/snippet}
|
<div>Invite other group members to events online or in real life.</div>
|
||||||
</ModalHeader>
|
{/snippet}
|
||||||
<Field>
|
</ModalHeader>
|
||||||
{#snippet label()}
|
{/snippet}
|
||||||
<p>Title*</p>
|
</CalendarEventForm>
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
|
||||||
<input bind:value={title} class="grow" type="text" />
|
|
||||||
</label>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Summary</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<div class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
|
||||||
<div class="input-editor flex-grow overflow-hidden">
|
|
||||||
<EditorContent {editor} />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
data-tip="Add an image"
|
|
||||||
class="center btn tooltip"
|
|
||||||
onclick={() => editor.chain().selectFiles().run()}>
|
|
||||||
{#if $uploading}
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
|
||||||
{:else}
|
|
||||||
<Icon icon="gallery-send" />
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
{#snippet label()}
|
|
||||||
Start*
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<DateTimeInput bind:value={start} />
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
{#snippet label()}
|
|
||||||
End*
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<DateTimeInput bind:value={end} />
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Location (optional)</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
|
||||||
<Icon icon="map-point" />
|
|
||||||
<input bind:value={location} class="grow" type="text" />
|
|
||||||
</label>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button class="btn btn-link" onclick={back}>
|
|
||||||
<Icon icon="alt-arrow-left" />
|
|
||||||
Go back
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
|
|
||||||
<Spinner loading={$uploading}>Create Event</Spinner>
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</form>
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {fromPairs} from "@welshman/lib"
|
import {fromPairs, LOCALE, secondsToDate} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {LOCALE, secondsToDate} from "@welshman/app"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
@@ -13,7 +12,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex h-14 w-14 flex-col items-center justify-center gap-1 rounded-box border border-solid border-base-content p-2 sm:h-24 sm:w-24">
|
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="sm:text-lg">{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</span>
|
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
|
||||||
<span class="sm:text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
|
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
|
||||||
|
<span class="text-xs opacity-75"
|
||||||
|
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getTagValue} from "@welshman/util"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
d: getTagValue("d", event.tags)!,
|
||||||
|
title: getTagValue("title", event.tags)!,
|
||||||
|
location: getTagValue("location", event.tags)!,
|
||||||
|
start: parseInt(getTagValue("start", event.tags)!),
|
||||||
|
end: parseInt(getTagValue("end", event.tags)!),
|
||||||
|
content: event.content,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarEventForm {url} {initialValues}>
|
||||||
|
{#snippet header()}
|
||||||
|
<ModalHeader>
|
||||||
|
{#snippet title()}
|
||||||
|
<div>Edit this Event</div>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
<div>Invite other group members to events online or in real life.</div>
|
||||||
|
{/snippet}
|
||||||
|
</ModalHeader>
|
||||||
|
{/snippet}
|
||||||
|
</CalendarEventForm>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
|
import {writable} from "svelte/store"
|
||||||
|
import {randomId, HOUR} from "@welshman/lib"
|
||||||
|
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
||||||
|
import {publishThunk} from "@welshman/app"
|
||||||
|
import {preventDefault} from "@lib/html"
|
||||||
|
import {daysBetween} from "@lib/util"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||||
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
|
import {PROTECTED} from "@app/state"
|
||||||
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
header: Snippet
|
||||||
|
initialValues?: {
|
||||||
|
d: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
location: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, header, initialValues}: Props = $props()
|
||||||
|
|
||||||
|
const uploading = writable(false)
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if ($uploading) return
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Please provide a title.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!start || !end) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Please provide start and end times.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start >= end) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "End time must be later than start time.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ed = await editor
|
||||||
|
const event = makeEvent(EVENT_TIME, {
|
||||||
|
content: ed.getText({blockSeparator: "\n"}).trim(),
|
||||||
|
tags: [
|
||||||
|
["d", initialValues?.d || randomId()],
|
||||||
|
["title", title],
|
||||||
|
["location", location || ""],
|
||||||
|
["start", start.toString()],
|
||||||
|
["end", end.toString()],
|
||||||
|
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
||||||
|
...ed.storage.nostr.getEditorTags(),
|
||||||
|
PROTECTED,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
pushToast({message: "Your event has been saved!"})
|
||||||
|
publishThunk({event, relays: [url]})
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = initialValues?.content || ""
|
||||||
|
const editor = makeEditor({url, submit, uploading, content})
|
||||||
|
|
||||||
|
let title = $state(initialValues?.title || "")
|
||||||
|
let location = $state(initialValues?.location || "")
|
||||||
|
let start: number | undefined = $state(initialValues?.start)
|
||||||
|
let end: number | undefined = $state(initialValues?.end)
|
||||||
|
let endDirty = Boolean(initialValues?.end)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!endDirty && start) {
|
||||||
|
end = start + HOUR
|
||||||
|
} else if (end) {
|
||||||
|
endDirty = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form novalidate class="column gap-4" onsubmit={preventDefault(submit)}>
|
||||||
|
{@render header()}
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Title*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<input bind:value={title} class="grow" type="text" />
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Summary</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<div class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
||||||
|
<div class="input-editor flex-grow overflow-hidden">
|
||||||
|
<EditorContent {editor} />
|
||||||
|
</div>
|
||||||
|
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
|
||||||
|
{#if $uploading}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Icon icon="gallery-send" />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
Start*
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<DateTimeInput bind:value={start} />
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
End*
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<DateTimeInput bind:value={end} />
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Location (optional)</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<Icon icon="map-point" />
|
||||||
|
<input bind:value={location} class="grow" type="text" />
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon="alt-arrow-left" />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
|
||||||
|
<Spinner loading={$uploading}>Save Event</Spinner>
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {fromPairs} from "@welshman/lib"
|
import {
|
||||||
|
fromPairs,
|
||||||
|
formatTimestamp,
|
||||||
|
formatTimestampAsDate,
|
||||||
|
formatTimestampAsTime,
|
||||||
|
} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/app"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
@@ -17,8 +21,13 @@
|
|||||||
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
|
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p class="text-xl">{meta.title || meta.name}</p>
|
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<p class="text-xl">{meta.title || meta.name}</p>
|
||||||
<Icon icon="clock-circle" size={4} />
|
<div class="flex items-center gap-2 text-sm">
|
||||||
{formatTimestampAsTime(start)} — {isSingleDay ? formatTimestampAsTime(end) : formatTimestamp(end)}
|
<Icon icon="clock-circle" size={4} />
|
||||||
|
<span class="sm:hidden">{formatTimestampAsDate(start)}</span>
|
||||||
|
{formatTimestampAsTime(start)} — {isSingleDay
|
||||||
|
? formatTimestampAsTime(end)
|
||||||
|
: formatTimestamp(end)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,12 +15,10 @@
|
|||||||
</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" href={makeCalendarPath(url, event.id)}>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<CalendarEventHeader {event} />
|
||||||
<CalendarEventHeader {event} />
|
|
||||||
</div>
|
|
||||||
<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} />
|
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
</span>
|
</span>
|
||||||
<CalendarEventActions showActivity {url} {event} />
|
<CalendarEventActions showActivity {url} {event} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,19 +6,22 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const {event}: Props = $props()
|
const {event, url}: Props = $props()
|
||||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span>
|
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
|
||||||
Posted by <ProfileLink pubkey={event.pubkey} />
|
|
||||||
</span>
|
|
||||||
{#if meta.location}
|
|
||||||
<span>•</span>
|
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<Icon icon="map-point" size={4} />
|
<Icon icon="user-circle" size={4} />
|
||||||
{meta.location}
|
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{#if meta.location}
|
||||||
|
<span class="flex items-start gap-1">
|
||||||
|
<Icon icon="map-point" class="mt-[2px]" size={4} />
|
||||||
|
<span class="break-words">{meta.location}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
|
import type {EventContent} from "@welshman/util"
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
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"
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
onSubmit: any
|
url?: string
|
||||||
|
onSubmit: (event: EventContent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const {onSubmit}: Props = $props()
|
const {onSubmit, url}: Props = $props()
|
||||||
|
|
||||||
const autofocus = !isMobile
|
const autofocus = !isMobile
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
|
||||||
export const focus = () => editor.chain().focus().run()
|
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
||||||
|
|
||||||
const uploadFiles = () => editor.chain().selectFiles().run()
|
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||||
|
|
||||||
const submit = () => {
|
const submit = async () => {
|
||||||
if ($uploading) return
|
if ($uploading) return
|
||||||
|
|
||||||
const content = editor.getText({blockSeparator: "\n"}).trim()
|
const ed = await editor
|
||||||
const tags = editor.storage.nostr.getEditorTags()
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
|
const tags = ed.storage.nostr.getEditorTags()
|
||||||
|
|
||||||
if (!content) return
|
if (!content) return
|
||||||
|
|
||||||
onSubmit({content, tags})
|
onSubmit({content, tags})
|
||||||
|
|
||||||
editor.chain().clearContent().run()
|
ed.chain().clearContent().run()
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = makeEditor({autofocus, submit, uploading, aggressive: true})
|
const editor = makeEditor({url, autofocus, submit, uploading, aggressive: 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)}>
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {hash} from "@welshman/lib"
|
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
|
||||||
import {now} from "@welshman/lib"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import {thunks, deriveProfile, deriveProfileDisplay} from "@welshman/app"
|
||||||
import {
|
|
||||||
thunks,
|
|
||||||
pubkey,
|
|
||||||
deriveProfile,
|
|
||||||
deriveProfileDisplay,
|
|
||||||
formatTimestampAsDate,
|
|
||||||
formatTimestampAsTime,
|
|
||||||
} from "@welshman/app"
|
|
||||||
import {isMobile} from "@lib/html"
|
import {isMobile} from "@lib/html"
|
||||||
import LongPress from "@lib/components/LongPress.svelte"
|
import TapTarget from "@lib/components/TapTarget.svelte"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.svelte"
|
||||||
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"
|
||||||
@@ -19,50 +11,45 @@
|
|||||||
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
|
import ChannelMessageZapButton from "@app/components/ChannelMessageZapButton.svelte"
|
||||||
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
|
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
|
||||||
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
|
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
|
||||||
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
|
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
|
||||||
import {colors} from "@app/state"
|
import {colors, ENABLE_ZAPS} from "@app/state"
|
||||||
import {publishDelete, publishReaction} from "@app/commands"
|
import {publishDelete, publishReaction} from "@app/commands"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: any
|
url: string
|
||||||
room: any
|
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
replyTo?: any
|
replyTo?: (event: TrustedEvent) => void
|
||||||
showPubkey?: boolean
|
showPubkey?: boolean
|
||||||
inert?: boolean
|
inert?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, room, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
|
const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
|
||||||
|
|
||||||
const thunk = $thunks[event.id]
|
const thunk = $thunks[event.id]
|
||||||
const today = formatTimestampAsDate(now())
|
const today = formatTimestampAsDate(now())
|
||||||
const profile = deriveProfile(event.pubkey)
|
const profile = deriveProfile(event.pubkey, [url])
|
||||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
|
||||||
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
||||||
|
|
||||||
const reply = () => replyTo(event)
|
const reply = () => replyTo!(event)
|
||||||
|
|
||||||
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
|
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
|
||||||
|
|
||||||
const onReactionClick = (content: string, events: TrustedEvent[]) => {
|
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
|
||||||
const reaction = events.find(e => e.pubkey === $pubkey)
|
|
||||||
|
|
||||||
if (reaction) {
|
const createReaction = (template: EventContent) =>
|
||||||
publishDelete({relays: [url], event: reaction})
|
publishReaction({...template, event, relays: [url]})
|
||||||
} else {
|
|
||||||
publishReaction({event, content, relays: [url]})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LongPress
|
<TapTarget
|
||||||
data-event={event.id}
|
data-event={event.id}
|
||||||
onLongPress={inert ? null : onLongPress}
|
onTap={inert ? null : onTap}
|
||||||
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
|
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">
|
<div class="flex w-full gap-3 overflow-auto">
|
||||||
{#if showPubkey}
|
{#if showPubkey}
|
||||||
@@ -89,7 +76,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<Content {event} relays={[url]} />
|
<Content minimalQuote {event} {url} />
|
||||||
{#if thunk}
|
{#if thunk}
|
||||||
<ThunkStatus {thunk} class="mt-2" />
|
<ThunkStatus {thunk} class="mt-2" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -97,17 +84,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-2 ml-10 mt-1">
|
<div class="row-2 ml-10 mt-1">
|
||||||
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right" />
|
<ReactionSummary
|
||||||
|
{url}
|
||||||
|
{event}
|
||||||
|
{deleteReaction}
|
||||||
|
{createReaction}
|
||||||
|
reactionClass="tooltip-right" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
{#if !isMobile}
|
||||||
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
<button
|
||||||
class:group-hover:opacity-100={!isMobile}>
|
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
||||||
<ChannelMessageEmojiButton {url} {room} {event} />
|
class:group-hover:opacity-100={!isMobile}>
|
||||||
{#if replyTo}
|
{#if ENABLE_ZAPS}
|
||||||
<Button class="btn join-item btn-xs" onclick={reply}>
|
<ChannelMessageZapButton {url} {event} />
|
||||||
<Icon icon="reply" size={4} />
|
{/if}
|
||||||
</Button>
|
<ChannelMessageEmojiButton {url} {event} />
|
||||||
{/if}
|
{#if replyTo}
|
||||||
<ChannelMessageMenuButton {url} {event} />
|
<Button class="btn join-item btn-xs" onclick={reply}>
|
||||||
</button>
|
<Icon icon="reply" size={4} />
|
||||||
</LongPress>
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<ChannelMessageMenuButton {url} {event} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</TapTarget>
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {noop} from "@welshman/lib"
|
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {publishReaction} from "@app/commands"
|
import {publishReaction} from "@app/commands"
|
||||||
|
|
||||||
const {url, room, event} = $props()
|
const {url, event} = $props()
|
||||||
|
|
||||||
// Tell svelte-check to shut up
|
|
||||||
noop(room)
|
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) =>
|
const onEmoji = (emoji: NativeEmoji) =>
|
||||||
publishReaction({event, relays: [url], content: emoji.unicode})
|
publishReaction({event, relays: [url], content: emoji.unicode})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
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 EventReport from "@app/components/EventReport.svelte"
|
||||||
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
|
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
const {url, event, onClick} = $props()
|
const {url, event, onClick} = $props()
|
||||||
@@ -16,12 +16,12 @@
|
|||||||
|
|
||||||
const showInfo = () => {
|
const showInfo = () => {
|
||||||
onClick()
|
onClick()
|
||||||
pushModal(EventInfo, {event})
|
pushModal(EventInfo, {url, event})
|
||||||
}
|
}
|
||||||
|
|
||||||
const showDelete = () => {
|
const showDelete = () => {
|
||||||
onClick()
|
onClick()
|
||||||
pushModal(ConfirmDelete, {url, event})
|
pushModal(EventDeleteConfirm, {url, event})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {pubkey} from "@welshman/app"
|
import {pubkey} from "@welshman/app"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
||||||
|
import ZapButton from "@app/components/ZapButton.svelte"
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
|
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||||
|
import {ENABLE_ZAPS} from "@app/state"
|
||||||
import {publishReaction} from "@app/commands"
|
import {publishReaction} from "@app/commands"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
const {url, event, reply} = $props()
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
reply: () => void
|
||||||
|
}
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) => {
|
const {url, event, reply}: Props = $props()
|
||||||
|
|
||||||
|
const onEmoji = ((event: TrustedEvent, url: string, emoji: NativeEmoji) => {
|
||||||
history.back()
|
history.back()
|
||||||
publishReaction({event, relays: [url], content: emoji.unicode})
|
publishReaction({event, relays: [url], content: emoji.unicode})
|
||||||
}
|
}).bind(undefined, event, url)
|
||||||
|
|
||||||
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
||||||
|
|
||||||
@@ -23,9 +32,9 @@
|
|||||||
reply()
|
reply()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
|
const showInfo = () => pushModal(EventInfo, {url, event}, {replaceState: true})
|
||||||
|
|
||||||
const showDelete = () => pushModal(ConfirmDelete, {url, event})
|
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
@@ -33,6 +42,12 @@
|
|||||||
<Icon size={4} icon="smile-circle" />
|
<Icon size={4} icon="smile-circle" />
|
||||||
Send Reaction
|
Send Reaction
|
||||||
</Button>
|
</Button>
|
||||||
|
{#if ENABLE_ZAPS}
|
||||||
|
<ZapButton replaceState {url} {event} class="btn btn-secondary w-full">
|
||||||
|
<Icon size={4} icon="bolt" />
|
||||||
|
Send Zap
|
||||||
|
</ZapButton>
|
||||||
|
{/if}
|
||||||
<Button class="btn btn-neutral w-full" onclick={sendReply}>
|
<Button class="btn btn-neutral w-full" onclick={sendReply}>
|
||||||
<Icon size={4} icon="reply" />
|
<Icon size={4} icon="reply" />
|
||||||
Send Reply
|
Send Reply
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import ZapButton from "@app/components/ZapButton.svelte"
|
||||||
|
|
||||||
|
const {url, event} = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ZapButton {url} {event} class="btn join-item btn-xs">
|
||||||
|
<Icon icon="bolt" size={4} />
|
||||||
|
</ZapButton>
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {GENERAL, channelsById, makeChannelId} from "@app/state"
|
import {channelsById, makeChannelId} from "@app/state"
|
||||||
|
|
||||||
const {url, room} = $props()
|
const {url, room} = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if room === GENERAL}
|
{$channelsById.get(makeChannelId(url, room))?.name || room}
|
||||||
general
|
|
||||||
{:else}
|
|
||||||
{$channelsById.get(makeChannelId(url, room))?.name || room}
|
|
||||||
{/if}
|
|
||||||
|
|||||||
+215
-121
@@ -1,20 +1,40 @@
|
|||||||
<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} 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,
|
||||||
|
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,
|
||||||
|
INBOX_RELAYS,
|
||||||
|
} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
pubkey,
|
pubkey,
|
||||||
tagPubkey,
|
tagPubkey,
|
||||||
formatTimestampAsDate,
|
sendWrapped,
|
||||||
|
loadUsingOutbox,
|
||||||
inboxRelaySelectionsByPubkey,
|
inboxRelaySelectionsByPubkey,
|
||||||
load,
|
|
||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
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"
|
||||||
import PageBar from "@lib/components/PageBar.svelte"
|
import PageBar from "@lib/components/PageBar.svelte"
|
||||||
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ProfileName from "@app/components/ProfileName.svelte"
|
import ProfileName from "@app/components/ProfileName.svelte"
|
||||||
@@ -23,27 +43,30 @@
|
|||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import ProfileList from "@app/components/ProfileList.svelte"
|
import ProfileList from "@app/components/ProfileList.svelte"
|
||||||
import ChatMessage from "@app/components/ChatMessage.svelte"
|
import ChatMessage from "@app/components/ChatMessage.svelte"
|
||||||
import ChatCompose from "@app/components/ChannelCompose.svelte"
|
import ChatCompose from "@app/components/ChatCompose.svelte"
|
||||||
import ChatComposeParent from "@app/components/ChannelComposeParent.svelte"
|
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||||
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME} from "@app/state"
|
import {
|
||||||
|
INDEXER_RELAYS,
|
||||||
|
userSettingValues,
|
||||||
|
deriveChat,
|
||||||
|
splitChatId,
|
||||||
|
PLATFORM_NAME,
|
||||||
|
} from "@app/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {sendWrapped, prependParent} from "@app/commands"
|
import {prependParent} from "@app/commands"
|
||||||
|
|
||||||
const {
|
type Props = {
|
||||||
id,
|
|
||||||
info,
|
|
||||||
}: {
|
|
||||||
id: string
|
id: string
|
||||||
info?: Snippet
|
info?: Snippet
|
||||||
} = $props()
|
}
|
||||||
|
|
||||||
|
const {id, info}: Props = $props()
|
||||||
|
|
||||||
const chat = deriveChat(id)
|
const chat = deriveChat(id)
|
||||||
const pubkeys = splitChatId(id)
|
const pubkeys = splitChatId(id)
|
||||||
const others = remove($pubkey!, pubkeys)
|
const others = remove($pubkey!, pubkeys)
|
||||||
const missingInboxes = $derived(pubkeys.filter(pk => !$inboxRelaySelectionsByPubkey.has(pk)))
|
const missingInboxes = $derived(pubkeys.filter(pk => !$inboxRelaySelectionsByPubkey.has(pk)))
|
||||||
|
|
||||||
const assertEvent = (e: any) => e as TrustedEvent
|
|
||||||
|
|
||||||
const showMembers = () =>
|
const showMembers = () =>
|
||||||
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
|
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
|
||||||
|
|
||||||
@@ -57,14 +80,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
for (let i = 0; i < templates.length; i++) {
|
||||||
|
const template = templates[i]
|
||||||
|
|
||||||
|
await sendWrapped({pubkeys, template, delay: $userSettingValues.send_delay + ms(i)})
|
||||||
|
}
|
||||||
|
|
||||||
clearParent()
|
clearParent()
|
||||||
}
|
}
|
||||||
@@ -72,6 +134,8 @@
|
|||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let compose: ChatCompose | undefined = $state()
|
let compose: ChatCompose | undefined = $state()
|
||||||
let parent: TrustedEvent | undefined = $state()
|
let parent: TrustedEvent | undefined = $state()
|
||||||
|
let chatCompose: HTMLElement | undefined = $state()
|
||||||
|
let dynamicPadding: HTMLElement | undefined = $state()
|
||||||
|
|
||||||
const elements = $derived.by(() => {
|
const elements = $derived.by(() => {
|
||||||
const elements = []
|
const elements = []
|
||||||
@@ -105,7 +169,27 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Don't use loadInboxRelaySelection because we want to force reload
|
// Don't use loadInboxRelaySelection because we want to force reload
|
||||||
load({filters: [{kinds: [INBOX_RELAYS], authors: others}]})
|
for (const pubkey of others) {
|
||||||
|
loadUsingOutbox({
|
||||||
|
pubkey,
|
||||||
|
kind: INBOX_RELAYS,
|
||||||
|
relays: INDEXER_RELAYS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (dynamicPadding && chatCompose) {
|
||||||
|
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(chatCompose!)
|
||||||
|
observer.observe(dynamicPadding!)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.unobserve(chatCompose!)
|
||||||
|
observer.unobserve(dynamicPadding!)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -113,106 +197,116 @@
|
|||||||
}, 5000)
|
}, 5000)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative flex h-full w-full flex-col">
|
<PageBar>
|
||||||
{#if others.length > 0}
|
{#snippet title()}
|
||||||
<PageBar>
|
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
|
||||||
{#snippet title()}
|
{#if others.length === 0}
|
||||||
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2">
|
<div class="row-2">
|
||||||
{#if others.length === 1}
|
<ProfileCircle pubkey={$pubkey!} size={5} />
|
||||||
{@const pubkey = others[0]}
|
<ProfileName pubkey={$pubkey!} />
|
||||||
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
|
|
||||||
<Button onclick={onClick} class="row-2">
|
|
||||||
<ProfileCircle {pubkey} size={5} />
|
|
||||||
<ProfileName {pubkey} />
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ProfileCircles pubkeys={others} size={5} />
|
|
||||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
<ProfileName pubkey={others[0]} />
|
|
||||||
and
|
|
||||||
{#if others.length === 2}
|
|
||||||
<ProfileName pubkey={others[1]} />
|
|
||||||
{:else}
|
|
||||||
{others.length - 1}
|
|
||||||
{others.length > 2 ? "others" : "other"}
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{#if others.length > 2}
|
|
||||||
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
|
|
||||||
>Show all members</Button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{:else if others.length === 1}
|
||||||
{#snippet action()}
|
{@const pubkey = others[0]}
|
||||||
<div>
|
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
|
||||||
{#if remove($pubkey, missingInboxes).length > 0}
|
<Button onclick={onClick} class="row-2">
|
||||||
{@const count = remove($pubkey, missingInboxes).length}
|
<ProfileCircle {pubkey} size={5} />
|
||||||
{@const label = count > 1 ? "inboxes are" : "inbox is"}
|
<ProfileName {pubkey} />
|
||||||
<div
|
</Button>
|
||||||
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
|
|
||||||
data-tip="{count} {label} not configured.">
|
|
||||||
<Icon icon="danger" />
|
|
||||||
{count}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</PageBar>
|
|
||||||
{/if}
|
|
||||||
<div class="-mt-2 flex flex-grow flex-col-reverse overflow-auto py-2">
|
|
||||||
{#if missingInboxes.includes($pubkey!)}
|
|
||||||
<div class="py-12">
|
|
||||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
|
||||||
<p class="row-2 text-lg text-error">
|
|
||||||
<Icon icon="danger" />
|
|
||||||
Your inbox is not configured.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if missingInboxes.length > 0}
|
|
||||||
<div class="py-12">
|
|
||||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
|
||||||
<p class="row-2 text-lg text-error">
|
|
||||||
<Icon icon="danger" />
|
|
||||||
{missingInboxes.length}
|
|
||||||
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#each elements as { type, id, value, showPubkey } (id)}
|
|
||||||
{#if type === "date"}
|
|
||||||
<Divider>{value}</Divider>
|
|
||||||
{:else}
|
{:else}
|
||||||
<ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} {replyTo} />
|
<div class="flex items-center gap-2">
|
||||||
{/if}
|
<ProfileCircles pubkeys={others} size={5} />
|
||||||
{/each}
|
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
<p
|
<ProfileName pubkey={others[0]} />
|
||||||
class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
|
and
|
||||||
<Spinner {loading}>
|
{#if others.length === 2}
|
||||||
{#if loading}
|
<ProfileName pubkey={others[1]} />
|
||||||
Looking for messages...
|
{:else}
|
||||||
{:else}
|
{others.length - 1}
|
||||||
End of message history
|
{others.length > 2 ? "others" : "other"}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if others.length > 2}
|
||||||
|
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
|
||||||
|
>Show all members</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</Spinner>
|
{/if}
|
||||||
{@render info?.()}
|
</div>
|
||||||
</p>
|
{/snippet}
|
||||||
</div>
|
{#snippet action()}
|
||||||
{#if parent}
|
<div>
|
||||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
{#if remove($pubkey, missingInboxes).length > 0}
|
||||||
|
{@const count = remove($pubkey, missingInboxes).length}
|
||||||
|
{@const label = count > 1 ? "inboxes are" : "inbox is"}
|
||||||
|
<div
|
||||||
|
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
|
||||||
|
data-tip="{count} {label} not configured.">
|
||||||
|
<Icon icon="danger" />
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PageBar>
|
||||||
|
|
||||||
|
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
||||||
|
<div bind:this={dynamicPadding}></div>
|
||||||
|
{#if missingInboxes.includes($pubkey!)}
|
||||||
|
<div class="py-12">
|
||||||
|
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||||
|
<p class="row-2 text-lg text-error">
|
||||||
|
<Icon icon="danger" />
|
||||||
|
Your inbox is not configured.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if missingInboxes.length > 0}
|
||||||
|
<div class="py-12">
|
||||||
|
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||||
|
<p class="row-2 text-lg text-error">
|
||||||
|
<Icon icon="danger" />
|
||||||
|
{missingInboxes.length}
|
||||||
|
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#each elements as { type, id, value, showPubkey } (id)}
|
||||||
|
{#if type === "date"}
|
||||||
|
<Divider>{value}</Divider>
|
||||||
|
{:else}
|
||||||
|
<ChatMessage
|
||||||
|
event={$state.snapshot(value as TrustedEvent)}
|
||||||
|
{pubkeys}
|
||||||
|
{showPubkey}
|
||||||
|
{replyTo} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
|
||||||
|
<Spinner {loading}>
|
||||||
|
{#if loading}
|
||||||
|
Looking for messages...
|
||||||
|
{:else}
|
||||||
|
End of message history
|
||||||
|
{/if}
|
||||||
|
</Spinner>
|
||||||
|
{@render info?.()}
|
||||||
|
</p>
|
||||||
|
</PageContent>
|
||||||
|
|
||||||
|
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
||||||
|
<div>
|
||||||
|
{#if parent}
|
||||||
|
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<ChatCompose bind:this={compose} {onSubmit} />
|
<ChatCompose bind:this={compose} {onSubmit} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {displayProfileByPubkey} from "@welshman/app"
|
||||||
|
import {slide} from "@lib/transition"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
|
|
||||||
|
const {
|
||||||
|
verb,
|
||||||
|
event,
|
||||||
|
clear,
|
||||||
|
}: {
|
||||||
|
verb: string
|
||||||
|
event: TrustedEvent
|
||||||
|
clear: () => void
|
||||||
|
} = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
|
||||||
|
transition:slide>
|
||||||
|
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
|
||||||
|
{#key event.id}
|
||||||
|
<NoteContent
|
||||||
|
{event}
|
||||||
|
hideMediaAtDepth={0}
|
||||||
|
minLength={100}
|
||||||
|
maxLength={300}
|
||||||
|
expandMode="disabled" />
|
||||||
|
{/key}
|
||||||
|
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
|
||||||
|
<Icon icon="close-circle" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
const {next} = $props()
|
const {next} = $props()
|
||||||
|
|
||||||
|
const nextUrl = $state.snapshot(next)
|
||||||
|
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
|
|
||||||
const enableChat = async () => {
|
const enableChat = async () => {
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearModals()
|
clearModals()
|
||||||
goto(next)
|
goto(nextUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
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(() => {
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
{#if others.length === 0}
|
{#if others.length === 0}
|
||||||
<ProfileCircle pubkey={$pubkey} size={5} />
|
<ProfileCircle pubkey={$pubkey!} size={5} />
|
||||||
Note to self
|
Note to self
|
||||||
{:else if others.length === 1}
|
{:else if others.length === 1}
|
||||||
<ProfileCircle pubkey={others[0]} size={5} />
|
<ProfileCircle pubkey={others[0]} size={5} />
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {type Instance} from "tippy.js"
|
import {type Instance} from "tippy.js"
|
||||||
import {hash} from "@welshman/lib"
|
import {hash, formatTimestampAsTime} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {
|
import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
|
||||||
thunks,
|
|
||||||
deriveProfile,
|
|
||||||
deriveProfileDisplay,
|
|
||||||
formatTimestampAsTime,
|
|
||||||
pubkey,
|
|
||||||
} from "@welshman/app"
|
|
||||||
import {isMobile} from "@lib/html"
|
import {isMobile} from "@lib/html"
|
||||||
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 LongPress from "@lib/components/LongPress.svelte"
|
import TapTarget from "@lib/components/TapTarget.svelte"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.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"
|
||||||
@@ -22,17 +16,17 @@
|
|||||||
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/state"
|
||||||
import {makeDelete, makeReaction, sendWrapped} from "@app/commands"
|
import {makeDelete, makeReaction} from "@app/commands"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
replyTo?: any
|
replyTo: (event: TrustedEvent) => void
|
||||||
pubkeys: string[]
|
pubkeys: string[]
|
||||||
showPubkey?: boolean
|
showPubkey?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {event, replyTo = undefined, pubkeys, showPubkey = false}: Props = $props()
|
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
|
||||||
|
|
||||||
const thunk = $thunks[event.id]
|
const thunk = $thunks[event.id]
|
||||||
const isOwn = event.pubkey === $pubkey
|
const isOwn = event.pubkey === $pubkey
|
||||||
@@ -40,16 +34,17 @@
|
|||||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||||
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
||||||
|
|
||||||
const onReactionClick = async (content: string, events: TrustedEvent[]) => {
|
const reply = () => replyTo(event)
|
||||||
const reaction = events.find(e => e.pubkey === $pubkey)
|
|
||||||
const template = reaction ? makeDelete({event: reaction}) : makeReaction({event, content})
|
|
||||||
|
|
||||||
await sendWrapped({template, pubkeys})
|
const deleteReaction = (event: TrustedEvent) =>
|
||||||
}
|
sendWrapped({template: makeDelete({event}), pubkeys})
|
||||||
|
|
||||||
|
const createReaction = (template: EventContent) =>
|
||||||
|
sendWrapped({template: makeReaction({event, ...template}), pubkeys})
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
||||||
|
|
||||||
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys})
|
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply})
|
||||||
|
|
||||||
const togglePopover = () => {
|
const togglePopover = () => {
|
||||||
if (popoverIsVisible) {
|
if (popoverIsVisible) {
|
||||||
@@ -72,32 +67,34 @@
|
|||||||
class:chat-start={!isOwn}
|
class:chat-start={!isOwn}
|
||||||
class:flex-row-reverse={!isOwn}
|
class:flex-row-reverse={!isOwn}
|
||||||
class:chat-end={isOwn}>
|
class:chat-end={isOwn}>
|
||||||
<Tippy
|
{#if !isMobile}
|
||||||
bind:popover
|
<Tippy
|
||||||
component={ChatMessageMenu}
|
bind:popover
|
||||||
props={{event, pubkeys, popover, replyTo}}
|
component={ChatMessageMenu}
|
||||||
params={{
|
props={{event, pubkeys, popover, replyTo}}
|
||||||
interactive: true,
|
params={{
|
||||||
trigger: "manual",
|
interactive: true,
|
||||||
onShow() {
|
trigger: "manual",
|
||||||
popoverIsVisible = true
|
onShow() {
|
||||||
},
|
popoverIsVisible = true
|
||||||
onHidden() {
|
},
|
||||||
popoverIsVisible = false
|
onHidden() {
|
||||||
},
|
popoverIsVisible = false
|
||||||
}}>
|
},
|
||||||
<button
|
}}>
|
||||||
type="button"
|
<button
|
||||||
class="opacity-0 transition-all"
|
type="button"
|
||||||
class:group-hover:opacity-100={!isMobile}
|
class="opacity-0 transition-all"
|
||||||
onclick={togglePopover}>
|
class:group-hover:opacity-100={!isMobile}
|
||||||
<Icon icon="menu-dots" size={4} />
|
onclick={togglePopover}>
|
||||||
</button>
|
<Icon icon="menu-dots" size={4} />
|
||||||
</Tippy>
|
</button>
|
||||||
|
</Tippy>
|
||||||
|
{/if}
|
||||||
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
|
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
|
||||||
<LongPress
|
<TapTarget
|
||||||
class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
|
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
|
||||||
onLongPress={showMobileMenu}>
|
onTap={showMobileMenu}>
|
||||||
{#if showPubkey}
|
{#if showPubkey}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if !isOwn}
|
{#if !isOwn}
|
||||||
@@ -120,9 +117,9 @@
|
|||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<Content showEntire {event} />
|
<Content showEntire {event} />
|
||||||
</div>
|
</div>
|
||||||
</LongPress>
|
</TapTarget>
|
||||||
<div class="row-2 z-feature -mt-1 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>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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 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, sendWrapped} from "@app/commands"
|
import {makeReaction} from "@app/commands"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {sendWrapped} 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 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, sendWrapped} from "@app/commands"
|
import {makeReaction} from "@app/commands"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {clip} from "@app/toast"
|
import {clip} from "@app/toast"
|
||||||
|
|
||||||
const {event, pubkeys} = $props()
|
type Props = {
|
||||||
|
pubkeys: string[]
|
||||||
const onEmoji = (emoji: NativeEmoji) => {
|
event: TrustedEvent
|
||||||
history.back()
|
reply: () => void
|
||||||
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {event, pubkeys, reply}: Props = $props()
|
||||||
|
|
||||||
|
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
|
||||||
|
history.back()
|
||||||
|
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
|
||||||
|
}).bind(undefined, event, pubkeys)
|
||||||
|
|
||||||
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
||||||
|
|
||||||
|
const sendReply = () => {
|
||||||
|
history.back()
|
||||||
|
reply()
|
||||||
|
}
|
||||||
|
|
||||||
const copyText = () => {
|
const copyText = () => {
|
||||||
history.back()
|
history.back()
|
||||||
clip(event.content)
|
clip(event.content)
|
||||||
@@ -30,6 +43,10 @@
|
|||||||
<Icon size={4} icon="smile-circle" />
|
<Icon size={4} icon="smile-circle" />
|
||||||
Send Reaction
|
Send Reaction
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button class="btn btn-neutral w-full" onclick={sendReply}>
|
||||||
|
<Icon size={4} icon="reply" />
|
||||||
|
Send Reply
|
||||||
|
</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
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<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"
|
||||||
@@ -14,7 +19,36 @@
|
|||||||
|
|
||||||
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,7 +62,7 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<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} from "@app/commands"
|
||||||
|
import {makeThreadPath} from "@app/routes"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: any
|
||||||
|
event: any
|
||||||
|
showActivity?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, showActivity = false}: Props = $props()
|
||||||
|
|
||||||
|
const path = makeThreadPath(url, event.id)
|
||||||
|
|
||||||
|
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
|
||||||
|
|
||||||
|
const createReaction = (template: EventContent) =>
|
||||||
|
publishReaction({...template, event, relays: [url]})
|
||||||
|
</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>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
truncate,
|
truncate,
|
||||||
renderAsHtml,
|
renderAsHtml,
|
||||||
isText,
|
isText,
|
||||||
|
isEmoji,
|
||||||
isTopic,
|
isTopic,
|
||||||
isCode,
|
isCode,
|
||||||
isCashu,
|
isCashu,
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
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"
|
||||||
@@ -38,8 +40,9 @@
|
|||||||
showEntire?: boolean
|
showEntire?: boolean
|
||||||
hideMediaAtDepth?: number
|
hideMediaAtDepth?: number
|
||||||
expandMode?: string
|
expandMode?: string
|
||||||
relays?: string[]
|
minimalQuote?: boolean
|
||||||
depth?: number
|
depth?: number
|
||||||
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -49,8 +52,9 @@
|
|||||||
showEntire = $bindable(false),
|
showEntire = $bindable(false),
|
||||||
hideMediaAtDepth = 1,
|
hideMediaAtDepth = 1,
|
||||||
expandMode = "block",
|
expandMode = "block",
|
||||||
relays = [],
|
minimalQuote = false,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
|
url,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
const fullContent = parse(event)
|
const fullContent = parse(event)
|
||||||
@@ -133,6 +137,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}
|
||||||
@@ -141,15 +147,21 @@
|
|||||||
<ContentToken value={parsed.value} />
|
<ContentToken value={parsed.value} />
|
||||||
{:else if isLink(parsed)}
|
{:else if isLink(parsed)}
|
||||||
{#if isBlock(i)}
|
{#if isBlock(i)}
|
||||||
<ContentLinkBlock value={parsed.value} />
|
<ContentLinkBlock value={parsed.value} {event} />
|
||||||
{:else}
|
{:else}
|
||||||
<ContentLinkInline value={parsed.value} />
|
<ContentLinkInline value={parsed.value} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else if isProfile(parsed)}
|
{:else if isProfile(parsed)}
|
||||||
<ContentMention value={parsed.value} />
|
<ContentMention value={parsed.value} {url} />
|
||||||
{:else if isEvent(parsed) || isAddress(parsed)}
|
{:else if isEvent(parsed) || isAddress(parsed)}
|
||||||
{#if isBlock(i)}
|
{#if isBlock(i)}
|
||||||
<ContentQuote {depth} {relays} {hideMediaAtDepth} value={parsed.value} {event} />
|
<ContentQuote
|
||||||
|
{depth}
|
||||||
|
{url}
|
||||||
|
{hideMediaAtDepth}
|
||||||
|
value={parsed.value}
|
||||||
|
{event}
|
||||||
|
minimal={minimalQuote} />
|
||||||
{:else}
|
{:else}
|
||||||
<Link
|
<Link
|
||||||
external
|
external
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {ParsedEmojiValue} from "@welshman/content"
|
||||||
|
import {imgproxy} from "@app/state"
|
||||||
|
|
||||||
|
export let value: ParsedEmojiValue
|
||||||
|
|
||||||
|
const alt = `:${value.name}:`
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if value.url}
|
||||||
|
<img
|
||||||
|
{alt}
|
||||||
|
src={imgproxy(value.url, {w: 24, h: 24})}
|
||||||
|
class="-mt-0.5 inline h-[1em] min-w-[1em] align-middle" />
|
||||||
|
{:else}
|
||||||
|
{alt}
|
||||||
|
{/if}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {ellipsize, postJson} from "@welshman/lib"
|
import {ellipsize, displayUrl, postJson} from "@welshman/lib"
|
||||||
import {dufflepud, imgproxy} from "@app/state"
|
import {dufflepud, imgproxy} from "@app/state"
|
||||||
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 {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
const {value} = $props()
|
const {value, event} = $props()
|
||||||
|
|
||||||
let hideImage = $state(false)
|
let hideImage = $state(false)
|
||||||
|
|
||||||
@@ -26,18 +27,18 @@
|
|||||||
hideImage = true
|
hideImage = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
|
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link external href={url} class="my-2 block">
|
<Link external href={url} 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" />
|
||||||
</video>
|
</video>
|
||||||
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
||||||
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
|
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
|
||||||
<img alt="Link preview" src={imgproxy(url)} class="m-auto max-h-96 rounded-box" />
|
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
{#await loadPreview()}
|
{#await loadPreview()}
|
||||||
@@ -51,15 +52,13 @@
|
|||||||
alt="Link preview"
|
alt="Link preview"
|
||||||
onerror={onError}
|
onerror={onError}
|
||||||
src={imgproxy(preview.image)}
|
src={imgproxy(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">
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount, onDestroy} from "svelte"
|
||||||
|
import {displayUrl} from "@welshman/lib"
|
||||||
|
import {getTags, decryptFile, getTagValue, tagsFromIMeta} from "@welshman/util"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import {imgproxy} from "@app/state"
|
||||||
|
|
||||||
|
const {value, event, ...props} = $props()
|
||||||
|
|
||||||
|
const url = value.url.toString()
|
||||||
|
const meta =
|
||||||
|
getTags("imeta", event.tags)
|
||||||
|
.map(tagsFromIMeta)
|
||||||
|
.find(meta => getTagValue("url", meta) === url) || event.tags
|
||||||
|
|
||||||
|
const key = getTagValue("decryption-key", meta)
|
||||||
|
const nonce = getTagValue("decryption-nonce", meta)
|
||||||
|
const algorithm = getTagValue("encryption-algorithm", meta)
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasError = $state(false)
|
||||||
|
let src = $state(imgproxy(url))
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
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([decryptedData]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
URL.revokeObjectURL(src)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if hasError}
|
||||||
|
<a href={url} class="link-content whitespace-nowrap">
|
||||||
|
<Icon icon="link-round" size={3} class="inline-block" />
|
||||||
|
{displayUrl(url)}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<img alt="" {src} onerror={onError} {...props} />
|
||||||
|
{/if}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import {imgproxy} from "@app/state"
|
|
||||||
|
|
||||||
const {url} = $props()
|
const {value, event} = $props()
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button class="m-auto h-screen w-screen cursor-pointer p-4" onclick={back}>
|
<Button class="m-auto h-screen w-screen cursor-pointer p-4" onclick={back}>
|
||||||
<img alt="" src={imgproxy(url)} class="m-auto max-h-full max-w-full rounded-box" />
|
<ContentLinkBlockImage {value} {event} class="m-auto max-h-full max-w-full rounded-box" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayProfile} from "@welshman/util"
|
import {removeNil} from "@welshman/lib"
|
||||||
import {deriveProfile} from "@welshman/app"
|
import type {ProfilePointer} from "@welshman/content"
|
||||||
|
import {deriveProfileDisplay} 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/modal"
|
||||||
|
|
||||||
const {value} = $props()
|
type Props = {
|
||||||
|
value: ProfilePointer
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
const profile = deriveProfile(value.pubkey)
|
const {value, url}: Props = $props()
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey})
|
const display = deriveProfileDisplay(value.pubkey, removeNil([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>
|
||||||
|
|||||||
@@ -1,112 +1,61 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {nip19} from "nostr-tools"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {goto} from "$app/navigation"
|
import {Router} from "@welshman/router"
|
||||||
import {ctx, nthEq} from "@welshman/lib"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {tracker, repository} from "@welshman/app"
|
import {Address, MESSAGE} from "@welshman/util"
|
||||||
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD, EVENT_TIME} from "@welshman/util"
|
|
||||||
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 NoteContent from "@app/components/NoteContent.svelte"
|
||||||
import {deriveEvent, entityLink, ROOM} from "@app/state"
|
import {deriveEvent, entityLink} from "@app/state"
|
||||||
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
|
import {goToEvent} from "@app/routes"
|
||||||
|
|
||||||
const {value, event, depth, hideMediaAtDepth, relays = []} = $props()
|
type Props = {
|
||||||
|
value: any
|
||||||
|
hideMediaAtDepth: number
|
||||||
|
event: TrustedEvent
|
||||||
|
depth: number
|
||||||
|
url?: string
|
||||||
|
minimal?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const {id, identifier, kind, pubkey, relays: relayHints = []} = value
|
const {value, event, depth, hideMediaAtDepth, url, minimal}: Props = $props()
|
||||||
|
|
||||||
|
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()
|
||||||
const mergedRelays = [
|
const mergedRelays = Router.get().Quote(event, idOrAddress, relays).getUrls()
|
||||||
...relays,
|
|
||||||
...ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls(),
|
if (url) {
|
||||||
]
|
mergedRelays.push(url)
|
||||||
|
}
|
||||||
|
|
||||||
const quote = deriveEvent(idOrAddress, mergedRelays)
|
const quote = deriveEvent(idOrAddress, mergedRelays)
|
||||||
const entity = id
|
const entity = id
|
||||||
? 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 scrollToEvent = (id: string) => {
|
|
||||||
const element = document.querySelector(`[data-event="${id}"]`) as any
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({behavior: "smooth"})
|
|
||||||
element.style =
|
|
||||||
"filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
element.style = "transition-property: all; transition-duration: 300ms;"
|
|
||||||
}, 800)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
element.style = ""
|
|
||||||
}, 800 + 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Boolean(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openMessage = (url: string, room: string, id: string) => {
|
|
||||||
const event = repository.getEvent(id)
|
|
||||||
|
|
||||||
if (event) {
|
|
||||||
goto(makeRoomPath(url, room))
|
|
||||||
|
|
||||||
// TODO: if the event doesn't immediately load, this won't work. Scroll up until it's found
|
|
||||||
setTimeout(() => scrollToEvent(id), 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 max-w-full text-left" {onclick}>
|
||||||
{#if $quote}
|
{#if $quote}
|
||||||
<NoteCard event={$quote} class="bg-alt rounded-box p-4">
|
{#if minimal && $quote.kind === MESSAGE}
|
||||||
<NoteContent {hideMediaAtDepth} {relays} 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%);">
|
||||||
|
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
|
||||||
|
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {formatTimestamp} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Content from "@app/components/Content.svelte"
|
||||||
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
|
import {goToEvent} from "@app/routes"
|
||||||
|
import {displayChannel} from "@app/state"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
room?: string
|
||||||
|
events: TrustedEvent[]
|
||||||
|
latest: TrustedEvent
|
||||||
|
earliest: TrustedEvent
|
||||||
|
participants: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, room, events, latest, earliest, participants}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button class="card2 bg-alt" 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 room}
|
||||||
|
<span class="font-medium text-blue-400">
|
||||||
|
#{displayChannel(url, room)}
|
||||||
|
</span>
|
||||||
|
<span class="opacity-50">•</span>
|
||||||
|
{/if}
|
||||||
|
<span>{formatTimestamp(earliest.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<Content minimalQuote minLength={100} maxLength={400} event={earliest} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-13 flex items-center justify-between">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Icon icon="alt-arrow-left" />
|
||||||
|
<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>
|
||||||
|
<Content minimalQuote minLength={100} maxLength={400} event={latest} />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
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 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 {ENABLE_ZAPS} from "@app/state"
|
||||||
import {publishReaction} from "@app/commands"
|
import {publishReaction} from "@app/commands"
|
||||||
|
|
||||||
const {
|
type Props = {
|
||||||
url,
|
|
||||||
noun,
|
|
||||||
event,
|
|
||||||
}: {
|
|
||||||
url: string
|
url: string
|
||||||
noun: string
|
noun: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
} = $props()
|
hideZap?: boolean
|
||||||
|
customActions?: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, noun, event, hideZap, customActions}: Props = $props()
|
||||||
|
|
||||||
const showPopover = () => popover?.show()
|
const showPopover = () => popover?.show()
|
||||||
|
|
||||||
@@ -30,13 +33,18 @@
|
|||||||
</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="smile-circle" size={4} />
|
||||||
</EmojiButton>
|
</EmojiButton>
|
||||||
<Tippy
|
<Tippy
|
||||||
bind:popover
|
bind:popover
|
||||||
component={EventMenu}
|
component={EventMenu}
|
||||||
props={{url, noun, event, 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="menu-dots" size={4} />
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {max} 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 {deriveEvents} from "@welshman/store"
|
import {deriveEvents} from "@welshman/store"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {formatTimestampRelative, repository, load} from "@welshman/app"
|
import {repository} from "@welshman/app"
|
||||||
import {notifications} from "@app/notifications"
|
import {notifications} from "@app/notifications"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -1,14 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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} from "@app/commands"
|
||||||
import {clearModals} from "@app/modal"
|
import {clearModals} from "@app/modal"
|
||||||
|
|
||||||
const {url, event} = $props()
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
const snapshot = $state.snapshot(event)
|
await publishDelete({event, relays: [url]})
|
||||||
|
|
||||||
await publishDelete({event: snapshot, relays: [url]})
|
|
||||||
|
|
||||||
clearModals()
|
clearModals()
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {nip19} from "nostr-tools"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {ctx} from "@welshman/lib"
|
import {Router} from "@welshman/router"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
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/toast"
|
||||||
|
|
||||||
const {event} = $props()
|
type Props = {
|
||||||
|
url?: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
const relays = ctx.app.router.Event(event).getUrls()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
||||||
const nevent1 = nip19.neventEncode({...event, relays})
|
const nevent1 = nip19.neventEncode({...event, relays})
|
||||||
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)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {COMMENT} from "@welshman/util"
|
import {COMMENT} from "@welshman/util"
|
||||||
import {pubkey} from "@welshman/app"
|
import {pubkey} from "@welshman/app"
|
||||||
@@ -7,45 +9,37 @@
|
|||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import EventReport from "@app/components/EventReport.svelte"
|
import EventReport from "@app/components/EventReport.svelte"
|
||||||
import EventShare from "@app/components/EventShare.svelte"
|
import EventShare from "@app/components/EventShare.svelte"
|
||||||
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
|
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
const {
|
type Props = {
|
||||||
url,
|
|
||||||
noun,
|
|
||||||
event,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
url: string
|
url: string
|
||||||
noun: string
|
noun: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
} = $props()
|
customActions?: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, noun, event, onClick, customActions}: Props = $props()
|
||||||
|
|
||||||
const isRoot = event.kind !== COMMENT
|
const isRoot = event.kind !== COMMENT
|
||||||
|
|
||||||
const report = () => {
|
const report = () => pushModal(EventReport, {url, event})
|
||||||
onClick()
|
|
||||||
pushModal(EventReport, {url, event})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showInfo = () => {
|
const showInfo = () => pushModal(EventInfo, {url, event})
|
||||||
onClick()
|
|
||||||
pushModal(EventInfo, {event})
|
|
||||||
}
|
|
||||||
|
|
||||||
const share = () => {
|
const share = () => pushModal(EventShare, {url, event})
|
||||||
onClick()
|
|
||||||
pushModal(EventShare, {url, event})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showDelete = () => {
|
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
||||||
onClick()
|
|
||||||
pushModal(ConfirmDelete, {url, event})
|
let ul: Element
|
||||||
}
|
|
||||||
|
onMount(() => {
|
||||||
|
ul.addEventListener("click", onClick)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl">
|
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl" bind:this={ul}>
|
||||||
{#if isRoot}
|
{#if isRoot}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={share}>
|
<Button onclick={share}>
|
||||||
@@ -60,6 +54,7 @@
|
|||||||
{noun} Details
|
{noun} Details
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
{@render customActions?.()}
|
||||||
{#if event.pubkey === $pubkey}
|
{#if event.pubkey === $pubkey}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={showDelete} class="text-error">
|
<Button onclick={showDelete} class="text-error">
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
import {fly, slideAndFade} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
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} from "@app/commands"
|
||||||
import {tagRoom, GENERAL, PROTECTED} from "@app/state"
|
import {PROTECTED} from "@app/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
@@ -15,11 +16,14 @@
|
|||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
|
||||||
const submit = () => {
|
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
if ($uploading) return
|
if ($uploading) return
|
||||||
|
|
||||||
const content = editor.getText({blockSeparator: "\n"}).trim()
|
const ed = await editor
|
||||||
const tags = [...editor.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
|
const tags = [...ed.storage.nostr.getEditorTags(), PROTECTED]
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
@@ -31,31 +35,53 @@
|
|||||||
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = makeEditor({submit, uploading, autofocus: !isMobile})
|
const editor = makeEditor({url, submit, uploading, autofocus: !isMobile})
|
||||||
|
|
||||||
|
let form: HTMLElement
|
||||||
|
let spacer: HTMLElement
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
spacer.scrollIntoView({block: "end", behavior: "smooth"})
|
||||||
|
})
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
spacer!.style.minHeight = `${form!.offsetHeight}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(form!)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.unobserve(form!)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={spacer}></div>
|
||||||
<form
|
<form
|
||||||
in:fly
|
in:fly
|
||||||
out:slideAndFade
|
bind:this={form}
|
||||||
onsubmit={preventDefault(submit)}
|
onsubmit={preventDefault(submit)}
|
||||||
class="card2 sticky bottom-2 z-feature mx-2 mt-4 bg-neutral">
|
class="cb cw fixed z-feature -mx-2 pt-3">
|
||||||
<div class="relative">
|
<div class="card2 mx-2 my-2 bg-neutral">
|
||||||
<div class="note-editor flex-grow overflow-hidden">
|
<div class="relative">
|
||||||
<EditorContent {editor} />
|
<div class="note-editor flex-grow overflow-hidden">
|
||||||
|
<EditorContent {editor} />
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
<Button
|
<ModalFooter>
|
||||||
data-tip="Add an image"
|
<Button class="btn btn-link" onclick={onClose}>Cancel</Button>
|
||||||
class="tooltip tooltip-left absolute bottom-1 right-2"
|
<Button type="submit" class="btn btn-primary">Post Reply</Button>
|
||||||
onclick={editor.commands.selectFiles}>
|
</ModalFooter>
|
||||||
{#if $uploading}
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
|
||||||
{:else}
|
|
||||||
<Icon icon="paperclip" size={3} />
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<ModalFooter>
|
|
||||||
<Button class="btn btn-link" onclick={onClose}>Cancel</Button>
|
|
||||||
<Button type="submit" class="btn btn-primary">Post Reply</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<div class="column gap-2">
|
<div class="column gap-2">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Profile pubkey={report.pubkey} />
|
<Profile pubkey={report.pubkey} {url} />
|
||||||
<span>Reported this event as "{reason}"</span>
|
<span>Reported this event as "{reason}"</span>
|
||||||
</div>
|
</div>
|
||||||
{#if report.pubkey === $pubkey}
|
{#if report.pubkey === $pubkey}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<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} from "@app/commands"
|
||||||
|
import {makeGoalPath} from "@app/routes"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: any
|
||||||
|
event: any
|
||||||
|
showActivity?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, showActivity = false}: Props = $props()
|
||||||
|
|
||||||
|
const path = makeGoalPath(url, event.id)
|
||||||
|
|
||||||
|
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
|
||||||
|
|
||||||
|
const createReaction = (template: EventContent) =>
|
||||||
|
publishReaction({...template, event, relays: [url]})
|
||||||
|
</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} hideZap noun="Goal" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<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 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/toast"
|
||||||
|
import {PROTECTED} from "@app/state"
|
||||||
|
import {makeEditor} from "@app/editor"
|
||||||
|
|
||||||
|
const {url} = $props()
|
||||||
|
|
||||||
|
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],
|
||||||
|
PROTECTED,
|
||||||
|
]
|
||||||
|
|
||||||
|
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="alt-arrow-left" />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="btn btn-primary">Create Goal</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<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 {makeGoalPath} from "@app/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const summary = getTagValue("summary", event.tags)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Link class="col-2 card2 bg-alt w-full cursor-pointer" 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} />
|
||||||
|
</span>
|
||||||
|
<GoalActions showActivity {url} {event} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<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 {deriveEventsMapped} from "@welshman/store"
|
||||||
|
import {repository, getValidZap} from "@welshman/app"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import ZapButton from "@app/components/ZapButton.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const zaps = deriveEventsMapped<Zap>(repository, {
|
||||||
|
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||||
|
itemToEvent: item => item.response,
|
||||||
|
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="card2 bg-alt flex flex-col gap-8">
|
||||||
|
<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,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {deriveZapperForPubkey} from "@welshman/app"
|
||||||
|
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="alt-arrow-left" />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
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} from "@welshman/app"
|
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
|
||||||
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"
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
import {pushModal, clearModals} from "@app/modal"
|
import {pushModal, clearModals} from "@app/modal"
|
||||||
import {PLATFORM_NAME, BURROW_URL} from "@app/state"
|
import {PLATFORM_NAME, BURROW_URL} from "@app/state"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {loadUserData} from "@app/commands"
|
import {loadUserData} from "@app/requests"
|
||||||
import {setChecked} from "@app/notifications"
|
import {setChecked} from "@app/notifications"
|
||||||
|
|
||||||
let signers: any[] = $state([])
|
let signers: any[] = $state([])
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
const signUp = () => pushModal(SignUp)
|
const signUp = () => pushModal(SignUp)
|
||||||
|
|
||||||
const onSuccess = async (session: Session, relays: string[] = []) => {
|
const onSuccess = async (session: Session, relays: string[] = []) => {
|
||||||
await loadUserData(session.pubkey, {relays})
|
await loadUserData(session.pubkey, relays)
|
||||||
|
|
||||||
addSession(session)
|
addSession(session)
|
||||||
pushToast({message: "Successfully logged in!"})
|
pushToast({message: "Successfully logged in!"})
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
const pubkey = await getNip07()?.getPublicKey()
|
const pubkey = await getNip07()?.getPublicKey()
|
||||||
|
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
await onSuccess({method: "nip07", pubkey})
|
await onSuccess(makeNip07Session(pubkey))
|
||||||
} else {
|
} else {
|
||||||
pushToast({
|
pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
const pubkey = await signer.getPubkey()
|
const pubkey = await signer.getPubkey()
|
||||||
|
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
await onSuccess({method: "nip55", pubkey, signer: app.packageName})
|
await onSuccess(makeNip55Session(pubkey, app.packageName))
|
||||||
} else {
|
} else {
|
||||||
pushToast({
|
pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
|
|||||||
@@ -1,125 +1,91 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount, onDestroy} from "svelte"
|
import type {Nip46ResponseWithResult} from "@welshman/signer"
|
||||||
import {Nip46Broker, getPubkey, makeSecret} from "@welshman/signer"
|
import {Nip46Broker, makeSecret} from "@welshman/signer"
|
||||||
import {addSession} from "@welshman/app"
|
import {loginWithNip01, loginWithNip46} from "@welshman/app"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import {slideAndFade} from "@lib/transition"
|
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Field from "@lib/components/Field.svelte"
|
|
||||||
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 QRCode from "@app/components/QRCode.svelte"
|
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
|
||||||
import InfoBunker from "@app/components/InfoBunker.svelte"
|
import BunkerUrl from "@app/components/BunkerUrl.svelte"
|
||||||
import {loginWithNip46, loadUserData} from "@app/commands"
|
import {loadUserData} from "@app/requests"
|
||||||
import {pushModal, clearModals} from "@app/modal"
|
import {clearModals} from "@app/modal"
|
||||||
import {setChecked} from "@app/notifications"
|
import {setChecked} from "@app/notifications"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
|
import {SIGNER_RELAYS, NIP46_PERMS} from "@app/state"
|
||||||
|
|
||||||
const clientSecret = makeSecret()
|
|
||||||
|
|
||||||
const abortController = new AbortController()
|
|
||||||
|
|
||||||
const broker = Nip46Broker.get({clientSecret, relays: SIGNER_RELAYS})
|
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const controller = new BunkerConnectController({
|
||||||
|
onNostrConnect: async (response: Nip46ResponseWithResult) => {
|
||||||
|
const pubkey = await controller.broker.getPublicKey()
|
||||||
|
|
||||||
|
await loadUserData(pubkey)
|
||||||
|
|
||||||
|
loginWithNip46(pubkey, controller.clientSecret, response.event.pubkey, SIGNER_RELAYS)
|
||||||
|
setChecked("*")
|
||||||
|
clearModals()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(bunker)
|
if (controller.loading) return
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signerPubkey || relays.length === 0) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: "Sorry, it looks like that's an invalid bunker link.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
loading = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await loginWithNip46({connectSecret, clientSecret, signerPubkey, relays})
|
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(controller.bunker)
|
||||||
|
|
||||||
if (success) {
|
console.log({signerPubkey, connectSecret, relays})
|
||||||
abortController.abort()
|
|
||||||
|
if (!signerPubkey || relays.length === 0) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Sorry, it looks like that's an invalid bunker link.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.loading = true
|
||||||
|
|
||||||
|
const {clientSecret} = controller
|
||||||
|
const broker = new Nip46Broker({relays, clientSecret, signerPubkey})
|
||||||
|
const result = await broker.connect(connectSecret, NIP46_PERMS)
|
||||||
|
const pubkey = await broker.getPublicKey()
|
||||||
|
|
||||||
|
// TODO: remove ack result
|
||||||
|
if (pubkey && ["ack", connectSecret].includes(result)) {
|
||||||
|
broker.cleanup()
|
||||||
|
controller.stop()
|
||||||
|
|
||||||
|
await loadUserData(pubkey)
|
||||||
|
|
||||||
|
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
|
||||||
} else {
|
} else {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Something went wrong, please try again!",
|
message: "Something went wrong, please try again!",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Something went wrong, please try again!",
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
controller.loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
clearModals()
|
clearModals()
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = $state("")
|
|
||||||
let bunker = $state("")
|
|
||||||
let loading = $state(false)
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// For testing and for play store reviewers
|
// For testing and for play store reviewers
|
||||||
if (bunker === "reviewkey") {
|
if (controller.bunker === "reviewkey") {
|
||||||
const secret = makeSecret()
|
loginWithNip01(makeSecret())
|
||||||
|
|
||||||
addSession({method: "nip01", secret, pubkey: getPubkey(secret)})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
url = await broker.makeNostrconnectUrl({
|
|
||||||
perms: NIP46_PERMS,
|
|
||||||
url: PLATFORM_URL,
|
|
||||||
name: PLATFORM_NAME,
|
|
||||||
image: PLATFORM_LOGO,
|
|
||||||
})
|
|
||||||
|
|
||||||
let response
|
|
||||||
try {
|
|
||||||
response = await broker.waitForNostrconnect(url, abortController)
|
|
||||||
} catch (errorResponse: any) {
|
|
||||||
if (errorResponse?.error) {
|
|
||||||
pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: `Received error from signer: ${errorResponse.error}`,
|
|
||||||
})
|
|
||||||
} else if (errorResponse) {
|
|
||||||
console.error(errorResponse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
loading = true
|
|
||||||
|
|
||||||
const userPubkey = await broker.getPublicKey()
|
|
||||||
|
|
||||||
await loadUserData(userPubkey)
|
|
||||||
|
|
||||||
addSession({
|
|
||||||
method: "nip46",
|
|
||||||
pubkey: userPubkey,
|
|
||||||
secret: clientSecret,
|
|
||||||
handler: {
|
|
||||||
pubkey: response.event.pubkey,
|
|
||||||
relays: SIGNER_RELAYS,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
setChecked("*")
|
|
||||||
clearModals()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
abortController.abort()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
|
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
|
||||||
@@ -131,35 +97,18 @@
|
|||||||
<div>Connect your signer by scanning the QR code below or pasting a bunker link.</div>
|
<div>Connect your signer by scanning the QR code below or pasting a bunker link.</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
{#if !loading && url}
|
<BunkerConnect {controller} />
|
||||||
<div class="flex justify-center" out:slideAndFade>
|
<BunkerUrl loading={controller.loading} bind:bunker={controller.bunker} />
|
||||||
<QRCode code={url} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<Field>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Bunker Link*</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
|
||||||
<Icon icon="cpu" />
|
|
||||||
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
|
|
||||||
</label>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet info()}
|
|
||||||
<p>
|
|
||||||
A login link provided by a nostr signing app.
|
|
||||||
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
|
|
||||||
</p>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
<Button class="btn btn-link" onclick={back} disabled={controller.loading}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon="alt-arrow-left" />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={loading || !bunker}>
|
<Button
|
||||||
<Spinner {loading}>Next</Spinner>
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={controller.loading || !controller.bunker}>
|
||||||
|
<Spinner loading={controller.loading}>Next</Spinner>
|
||||||
<Icon icon="alt-arrow-right" />
|
<Icon icon="alt-arrow-right" />
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import {postJson, stripProtocol} from "@welshman/lib"
|
import {postJson, stripProtocol} from "@welshman/lib"
|
||||||
import {Nip46Broker, makeSecret} from "@welshman/signer"
|
import {Nip46Broker, makeSecret} from "@welshman/signer"
|
||||||
import {normalizeRelayUrl} from "@welshman/util"
|
import {normalizeRelayUrl} from "@welshman/util"
|
||||||
import {addSession} from "@welshman/app"
|
import {addSession, makeNip46Session} from "@welshman/app"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
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 PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
|
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
|
||||||
import {loadUserData} from "@app/commands"
|
import {loadUserData} from "@app/requests"
|
||||||
import {clearModals, pushModal} from "@app/modal"
|
import {clearModals, pushModal} from "@app/modal"
|
||||||
import {setChecked} from "@app/notifications"
|
import {setChecked} from "@app/notifications"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))]
|
? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))]
|
||||||
: [normalizeRelayUrl(BURROW_URL)]
|
: [normalizeRelayUrl(BURROW_URL)]
|
||||||
|
|
||||||
const broker = Nip46Broker.get({clientSecret, relays})
|
const broker = new Nip46Broker({clientSecret, relays})
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
|
|
||||||
let response
|
let response
|
||||||
try {
|
try {
|
||||||
response = await broker.waitForNostrconnect(url, abortController)
|
response = await broker.waitForNostrconnect(url, abortController.signal)
|
||||||
} catch (errorResponse: any) {
|
} catch (errorResponse: any) {
|
||||||
if (errorResponse?.error) {
|
if (errorResponse?.error) {
|
||||||
pushToast({
|
pushToast({
|
||||||
@@ -83,18 +83,13 @@
|
|||||||
if (response) {
|
if (response) {
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
const userPubkey = await broker.getPublicKey()
|
const pubkey = await broker.getPublicKey()
|
||||||
|
const session = makeNip46Session(pubkey, clientSecret, response.event.pubkey, relays)
|
||||||
|
|
||||||
await loadUserData(userPubkey)
|
await loadUserData(pubkey)
|
||||||
|
|
||||||
addSession({
|
|
||||||
email,
|
|
||||||
method: "nip46",
|
|
||||||
pubkey: userPubkey,
|
|
||||||
secret: clientSecret,
|
|
||||||
handler: {pubkey: response.event.pubkey, relays},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
addSession({...session, email})
|
||||||
|
broker.cleanup()
|
||||||
setChecked("*")
|
setChecked("*")
|
||||||
clearModals()
|
clearModals()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
urls: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const {urls}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="column menu gap-2">
|
||||||
|
{#each urls as url (url)}
|
||||||
|
<MenuSpacesItem {url} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {displayRelayUrl, GROUP_META} from "@welshman/util"
|
import {displayRelayUrl, getTagValue} from "@welshman/util"
|
||||||
|
import {deriveRelay} from "@welshman/app"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
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"
|
||||||
@@ -12,26 +13,34 @@
|
|||||||
import SpaceExit from "@app/components/SpaceExit.svelte"
|
import SpaceExit from "@app/components/SpaceExit.svelte"
|
||||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||||
import ProfileList from "@app/components/ProfileList.svelte"
|
import ProfileList from "@app/components/ProfileList.svelte"
|
||||||
|
import AlertAdd from "@app/components/AlertAdd.svelte"
|
||||||
|
import Alerts from "@app/components/Alerts.svelte"
|
||||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||||
import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
|
import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
|
||||||
import {
|
import {
|
||||||
|
ENABLE_ZAPS,
|
||||||
userRoomsByUrl,
|
userRoomsByUrl,
|
||||||
hasMembershipUrl,
|
hasMembershipUrl,
|
||||||
memberships,
|
memberships,
|
||||||
deriveUserRooms,
|
deriveUserRooms,
|
||||||
deriveOtherRooms,
|
deriveOtherRooms,
|
||||||
|
hasNip29,
|
||||||
|
alerts,
|
||||||
} from "@app/state"
|
} from "@app/state"
|
||||||
import {notifications} from "@app/notifications"
|
import {notifications} from "@app/notifications"
|
||||||
import {pullConservatively} from "@app/requests"
|
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
|
|
||||||
|
const relay = deriveRelay(url)
|
||||||
|
const chatPath = makeSpacePath(url, "chat")
|
||||||
|
const goalsPath = makeSpacePath(url, "goals")
|
||||||
const threadsPath = makeSpacePath(url, "threads")
|
const threadsPath = makeSpacePath(url, "threads")
|
||||||
const calendarPath = makeSpacePath(url, "calendar")
|
const calendarPath = makeSpacePath(url, "calendar")
|
||||||
const userRooms = deriveUserRooms(url)
|
const userRooms = deriveUserRooms(url)
|
||||||
const otherRooms = deriveOtherRooms(url)
|
const otherRooms = deriveOtherRooms(url)
|
||||||
|
const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url)))
|
||||||
|
|
||||||
const openMenu = () => {
|
const openMenu = () => {
|
||||||
showMenu = true
|
showMenu = true
|
||||||
@@ -44,7 +53,7 @@
|
|||||||
const showMembers = () =>
|
const showMembers = () =>
|
||||||
pushModal(
|
pushModal(
|
||||||
ProfileList,
|
ProfileList,
|
||||||
{pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)},
|
{url, pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)},
|
||||||
{replaceState},
|
{replaceState},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,6 +65,13 @@
|
|||||||
|
|
||||||
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
|
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
|
||||||
|
|
||||||
|
const manageAlerts = () => {
|
||||||
|
const component = hasAlerts ? Alerts : AlertAdd
|
||||||
|
const params = {url, channel: "push", hideSpaceField: true}
|
||||||
|
|
||||||
|
pushModal(component, params, {replaceState})
|
||||||
|
}
|
||||||
|
|
||||||
let showMenu = $state(false)
|
let showMenu = $state(false)
|
||||||
let replaceState = $state(false)
|
let replaceState = $state(false)
|
||||||
let element: Element | undefined = $state()
|
let element: Element | undefined = $state()
|
||||||
@@ -66,22 +82,23 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
replaceState = Boolean(element?.closest(".drawer"))
|
replaceState = Boolean(element?.closest(".drawer"))
|
||||||
pullConservatively({relays: [url], filters: [{kinds: [GROUP_META]}]})
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={element}>
|
<div bind:this={element} class="flex h-full flex-col justify-between">
|
||||||
<SecondaryNavSection class="max-h-screen">
|
<SecondaryNavSection>
|
||||||
<div>
|
<div>
|
||||||
<SecondaryNavItem class="w-full !justify-between" onclick={openMenu}>
|
<SecondaryNavItem class="w-full !justify-between" onclick={openMenu}>
|
||||||
<strong class="ellipsize">{displayRelayUrl(url)}</strong>
|
<strong class="ellipsize flex items-center gap-3">
|
||||||
|
{displayRelayUrl(url)}
|
||||||
|
</strong>
|
||||||
<Icon icon="alt-arrow-down" />
|
<Icon icon="alt-arrow-down" />
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{#if showMenu}
|
{#if showMenu}
|
||||||
<Popover hideOnClick onClose={toggleMenu}>
|
<Popover hideOnClick onClose={toggleMenu}>
|
||||||
<ul
|
<ul
|
||||||
transition:fly
|
transition:fly
|
||||||
class="menu absolute z-popover mt-2 w-full rounded-box bg-base-100 p-2 shadow-xl">
|
class="menu absolute z-popover mt-2 w-full gap-1 rounded-box bg-base-100 p-2 shadow-xl">
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={showMembers}>
|
<Button onclick={showMembers}>
|
||||||
<Icon icon="user-rounded" />
|
<Icon icon="user-rounded" />
|
||||||
@@ -111,10 +128,18 @@
|
|||||||
</Popover>
|
</Popover>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-h-0 flex-col gap-1 overflow-auto">
|
<div class="flex max-h-[calc(100vh-150px)] min-h-0 flex-col gap-1 overflow-auto">
|
||||||
<SecondaryNavItem {replaceState} href={makeSpacePath(url)}>
|
<SecondaryNavItem {replaceState} href={makeSpacePath(url)}>
|
||||||
<Icon icon="home-smile" /> Home
|
<Icon icon="home-smile" /> Home
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
|
{#if ENABLE_ZAPS}
|
||||||
|
<SecondaryNavItem
|
||||||
|
{replaceState}
|
||||||
|
href={goalsPath}
|
||||||
|
notification={$notifications.has(goalsPath)}>
|
||||||
|
<Icon icon="star-fall-minimalistic-2" /> Goals
|
||||||
|
</SecondaryNavItem>
|
||||||
|
{/if}
|
||||||
<SecondaryNavItem
|
<SecondaryNavItem
|
||||||
{replaceState}
|
{replaceState}
|
||||||
href={threadsPath}
|
href={threadsPath}
|
||||||
@@ -127,28 +152,45 @@
|
|||||||
notification={$notifications.has(calendarPath)}>
|
notification={$notifications.has(calendarPath)}>
|
||||||
<Icon icon="calendar-minimalistic" /> Calendar
|
<Icon icon="calendar-minimalistic" /> Calendar
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
<div class="h-2"></div>
|
{#if hasNip29($relay)}
|
||||||
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
{#if $userRooms.length > 0}
|
||||||
{#each $userRooms as room, i (room)}
|
<div class="h-2"></div>
|
||||||
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
|
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
||||||
{/each}
|
{/if}
|
||||||
{#if $otherRooms.length > 0}
|
{#each $userRooms as room, i (room)}
|
||||||
<div class="h-2"></div>
|
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
|
||||||
<SecondaryNavHeader>
|
{/each}
|
||||||
{#if $userRooms.length > 0}
|
{#if $otherRooms.length > 0}
|
||||||
Other Rooms
|
<div class="h-2"></div>
|
||||||
{:else}
|
<SecondaryNavHeader>
|
||||||
Rooms
|
{#if $userRooms.length > 0}
|
||||||
{/if}
|
Other Rooms
|
||||||
</SecondaryNavHeader>
|
{:else}
|
||||||
|
Rooms
|
||||||
|
{/if}
|
||||||
|
</SecondaryNavHeader>
|
||||||
|
{/if}
|
||||||
|
{#each $otherRooms as room, i (room)}
|
||||||
|
<MenuSpaceRoomItem {replaceState} {url} {room} />
|
||||||
|
{/each}
|
||||||
|
<SecondaryNavItem {replaceState} onclick={addRoom}>
|
||||||
|
<Icon icon="add-circle" />
|
||||||
|
Create room
|
||||||
|
</SecondaryNavItem>
|
||||||
|
{:else}
|
||||||
|
<SecondaryNavItem
|
||||||
|
{replaceState}
|
||||||
|
href={chatPath}
|
||||||
|
notification={$notifications.has(chatPath)}>
|
||||||
|
<Icon icon="chat-round" /> Chat
|
||||||
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
{#each $otherRooms as room, i (room)}
|
|
||||||
<MenuSpaceRoomItem {replaceState} {url} {room} />
|
|
||||||
{/each}
|
|
||||||
<SecondaryNavItem {replaceState} onclick={addRoom}>
|
|
||||||
<Icon icon="add-circle" />
|
|
||||||
Create room
|
|
||||||
</SecondaryNavItem>
|
|
||||||
</div>
|
</div>
|
||||||
</SecondaryNavSection>
|
</SecondaryNavSection>
|
||||||
|
<div class="p-4">
|
||||||
|
<button class="btn btn-neutral btn-sm w-full" onclick={manageAlerts}>
|
||||||
|
<Icon icon="bell" />
|
||||||
|
Manage Alerts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||||
import ChannelName from "@app/components/ChannelName.svelte"
|
import ChannelName from "@app/components/ChannelName.svelte"
|
||||||
import {makeRoomPath} from "@app/routes"
|
import {makeRoomPath} from "@app/routes"
|
||||||
import {deriveChannel, channelIsLocked} from "@app/state"
|
import {deriveChannel} from "@app/state"
|
||||||
import {notifications} from "@app/notifications"
|
import {notifications} from "@app/notifications"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
href={path}
|
href={path}
|
||||||
{replaceState}
|
{replaceState}
|
||||||
notification={notify ? $notifications.has(path) : false}>
|
notification={notify ? $notifications.has(path) : false}>
|
||||||
{#if channelIsLocked($channel)}
|
{#if $channel?.closed || $channel?.private}
|
||||||
<Icon icon="lock" size={4} />
|
<Icon icon="lock" size={4} />
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon="hashtag" />
|
<Icon icon="hashtag" />
|
||||||
|
|||||||
@@ -5,23 +5,22 @@
|
|||||||
import CardButton from "@lib/components/CardButton.svelte"
|
import CardButton from "@lib/components/CardButton.svelte"
|
||||||
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
|
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
|
||||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||||
import {userRoomsByUrl, PLATFORM_RELAY} from "@app/state"
|
import {userRoomsByUrl, PLATFORM_RELAYS} from "@app/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
const addSpace = () => pushModal(SpaceAdd)
|
const addSpace = () => pushModal(SpaceAdd)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="column menu gap-2">
|
<div class="column menu gap-2">
|
||||||
{#if PLATFORM_RELAY}
|
{#each PLATFORM_RELAYS as url (url)}
|
||||||
<MenuSpacesItem url={PLATFORM_RELAY} />
|
<MenuSpacesItem {url} />
|
||||||
<Divider />
|
{:else}
|
||||||
{:else if $userRoomsByUrl.size > 0}
|
{#if $userRoomsByUrl.size > 0}
|
||||||
{#each $userRoomsByUrl.keys() as url (url)}
|
{#each $userRoomsByUrl.keys() as url (url)}
|
||||||
<MenuSpacesItem {url} />
|
<MenuSpacesItem {url} />
|
||||||
{/each}
|
{/each}
|
||||||
<Divider />
|
<Divider />
|
||||||
{/if}
|
{/if}
|
||||||
{#if !PLATFORM_RELAY}
|
|
||||||
<Button onclick={addSpace}>
|
<Button onclick={addSpace}>
|
||||||
<CardButton>
|
<CardButton>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
@@ -35,5 +34,5 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</CardButton>
|
</CardButton>
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,26 +10,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let modal: any = $state()
|
|
||||||
const hash = $derived($page.url.hash.slice(1))
|
const hash = $derived($page.url.hash.slice(1))
|
||||||
const hashIsValid = $derived(Boolean($modals[hash]))
|
const modal = $derived($modals[hash])
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if ($modals[hash]) {
|
|
||||||
modal = $modals[hash]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeyDown} />
|
<svelte:window onkeydown={onKeyDown} />
|
||||||
|
|
||||||
{#if hashIsValid && modal?.options?.drawer}
|
{#if modal?.options?.drawer}
|
||||||
<Drawer onClose={clearModals} {...modal.options}>
|
<Drawer onClose={clearModals} {...modal.options}>
|
||||||
{#key modal.id}
|
{#key modal.id}
|
||||||
<modal.component {...modal.props} />
|
<modal.component {...modal.props} />
|
||||||
{/key}
|
{/key}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
{:else if hashIsValid && modal}
|
{:else if modal}
|
||||||
<Dialog onClose={clearModals} {...modal.options}>
|
<Dialog onClose={clearModals} {...modal.options}>
|
||||||
{#key modal.id}
|
{#key modal.id}
|
||||||
<modal.component {...modal.props} />
|
<modal.component {...modal.props} />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {nip19} from "nostr-tools"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {ctx} from "@welshman/lib"
|
import {formatTimestamp} from "@welshman/lib"
|
||||||
import {getListTags, getPubkeyTagValues} from "@welshman/util"
|
import {getListTags, getPubkeyTagValues} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {formatTimestamp, userMutes} from "@welshman/app"
|
import {Router} from "@welshman/router"
|
||||||
|
import {userMutes} from "@welshman/app"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
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"
|
||||||
@@ -18,16 +19,18 @@
|
|||||||
children,
|
children,
|
||||||
minimal = false,
|
minimal = false,
|
||||||
hideProfile = false,
|
hideProfile = false,
|
||||||
|
url,
|
||||||
...restProps
|
...restProps
|
||||||
}: {
|
}: {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
children: Snippet
|
children: Snippet
|
||||||
minimal?: boolean
|
minimal?: boolean
|
||||||
hideProfile?: boolean
|
hideProfile?: boolean
|
||||||
|
url?: string
|
||||||
class?: string
|
class?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
const relays = ctx.app.router.Event(event).getUrls()
|
const relays = Router.get().Event(event).getUrls()
|
||||||
const nevent = nip19.neventEncode({id: event.id, relays})
|
const nevent = nip19.neventEncode({id: event.id, relays})
|
||||||
|
|
||||||
const ignoreMute = () => {
|
const ignoreMute = () => {
|
||||||
@@ -50,9 +53,9 @@
|
|||||||
<div class="flex justify-between gap-2">
|
<div class="flex justify-between gap-2">
|
||||||
{#if !hideProfile}
|
{#if !hideProfile}
|
||||||
{#if minimal}
|
{#if minimal}
|
||||||
@<ProfileName pubkey={event.pubkey} />
|
@<ProfileName pubkey={event.pubkey} {url} />
|
||||||
{:else}
|
{:else}
|
||||||
<Profile pubkey={event.pubkey} />
|
<Profile pubkey={event.pubkey} {url} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<CalendarEventDate event={props.event} />
|
<CalendarEventDate event={props.event} />
|
||||||
<div class="flex flex-grow flex-col">
|
<div class="flex flex-grow flex-col">
|
||||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
<CalendarEventHeader event={props.event} />
|
||||||
<CalendarEventHeader event={props.event} />
|
|
||||||
</div>
|
|
||||||
<div class="flex py-2 opacity-50">
|
<div class="flex py-2 opacity-50">
|
||||||
<div class="h-px flex-grow bg-base-content opacity-25"></div>
|
<div class="h-px flex-grow bg-base-content opacity-25"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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, EventContent} from "@welshman/util"
|
||||||
import {pubkey} from "@welshman/app"
|
|
||||||
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 NoteContent from "@app/components/NoteContent.svelte"
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
@@ -11,24 +10,19 @@
|
|||||||
|
|
||||||
const {url, event} = $props()
|
const {url, event} = $props()
|
||||||
|
|
||||||
const onReactionClick = (content: string, events: TrustedEvent[]) => {
|
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
|
||||||
const reaction = events.find(e => e.pubkey === $pubkey)
|
|
||||||
|
|
||||||
if (reaction) {
|
const createReaction = (template: EventContent) =>
|
||||||
publishDelete({relays: [url], event: reaction})
|
publishReaction({...template, event, relays: [url]})
|
||||||
} else {
|
|
||||||
publishReaction({event, content, relays: [url]})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) =>
|
const onEmoji = (emoji: NativeEmoji) =>
|
||||||
publishReaction({event, content: emoji.unicode, relays: [url]})
|
publishReaction({event, content: emoji.unicode, relays: [url]})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<NoteCard {event} class="card2 bg-alt">
|
<NoteCard {event} {url} class="card2 bg-alt">
|
||||||
<NoteContent {event} expandMode="inline" />
|
<NoteContent {event} expandMode="inline" />
|
||||||
<div class="flex w-full justify-between gap-2">
|
<div class="flex w-full justify-between gap-2">
|
||||||
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right">
|
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
|
||||||
<EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box">
|
<EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box">
|
||||||
<Icon icon="smile-circle" size={4} />
|
<Icon icon="smile-circle" size={4} />
|
||||||
</EmojiButton>
|
</EmojiButton>
|
||||||
|
|||||||
@@ -1,48 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import {ctx} from "@welshman/lib"
|
|
||||||
import type {Filter} from "@welshman/util"
|
|
||||||
import {deriveEvents} from "@welshman/store"
|
|
||||||
import {repository, load, loadRelaySelections, formatTimestampRelative} from "@welshman/app"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Profile from "@app/components/Profile.svelte"
|
import Profile from "@app/components/Profile.svelte"
|
||||||
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||||
import {makeChatPath} from "@app/routes"
|
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||||
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
const {pubkey} = $props()
|
type Props = {
|
||||||
|
pubkey: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
|
const {pubkey, url}: Props = $props()
|
||||||
const events = deriveEvents(repository, {filters})
|
|
||||||
|
|
||||||
onMount(async () => {
|
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||||
// Make sure we have their relay selections before we load their posts
|
|
||||||
await loadRelaySelections(pubkey)
|
|
||||||
|
|
||||||
// Load at least one note, regardless of time frame
|
|
||||||
load({
|
|
||||||
filters: [{authors: [pubkey], limit: 1}],
|
|
||||||
relays: ctx.app.router.FromPubkeys([pubkey]).getUrls(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card2 bg-alt col-2 shadow-xl">
|
<div class="card2 bg-alt flex flex-col gap-4 shadow-xl">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<Profile {pubkey} />
|
<Profile {pubkey} {url} />
|
||||||
<Link class="btn btn-primary hidden sm:flex" href={makeChatPath([pubkey])}>
|
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
|
||||||
<Icon icon="letter" />
|
<Icon icon="user-circle" />
|
||||||
Start a Chat
|
View Profile
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ProfileInfo {pubkey} />
|
<ProfileInfo {pubkey} {url} />
|
||||||
{#if $events.length > 0}
|
<ProfileBadges {pubkey} {url} />
|
||||||
<div class="bg-alt badge badge-neutral border-none">
|
<Button onclick={openProfile} class="btn btn-primary sm:hidden">
|
||||||
Last active {formatTimestampRelative($events[0].created_at)}
|
<Icon icon="user-circle" />
|
||||||
</div>
|
View Profile
|
||||||
{/if}
|
</Button>
|
||||||
<Link class="btn btn-primary sm:hidden" href={makeChatPath([pubkey])}>
|
|
||||||
<Icon icon="letter" />
|
|
||||||
Start a Chat
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
|
import {splitAt} from "@welshman/lib"
|
||||||
import {userProfile} from "@welshman/app"
|
import {userProfile} from "@welshman/app"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.svelte"
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
@@ -8,50 +10,76 @@
|
|||||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||||
import MenuSpaces from "@app/components/MenuSpaces.svelte"
|
import MenuSpaces from "@app/components/MenuSpaces.svelte"
|
||||||
|
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
|
||||||
import MenuSettings from "@app/components/MenuSettings.svelte"
|
import MenuSettings from "@app/components/MenuSettings.svelte"
|
||||||
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||||
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state"
|
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
import {notifications} from "@app/notifications"
|
import {notifications} from "@app/notifications"
|
||||||
interface Props {
|
|
||||||
children?: import("svelte").Snippet
|
type Props = {
|
||||||
|
children?: Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
const {children}: Props = $props()
|
const {children}: Props = $props()
|
||||||
|
|
||||||
const addSpace = () => pushModal(SpaceAdd)
|
const addSpace = () => pushModal(SpaceAdd)
|
||||||
|
|
||||||
const showSpacesMenu = () => (spacePaths.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd))
|
const showSpacesMenu = () => (spaceUrls.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd))
|
||||||
|
|
||||||
|
const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls})
|
||||||
|
|
||||||
const showSettingsMenu = () => pushModal(MenuSettings)
|
const showSettingsMenu = () => pushModal(MenuSettings)
|
||||||
|
|
||||||
const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
|
const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
|
||||||
|
|
||||||
|
const hasNotification = (url: string) => {
|
||||||
|
const path = makeSpacePath(url)
|
||||||
|
|
||||||
|
return !$page.url.pathname.startsWith(path) && $notifications.has(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let windowHeight = $state(0)
|
||||||
|
|
||||||
|
const itemHeight = 56
|
||||||
|
const navPadding = 6 * itemHeight
|
||||||
|
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
|
||||||
const spaceUrls = $derived(Array.from($userRoomsByUrl.keys()))
|
const spaceUrls = $derived(Array.from($userRoomsByUrl.keys()))
|
||||||
const spacePaths = $derived(spaceUrls.map(url => makeSpacePath(url)))
|
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, spaceUrls))
|
||||||
const anySpaceNotifications = $derived(
|
const anySpaceNotifications = $derived(spaceUrls.some(hasNotification))
|
||||||
spacePaths.some(path => !$page.url.pathname.startsWith(path) && $notifications.has(path)),
|
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(hasNotification))
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="sail sait saib relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
<svelte:window bind:innerHeight={windowHeight} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
||||||
<div class="flex h-full flex-col justify-between">
|
<div class="flex h-full flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
{#if PLATFORM_RELAY}
|
{#each PLATFORM_RELAYS as url (url)}
|
||||||
<PrimaryNavItemSpace url={PLATFORM_RELAY} />
|
<PrimaryNavItemSpace {url} />
|
||||||
{:else}
|
{:else}
|
||||||
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
|
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
|
||||||
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
|
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
{#each spaceUrls as url (url)}
|
{#each primarySpaceUrls as url (url)}
|
||||||
<PrimaryNavItemSpace {url} />
|
<PrimaryNavItemSpace {url} />
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if secondarySpaceUrls.length > 0}
|
||||||
|
<PrimaryNavItem
|
||||||
|
title="Other Spaces"
|
||||||
|
class="tooltip-right"
|
||||||
|
onclick={showOtherSpacesMenu}
|
||||||
|
notification={otherSpaceNotifications}>
|
||||||
|
<Avatar icon="widget" class="!h-10 !w-10" />
|
||||||
|
</PrimaryNavItem>
|
||||||
|
{/if}
|
||||||
<PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right">
|
<PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right">
|
||||||
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
|
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
{/if}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
@@ -78,7 +106,7 @@
|
|||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|
||||||
<!-- a little extra something for ios -->
|
<!-- a little extra something for ios -->
|
||||||
<div class="fixed bottom-0 left-0 right-0 z-nav h-14 bg-base-100 md:hidden"></div>
|
<div class="fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden"></div>
|
||||||
<div
|
<div
|
||||||
class="border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
class="border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
||||||
<div class="content-padding-x content-sizing flex justify-between px-2">
|
<div class="content-padding-x content-sizing flex justify-between px-2">
|
||||||
@@ -92,9 +120,14 @@
|
|||||||
notification={$notifications.has("/chat")}>
|
notification={$notifications.has("/chat")}>
|
||||||
<Avatar icon="letter" class="!h-10 !w-10" />
|
<Avatar icon="letter" class="!h-10 !w-10" />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<PrimaryNavItem title="Spaces" onclick={showSpacesMenu} notification={anySpaceNotifications}>
|
{#if PLATFORM_RELAYS.length !== 1}
|
||||||
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
|
<PrimaryNavItem
|
||||||
</PrimaryNavItem>
|
title="Spaces"
|
||||||
|
onclick={showSpacesMenu}
|
||||||
|
notification={anySpaceNotifications}>
|
||||||
|
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
|
||||||
|
</PrimaryNavItem>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<PrimaryNavItem title="Settings" onclick={showSettingsMenu}>
|
<PrimaryNavItem title="Settings" onclick={showSettingsMenu}>
|
||||||
<Avatar icon="settings" src={$userProfile?.picture} class="!h-10 !w-10" />
|
<Avatar icon="settings" src={$userProfile?.picture} class="!h-10 !w-10" />
|
||||||
|
|||||||
@@ -1,47 +1,63 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
|
import {removeNil} from "@welshman/lib"
|
||||||
|
import {displayPubkey} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
session,
|
|
||||||
userFollows,
|
|
||||||
deriveUserWotScore,
|
|
||||||
deriveProfile,
|
|
||||||
deriveHandleForPubkey,
|
deriveHandleForPubkey,
|
||||||
displayHandle,
|
displayHandle,
|
||||||
|
deriveProfile,
|
||||||
deriveProfileDisplay,
|
deriveProfileDisplay,
|
||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.svelte"
|
||||||
import WotScore from "@lib/components/WotScore.svelte"
|
import WotScore from "@app/components/WotScore.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
import {clip} from "@app/toast"
|
||||||
|
|
||||||
const {pubkey} = $props()
|
type Props = {
|
||||||
|
pubkey: string
|
||||||
|
url?: string
|
||||||
|
showPubkey?: boolean
|
||||||
|
avatarSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
const profile = deriveProfile(pubkey)
|
const {pubkey, url, showPubkey, avatarSize = 10}: Props = $props()
|
||||||
const profileDisplay = deriveProfileDisplay(pubkey)
|
|
||||||
|
const relays = removeNil([url])
|
||||||
|
const profile = deriveProfile(pubkey, relays)
|
||||||
|
const profileDisplay = deriveProfileDisplay(pubkey, relays)
|
||||||
const handle = deriveHandleForPubkey(pubkey)
|
const handle = deriveHandleForPubkey(pubkey)
|
||||||
const score = deriveUserWotScore(pubkey)
|
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||||
|
|
||||||
const following = $derived(
|
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
|
||||||
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex max-w-full gap-3">
|
<div class="flex max-w-full items-start gap-3">
|
||||||
<Button onclick={openProfile} class="py-1">
|
<Button onclick={openProfile} class="py-1">
|
||||||
<Avatar src={$profile?.picture} size={10} />
|
<Avatar src={$profile?.picture} size={avatarSize} />
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex min-w-0 flex-col">
|
<div class="flex min-w-0 flex-col">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
|
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
|
||||||
{$profileDisplay}
|
{$profileDisplay}
|
||||||
</Button>
|
</Button>
|
||||||
<WotScore score={$score} active={following} />
|
<WotScore {pubkey} />
|
||||||
</div>
|
|
||||||
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
|
|
||||||
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if $handle}
|
||||||
|
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
|
||||||
|
{displayHandle($handle)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if showPubkey}
|
||||||
|
<div class="flex items-center gap-1 overflow-hidden text-ellipsis text-xs opacity-60">
|
||||||
|
{displayPubkey(pubkey)}
|
||||||
|
<Button onclick={copyPubkey} class="pt-1">
|
||||||
|
<Icon size={3} icon="copy" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import {load} from "@welshman/net"
|
||||||
|
import {Router} from "@welshman/router"
|
||||||
|
import type {Filter} from "@welshman/util"
|
||||||
|
import {deriveEvents} from "@welshman/store"
|
||||||
|
import {formatTimestampRelative} from "@welshman/lib"
|
||||||
|
import {NOTE, ROOMS, MESSAGE, THREAD, COMMENT, getRelayTags, getListTags} from "@welshman/util"
|
||||||
|
import {repository, loadRelaySelections} from "@welshman/app"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
|
||||||
|
import {membershipsByPubkey} from "@app/state"
|
||||||
|
import {goToEvent} from "@app/routes"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pubkey: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {pubkey, url}: Props = $props()
|
||||||
|
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
|
||||||
|
const events = deriveEvents(repository, {filters})
|
||||||
|
const membership = $derived($membershipsByPubkey.get(pubkey))
|
||||||
|
const relays = $derived(getRelayTags(getListTags(membership)))
|
||||||
|
|
||||||
|
const viewEvent = () => goToEvent($events[0]!)
|
||||||
|
|
||||||
|
const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url})
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Make sure we have their relay selections before we load their posts
|
||||||
|
await loadRelaySelections(pubkey)
|
||||||
|
|
||||||
|
// Load groups and at least one note, regardless of time frame
|
||||||
|
load({
|
||||||
|
filters: [
|
||||||
|
{authors: [pubkey], kinds: [ROOMS]},
|
||||||
|
{authors: [pubkey], limit: 1, kinds: [NOTE, MESSAGE, THREAD, COMMENT]},
|
||||||
|
],
|
||||||
|
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#if $events.length > 0}
|
||||||
|
<Button onclick={viewEvent} class="badge badge-neutral">
|
||||||
|
Last active {formatTimestampRelative($events[0].created_at)}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{#if relays.length > 0}
|
||||||
|
<Button onclick={openSpaces} class="badge badge-neutral">
|
||||||
|
{relays.length}
|
||||||
|
{relays.length === 1 ? "space" : "spaces"}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user