Compare commits
93 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 |
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
android
|
||||||
|
ios
|
||||||
|
build
|
||||||
+3
-2
@@ -5,13 +5,14 @@ VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
|||||||
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
||||||
VITE_PLATFORM_NAME=Flotilla
|
VITE_PLATFORM_NAME=Flotilla
|
||||||
VITE_PLATFORM_LOGO=static/flotilla.png
|
VITE_PLATFORM_LOGO=static/flotilla.png
|
||||||
VITE_PLATFORM_RELAY=
|
VITE_PLATFORM_RELAYS=
|
||||||
VITE_PLATFORM_ACCENT="#7161FF"
|
VITE_PLATFORM_ACCENT="#7161FF"
|
||||||
VITE_PLATFORM_SECONDARY="#EB5E28"
|
VITE_PLATFORM_SECONDARY="#EB5E28"
|
||||||
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
|
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
|
||||||
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/
|
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/,wss://indexer.coracle.social/
|
||||||
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
|
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
|
||||||
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
|
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
|
||||||
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
|
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
|
||||||
|
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
||||||
VITE_GLITCHTIP_API_KEY=
|
VITE_GLITCHTIP_API_KEY=
|
||||||
GLITCHTIP_AUTH_TOKEN=
|
GLITCHTIP_AUTH_TOKEN=
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
package-lock.json -diff
|
pnpm-lock.yaml -diff
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['master']
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: docker-container
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
- name: Generate artifact attestation
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||||
|
subject-digest: ${{ steps.push.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
@@ -60,6 +60,7 @@ google-services.json
|
|||||||
GoogleService-Info.plist
|
GoogleService-Info.plist
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
|
.roo
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
pnpm run lint
|
pnpm run lint
|
||||||
pnpm run check
|
pnpm run check
|
||||||
|
|
||||||
|
if [[ ! -z $(cat package.json | grep 'link:') ]]; then
|
||||||
|
echo "Some packages are linked to local files!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,46 @@
|
|||||||
# 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
|
# 1.0.4
|
||||||
|
|
||||||
* Fix thunk status click handler
|
* Fix thunk status click handler
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
@@ -4,14 +4,6 @@ A discord-like nostr client based on the idea of "relays as groups".
|
|||||||
|
|
||||||
If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797
|
If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797
|
||||||
|
|
||||||
# Deploy
|
|
||||||
|
|
||||||
To run your own Flotilla, it's as simple as:
|
|
||||||
|
|
||||||
- `pnpm install`
|
|
||||||
- `pnpm run build`
|
|
||||||
- `npx serve build`
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
You can also optionally create an `.env` 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):
|
||||||
@@ -20,7 +12,7 @@ You can also optionally create an `.env` file and populate it with the following
|
|||||||
- `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` file and populate it with the following
|
|||||||
|
|
||||||
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` file, and build the app.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Replace with your password
|
pnpm install
|
||||||
PASSWORD=<YOUR PASSWORD HERE>
|
pnpm run build
|
||||||
|
npx serve build
|
||||||
# Add the user and set a password
|
|
||||||
adduser flotilla
|
|
||||||
echo flotilla:$PASSWORD | chpasswd
|
|
||||||
|
|
||||||
# Login as flotilla
|
|
||||||
sudo su flotilla
|
|
||||||
|
|
||||||
# Go to flotilla's home directory
|
|
||||||
cd ~
|
|
||||||
|
|
||||||
# Install nvm, yarn, clone repos
|
|
||||||
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
|
||||||
|
|
||||||
# Update PATH
|
|
||||||
. ~/.bashrc
|
|
||||||
|
|
||||||
# Clone repository and install dependencies
|
|
||||||
git clone https://github.com/coracle-social/flotilla.git
|
|
||||||
cd ~/flotilla
|
|
||||||
nvm install
|
|
||||||
nvm use
|
|
||||||
pnpm i
|
|
||||||
|
|
||||||
# Optionally create and populate .env to suit your use case
|
|
||||||
|
|
||||||
# Build the app
|
|
||||||
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
|
|
||||||
|
|
||||||
# Exit back to root
|
|
||||||
exit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Once you've exited back to root, you can set up nginx. Place the following in a file named after your domain in the `/etc/nginx/sites-available` directory, for example, `flotilla.example.com`. This should match the `A` record you registered above.
|
Or, if you prefer to use a container:
|
||||||
|
|
||||||
```conf
|
```sh
|
||||||
server {
|
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
|
||||||
listen 80;
|
|
||||||
server_name <SERVER NAME>;
|
|
||||||
root /home/flotilla/flotilla/build;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Now you can run `certbot`, which will provision a TLS certificate for your domain and update your nginx configuration.
|
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir ./mount
|
||||||
|
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
|
||||||
```
|
```
|
||||||
certbot --nginx -d <SERVER NAME>
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, enable the site and restart nginx. If you want to be careful, run `nginx -t` before restarting nginx.
|
|
||||||
|
|
||||||
```
|
|
||||||
ln -s /etc/nginx/sites-{available,enabled}/<SERVER NAME>
|
|
||||||
service nginx restart
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, visit your domain. You should be all set up!
|
|
||||||
|
|
||||||
# Development
|
|
||||||
|
|
||||||
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, run `pnpm run format && pnpm run lint` and fix any errors that come up.
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "social.flotilla"
|
applicationId "social.flotilla"
|
||||||
minSdk rootProject.ext.minSdkVersion
|
minSdk rootProject.ext.minSdkVersion
|
||||||
targetSdk rootProject.ext.targetSdkVersion
|
targetSdk rootProject.ext.targetSdkVersion
|
||||||
versionCode 18
|
versionCode 23
|
||||||
versionName "1.0.4"
|
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,8 +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-keyboard')
|
||||||
|
implementation project(':capacitor-push-notifications')
|
||||||
|
implementation project(':capawesome-capacitor-badge')
|
||||||
implementation project(':nostr-signer-capacitor-plugin')
|
implementation project(':nostr-signer-capacitor-plugin')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,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>
|
||||||
|
|||||||
@@ -2,11 +2,20 @@
|
|||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.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/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
include ':capacitor-keyboard'
|
include ':capacitor-keyboard'
|
||||||
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android')
|
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.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/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')
|
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
# Fetch tags and set to env vars
|
# Fetch tags and set to env vars
|
||||||
git fetch --prune --unshallow --tags
|
git fetch --prune --unshallow --tags || true
|
||||||
git describe --tags --abbrev=0
|
git describe --tags --abbrev=0 || true
|
||||||
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
|
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
|
||||||
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
|
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
|
||||||
|
|
||||||
# Remove link overrides
|
# Install dependencies
|
||||||
node remove-pnpm-overrides.js package.json
|
CI=0 pnpm i
|
||||||
|
|
||||||
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
|
|
||||||
pnpm i --no-frozen-lockfile
|
|
||||||
|
|
||||||
# Rebuild sharp
|
# Rebuild sharp
|
||||||
pnpm rebuild
|
pnpm rebuild
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const config: CapacitorConfig = {
|
|||||||
style: "DARK",
|
style: "DARK",
|
||||||
resizeOnFullScreen: true,
|
resizeOnFullScreen: true,
|
||||||
},
|
},
|
||||||
|
Badge: {
|
||||||
|
persist: true,
|
||||||
|
autoClear: true
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
|
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
|
||||||
// server: {
|
// server: {
|
||||||
|
|||||||
@@ -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 = 11;
|
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 = 1.0.4;
|
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 = 11;
|
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 = 1.0.4;
|
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>
|
||||||
@@ -11,8 +11,11 @@ install! 'cocoapods', :disable_input_output_paths => true
|
|||||||
def capacitor_pods
|
def capacitor_pods
|
||||||
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
|
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
|
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
|
||||||
|
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 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
|
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 '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'
|
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
|
||||||
|
|
||||||
|
|||||||
+25
-31
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flotilla",
|
"name": "flotilla",
|
||||||
"version": "1.0.4",
|
"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,32 +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",
|
||||||
"@capacitor/keyboard": "^7.0.0",
|
"@capacitor/keyboard": "^7.0.0",
|
||||||
"@noble/curves": "^1.5.0",
|
"@capacitor/push-notifications": "^7.0.1",
|
||||||
"@noble/hashes": "^1.4.0",
|
"@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.2.5",
|
"@welshman/app": "^0.4.0",
|
||||||
"@welshman/content": "^0.2.2",
|
"@welshman/content": "^0.4.0",
|
||||||
"@welshman/dvm": "^0.2.0",
|
"@welshman/editor": "^0.4.0",
|
||||||
"@welshman/editor": "^0.2.4",
|
"@welshman/feeds": "^0.4.0",
|
||||||
"@welshman/feeds": "^0.2.2",
|
"@welshman/lib": "^0.4.0",
|
||||||
"@welshman/lib": "^0.2.2",
|
"@welshman/net": "^0.4.0",
|
||||||
"@welshman/net": "^0.2.3",
|
"@welshman/relay": "^0.4.0",
|
||||||
"@welshman/relay": "^0.2.0",
|
"@welshman/router": "^0.4.0",
|
||||||
"@welshman/router": "^0.2.0",
|
"@welshman/signer": "^0.4.0",
|
||||||
"@welshman/signer": "^0.2.3",
|
"@welshman/store": "^0.4.0",
|
||||||
"@welshman/store": "^0.2.0",
|
"@welshman/util": "^0.4.0",
|
||||||
"@welshman/util": "^0.2.3",
|
"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",
|
||||||
@@ -71,25 +76,14 @@
|
|||||||
"husky": "^9.1.6",
|
"husky": "^9.1.6",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
"nostr-signer-capacitor-plugin": "^0.0.4",
|
"nostr-signer-capacitor-plugin": "^0.0.4",
|
||||||
"nostr-tools": "^2.7.2",
|
"nostr-tools": "^2.14.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"prettier-plugin-tailwindcss": "^0.6.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": {
|
"pnpm": {
|
||||||
"overrides": {
|
|
||||||
"@welshman/lib": "link:../welshman/packages/lib",
|
|
||||||
"@welshman/util": "link:../welshman/packages/util",
|
|
||||||
"@welshman/app": "link:../welshman/packages/app",
|
|
||||||
"@welshman/content": "link:../welshman/packages/content",
|
|
||||||
"@welshman/dvm": "link:../welshman/packages/dvm",
|
|
||||||
"@welshman/feeds": "link:../welshman/packages/feeds",
|
|
||||||
"@welshman/net": "link:../welshman/packages/net",
|
|
||||||
"@welshman/relay": "link:../welshman/packages/relay",
|
|
||||||
"@welshman/router": "link:../welshman/packages/router",
|
|
||||||
"@welshman/signer": "link:../welshman/packages/signer",
|
|
||||||
"@welshman/store": "link:../welshman/packages/store",
|
|
||||||
"@welshman/editor": "link:../welshman/packages/editor"
|
|
||||||
},
|
|
||||||
"ignoredBuiltDependencies": [
|
"ignoredBuiltDependencies": [
|
||||||
"@sentry/cli",
|
"@sentry/cli",
|
||||||
"esbuild"
|
"esbuild"
|
||||||
|
|||||||
Generated
+958
-58
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
|||||||
// This script is necessary for installing stuff on a host, since our links don't exist there.
|
|
||||||
|
|
||||||
import fs from "fs"
|
|
||||||
|
|
||||||
const pkgName = process.argv[2]
|
|
||||||
|
|
||||||
if (!pkgName?.endsWith("package.json")) {
|
|
||||||
console.log("File passed was not a package.json file")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pkg = JSON.parse(fs.readFileSync(pkgName, "utf8"))
|
|
||||||
|
|
||||||
if (pkg.pnpm && pkg.pnpm.overrides) {
|
|
||||||
// Use $package notation to make sure we only get one copy of each welshman dependency
|
|
||||||
// TODO: move welshman to a single package to straighten all this out.
|
|
||||||
for (const k of Object.keys(pkg.pnpm.overrides)) {
|
|
||||||
pkg.pnpm.overrides[k] = '$' + k
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(pkgName, JSON.stringify(pkg, null, 2) + "\n")
|
|
||||||
console.log("Removed pnpm.overrides from package.json")
|
|
||||||
} else {
|
|
||||||
console.log("No pnpm.overrides found in package.json")
|
|
||||||
}
|
|
||||||
+104
-94
@@ -1,6 +1,7 @@
|
|||||||
|
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 {randomId, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
|
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
|
||||||
import type {Feed} from "@welshman/feeds"
|
import type {Feed} from "@welshman/feeds"
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
@@ -12,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,
|
||||||
@@ -33,7 +34,7 @@ import {
|
|||||||
getRelaysFromList,
|
getRelaysFromList,
|
||||||
RelayMode,
|
RelayMode,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import {Pool, PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
|
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
|
||||||
import {Router} from "@welshman/router"
|
import {Router} from "@welshman/router"
|
||||||
import {
|
import {
|
||||||
pubkey,
|
pubkey,
|
||||||
@@ -52,15 +53,14 @@ import {
|
|||||||
dropSession,
|
dropSession,
|
||||||
tagEventForComment,
|
tagEventForComment,
|
||||||
tagEventForQuote,
|
tagEventForQuote,
|
||||||
thunkIsComplete,
|
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,
|
||||||
ALERT,
|
|
||||||
NOTIFIER_PUBKEY,
|
NOTIFIER_PUBKEY,
|
||||||
NOTIFIER_RELAY,
|
NOTIFIER_RELAY,
|
||||||
userRoomsByUrl,
|
userRoomsByUrl,
|
||||||
@@ -83,21 +83,6 @@ export const getPubkeyPetname = (pubkey: string) => {
|
|||||||
return display
|
return display
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getThunkError = (thunk: Thunk) =>
|
|
||||||
new Promise<string>(resolve => {
|
|
||||||
thunk.subscribe($thunk => {
|
|
||||||
for (const [relay, status] of Object.entries($thunk.status)) {
|
|
||||||
if (status === PublishStatus.Failure) {
|
|
||||||
resolve($thunk.details[relay])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thunkIsComplete($thunk)) {
|
|
||||||
resolve("")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
|
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const nevent = nip19.neventEncode({
|
const nevent = nip19.neventEncode({
|
||||||
@@ -142,37 +127,10 @@ 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([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||||
|
|
||||||
@@ -180,7 +138,7 @@ export const addSpaceMembership = async (url: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||||
@@ -188,11 +146,11 @@ export const removeSpaceMembership = async (url: string) => {
|
|||||||
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([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||||
@@ -201,7 +159,7 @@ export const addRoomMembership = async (url: string, room: string, name: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
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([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||||
@@ -222,7 +180,7 @@ 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,
|
||||||
@@ -244,7 +202,7 @@ 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,
|
||||||
...Router.get().FromUser().getUrls(),
|
...Router.get().FromUser().getUrls(),
|
||||||
@@ -256,13 +214,18 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
|
|||||||
|
|
||||||
// Relay access
|
// Relay access
|
||||||
|
|
||||||
|
export const attemptAuth = (url: string) =>
|
||||||
|
Pool.get()
|
||||||
|
.get(url)
|
||||||
|
.auth.attemptAuth(e => signer.get()?.sign(e))
|
||||||
|
|
||||||
export const checkRelayAccess = async (url: string, claim = "") => {
|
export const checkRelayAccess = async (url: string, claim = "") => {
|
||||||
const socket = Pool.get().get(url)
|
const socket = Pool.get().get(url)
|
||||||
|
|
||||||
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
|
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],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -281,6 +244,9 @@ export const checkRelayAccess = async (url: string, claim = "") => {
|
|||||||
// Ignore messages about the relay ignoring ours
|
// Ignore messages about the relay ignoring ours
|
||||||
if (error?.startsWith("mute: ")) return
|
if (error?.startsWith("mute: ")) return
|
||||||
|
|
||||||
|
// Ignore rejected empty claims
|
||||||
|
if (!claim && error?.includes("invite code")) return
|
||||||
|
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,7 +278,7 @@ export const checkRelayAuth = async (url: string, timeout = 3000) => {
|
|||||||
const socket = Pool.get().get(url)
|
const socket = Pool.get().get(url)
|
||||||
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
|
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
|
||||||
|
|
||||||
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
|
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
|
||||||
@@ -339,20 +305,26 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
export const makeDelete = ({event}: {event: TrustedEvent}) => {
|
export const makeDelete = ({event, tags = []}: {event: TrustedEvent; tags?: string[][]}) => {
|
||||||
const tags = [["k", String(event.kind)], ...tagEvent(event)]
|
const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags]
|
||||||
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
|
||||||
@@ -366,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 = ({
|
||||||
@@ -393,7 +365,7 @@ export const makeReaction = ({content, event, tags: paramTags = []}: ReactionPar
|
|||||||
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[]}) =>
|
||||||
@@ -406,42 +378,64 @@ 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 = {
|
export type AlertParams = {
|
||||||
feed: Feed
|
feed: Feed
|
||||||
cron: string
|
|
||||||
email: string
|
|
||||||
bunker: string
|
|
||||||
secret: string
|
|
||||||
description: string
|
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 ({cron, email, feed, bunker, secret, description}: AlertParams) => {
|
export const makeAlert = async (params: AlertParams) => {
|
||||||
const tags = [
|
const tags = [
|
||||||
["feed", JSON.stringify(feed)],
|
["feed", JSON.stringify(params.feed)],
|
||||||
["cron", cron],
|
|
||||||
["email", email],
|
|
||||||
["locale", LOCALE],
|
["locale", LOCALE],
|
||||||
["timezone", TIMEZONE],
|
["timezone", TIMEZONE],
|
||||||
["description", description],
|
["description", params.description],
|
||||||
["channel", "email"],
|
|
||||||
[
|
|
||||||
"handler",
|
|
||||||
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
|
|
||||||
"wss://relay.nostr.band/",
|
|
||||||
"web",
|
|
||||||
],
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if (bunker) {
|
for (const [relay, claim] of Object.entries(params.claims)) {
|
||||||
tags.push(["nip46", secret, bunker])
|
tags.push(["claim", relay, claim])
|
||||||
}
|
}
|
||||||
|
|
||||||
return createEvent(ALERT, {
|
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)),
|
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
|
||||||
tags: [
|
tags: [
|
||||||
["d", randomId()],
|
["d", randomId()],
|
||||||
@@ -452,3 +446,19 @@ export const makeAlert = async ({cron, email, feed, bunker, secret, description}
|
|||||||
|
|
||||||
export const publishAlert = async (params: AlertParams) =>
|
export const publishAlert = async (params: AlertParams) =>
|
||||||
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+167
-107
@@ -1,30 +1,56 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
|
import {decrypt} from "@welshman/signer"
|
||||||
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
|
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 type {Filter} from "@welshman/util"
|
||||||
import type {Nip46ResponseWithResult} from "@welshman/signer"
|
|
||||||
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
|
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
|
||||||
import {pubkey} from "@welshman/app"
|
import {pubkey, signer, getThunkError} 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 FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import InfoBunker from "@app/components/InfoBunker.svelte"
|
|
||||||
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
|
|
||||||
import {
|
import {
|
||||||
GENERAL,
|
|
||||||
alerts,
|
alerts,
|
||||||
getMembershipUrls,
|
getMembershipUrls,
|
||||||
getMembershipRoomsByUrl,
|
|
||||||
userMembership,
|
userMembership,
|
||||||
|
NOTIFIER_PUBKEY,
|
||||||
|
NOTIFIER_RELAY,
|
||||||
} from "@app/state"
|
} from "@app/state"
|
||||||
import {loadAlertStatuses} from "@app/requests"
|
import {loadAlertStatuses, requestRelayClaim} from "@app/requests"
|
||||||
import {publishAlert} from "@app/commands"
|
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"
|
import {pushToast} from "@app/toast"
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
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 timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
|
||||||
const minute = randomInt(0, 59)
|
const minute = randomInt(0, 59)
|
||||||
@@ -32,49 +58,22 @@
|
|||||||
const WEEKLY = `0 ${minute} ${hour} * * 1`
|
const WEEKLY = `0 ${minute} ${hour} * * 1`
|
||||||
const DAILY = `0 ${minute} ${hour} * * *`
|
const DAILY = `0 ${minute} ${hour} * * *`
|
||||||
|
|
||||||
let loading = false
|
let loading = $state(false)
|
||||||
let cron = WEEKLY
|
let cron = $state(WEEKLY)
|
||||||
let email = $alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || ""
|
let claim = $state("")
|
||||||
let relay = ""
|
let email = $state($alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || "")
|
||||||
let bunker = ""
|
|
||||||
let secret = ""
|
|
||||||
let notifyThreads = true
|
|
||||||
let notifyCalendar = true
|
|
||||||
let notifyChat = false
|
|
||||||
let showBunker = false
|
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const controller = new BunkerConnectController({
|
|
||||||
onNostrConnect: (response: Nip46ResponseWithResult) => {
|
|
||||||
bunker = controller.broker.getBunkerUrl()
|
|
||||||
secret = controller.broker.params.clientSecret
|
|
||||||
showBunker = false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const connectBunker = () => {
|
|
||||||
showBunker = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideBunker = () => {
|
|
||||||
showBunker = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearBunker = () => {
|
|
||||||
bunker = ""
|
|
||||||
secret = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!email.includes("@")) {
|
if (channel === "email" && !email.includes("@")) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Please provide an email address",
|
message: "Please provide an email address",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!relay) {
|
if (!url) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
message: "Please select a space",
|
message: "Please select a space",
|
||||||
@@ -105,22 +104,69 @@
|
|||||||
|
|
||||||
if (notifyChat) {
|
if (notifyChat) {
|
||||||
display.push("chat")
|
display.push("chat")
|
||||||
filters.push({
|
filters.push({kinds: [MESSAGE]})
|
||||||
kinds: [MESSAGE],
|
|
||||||
"#h": [GENERAL, ...getMembershipRoomsByUrl(relay, $userMembership)],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
|
const claims = claim ? {[url]: claim} : {}
|
||||||
const description = `${cadence} alert for ${displayList(display)} on ${displayRelayUrl(relay)}, sent via email.`
|
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url))
|
||||||
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(relay))
|
const description = `for ${displayList(display)} on ${displayRelayUrl(url)}`
|
||||||
const thunk = await publishAlert({cron, email, feed, bunker, secret, description})
|
const params: AlertParams = {feed, claims, description}
|
||||||
|
|
||||||
await thunk.result
|
if (channel === "email") {
|
||||||
await loadAlertStatuses($pubkey!)
|
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!"})
|
pushToast({message: "Your alert has been successfully created!"})
|
||||||
back()
|
back()
|
||||||
@@ -128,6 +174,20 @@
|
|||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!canSendPushNotifications()) {
|
||||||
|
channel = "email"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
requestRelayClaim(url).then(code => {
|
||||||
|
if (code) {
|
||||||
|
claim = code
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="column gap-4" onsubmit={preventDefault(submit)}>
|
<form class="column gap-4" onsubmit={preventDefault(submit)}>
|
||||||
@@ -136,13 +196,20 @@
|
|||||||
Add an Alert
|
Add an Alert
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
{#if showBunker}
|
{#if canSendPushNotifications()}
|
||||||
<div class="card2 flex flex-col items-center gap-4 bg-base-300">
|
<FieldInline>
|
||||||
<p>Scan using a nostr signer, or click to copy.</p>
|
{#snippet label()}
|
||||||
<BunkerConnect {controller} />
|
<p>Alert Type*</p>
|
||||||
<Button class="btn btn-neutral btn-sm" onclick={hideBunker}>Cancel</Button>
|
{/snippet}
|
||||||
</div>
|
{#snippet input()}
|
||||||
{:else}
|
<select bind:value={channel} class="select select-bordered">
|
||||||
|
<option value="email">Email Digest</option>
|
||||||
|
<option value="push">Push Notification</option>
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{/if}
|
||||||
|
{#if channel === "email"}
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
<p>Email Address*</p>
|
<p>Email Address*</p>
|
||||||
@@ -164,12 +231,14 @@
|
|||||||
</select>
|
</select>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
|
{/if}
|
||||||
|
{#if !hideSpaceField}
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
<p>Space*</p>
|
<p>Space*</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<select bind:value={relay} class="select select-bordered">
|
<select bind:value={url} class="select select-bordered">
|
||||||
<option value="" disabled selected>Choose a space URL</option>
|
<option value="" disabled selected>Choose a space URL</option>
|
||||||
{#each getMembershipUrls($userMembership) as url (url)}
|
{#each getMembershipUrls($userMembership) as url (url)}
|
||||||
<option value={url}>{displayRelayUrl(url)}</option>
|
<option value={url}>{displayRelayUrl(url)}</option>
|
||||||
@@ -177,59 +246,50 @@
|
|||||||
</select>
|
</select>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
<FieldInline>
|
{/if}
|
||||||
{#snippet label()}
|
<FieldInline>
|
||||||
<p>Notifications*</p>
|
{#snippet label()}
|
||||||
{/snippet}
|
<p>Notifications*</p>
|
||||||
{#snippet input()}
|
{/snippet}
|
||||||
<div class="flex items-center justify-end gap-4">
|
{#snippet input()}
|
||||||
<span class="flex gap-3">
|
<div class="flex items-center justify-end gap-4">
|
||||||
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
|
<span class="flex gap-3">
|
||||||
Threads
|
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
|
||||||
</span>
|
Threads
|
||||||
<span class="flex gap-3">
|
</span>
|
||||||
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
|
<span class="flex gap-3">
|
||||||
Calendar
|
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
|
||||||
</span>
|
Calendar
|
||||||
<span class="flex gap-3">
|
</span>
|
||||||
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
|
<span class="flex gap-3">
|
||||||
Chat
|
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
|
||||||
</span>
|
Chat
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</FieldInline>
|
|
||||||
<div class="card2 flex flex-col gap-3 bg-base-300">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<strong>Connect a Bunker</strong>
|
|
||||||
<span class="flex items-center gap-2 text-sm" class:text-primary={bunker}>
|
|
||||||
{#if bunker}
|
|
||||||
<Icon icon="check-circle" size={5} />
|
|
||||||
Connected
|
|
||||||
{:else}
|
|
||||||
<Icon icon="close-circle" size={5} />
|
|
||||||
Not Connected
|
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm">
|
{/snippet}
|
||||||
Required for receiving alerts about spaces with access controls. You can get one from your
|
</FieldInline>
|
||||||
<Button class="text-primary" onclick={() => pushModal(InfoBunker)}>remote signer app</Button
|
<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>
|
</p>
|
||||||
{#if bunker}
|
{/snippet}
|
||||||
<Button class="btn btn-neutral btn-sm flex-grow" onclick={clearBunker}>Disconnect</Button>
|
</FieldInline>
|
||||||
{:else}
|
|
||||||
<Button class="btn btn-primary btn-sm w-full flex-grow" onclick={connectBunker}
|
|
||||||
>Connect</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon="alt-arrow-left" />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={loading || showBunker}>
|
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||||
<Spinner {loading}>Confirm</Spinner>
|
<Spinner {loading}>Confirm</Spinner>
|
||||||
<Icon icon="alt-arrow-right" />
|
<Icon icon="alt-arrow-right" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import type {Alert} from "@app/state"
|
import type {Alert} from "@app/state"
|
||||||
import {NOTIFIER_RELAY} from "@app/state"
|
import {NOTIFIER_RELAY, NOTIFIER_PUBKEY} from "@app/state"
|
||||||
import {publishDelete} from "@app/commands"
|
import {publishDelete} from "@app/commands"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
const {alert}: Props = $props()
|
const {alert}: Props = $props()
|
||||||
|
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY]})
|
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY], tags: [["p", NOTIFIER_PUBKEY]]})
|
||||||
pushToast({message: "Your alert has been deleted!"})
|
pushToast({message: "Your alert has been deleted!"})
|
||||||
history.back()
|
history.back()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {parseJson, nthEq} from "@welshman/lib"
|
import {parseJson} from "@welshman/lib"
|
||||||
import {displayFeeds} from "@welshman/feeds"
|
import {displayFeeds} from "@welshman/feeds"
|
||||||
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
|
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import AlertDelete from "@app/components/AlertDelete.svelte"
|
import AlertDelete from "@app/components/AlertDelete.svelte"
|
||||||
import type {Alert} from "@app/state"
|
import type {Alert} from "@app/state"
|
||||||
import {alertStatuses} from "@app/state"
|
import {deriveAlertStatus} from "@app/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -15,8 +15,7 @@
|
|||||||
|
|
||||||
const {alert}: Props = $props()
|
const {alert}: Props = $props()
|
||||||
|
|
||||||
const address = $derived(getAddress(alert.event))
|
const status = deriveAlertStatus(getAddress(alert.event))
|
||||||
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
|
|
||||||
const cron = $derived(getTagValue("cron", alert.tags))
|
const cron = $derived(getTagValue("cron", alert.tags))
|
||||||
const channel = $derived(getTagValue("channel", alert.tags))
|
const channel = $derived(getTagValue("channel", alert.tags))
|
||||||
const feeds = $derived(getTagValues("feed", alert.tags))
|
const feeds = $derived(getTagValues("feed", alert.tags))
|
||||||
@@ -39,24 +38,24 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<div class="flex-inline gap-1">{description}</div>
|
<div class="flex-inline gap-1">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
{#if status}
|
{#if $status}
|
||||||
{@const statusText = getTagValue("status", status.tags) || "error"}
|
{@const statusText = getTagValue("status", $status.tags) || "error"}
|
||||||
{#if statusText === "ok"}
|
{#if statusText === "ok"}
|
||||||
<span
|
<span
|
||||||
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
|
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)}>
|
data-tip={getTagValue("message", $status.tags)}>
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
{:else if statusText === "pending"}
|
{:else if statusText === "pending"}
|
||||||
<span
|
<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"
|
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)}>
|
data-tip={getTagValue("message", $status.tags)}>
|
||||||
Pending
|
Pending
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<span
|
||||||
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
|
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)}>
|
data-tip={getTagValue("message", $status.tags)}>
|
||||||
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
|
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {getTagValue} from "@welshman/util"
|
||||||
import {pubkey} from "@welshman/app"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import AlertAdd from "@app/components/AlertAdd.svelte"
|
import AlertAdd from "@app/components/AlertAdd.svelte"
|
||||||
import AlertItem from "@app/components/AlertItem.svelte"
|
import AlertItem from "@app/components/AlertItem.svelte"
|
||||||
import {loadAlertStatuses, loadAlerts} from "@app/requests"
|
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {alerts} from "@app/state"
|
import {alerts} from "@app/state"
|
||||||
|
|
||||||
const startAlert = () => pushModal(AlertAdd)
|
type Props = {
|
||||||
|
url?: string
|
||||||
|
channel?: string
|
||||||
|
hideSpaceField?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
|
||||||
loadAlertStatuses($pubkey!)
|
|
||||||
loadAlerts($pubkey!)
|
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
|
||||||
})
|
|
||||||
|
const filteredAlerts = $derived(
|
||||||
|
url ? $alerts.filter(a => getTagValue("feed", a.tags)?.includes(url)) : $alerts,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
|
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
|
||||||
@@ -29,10 +34,10 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
{#each $alerts as alert (alert.event.id)}
|
{#each filteredAlerts as alert (alert.event.id)}
|
||||||
<AlertItem {alert} />
|
<AlertItem {alert} />
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-center opacity-75 py-12">No alerts found</p>
|
<p class="text-center opacity-75 py-12">Nothing here yet!</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import {randomId, HOUR} from "@welshman/lib"
|
import {randomId, HOUR} from "@welshman/lib"
|
||||||
import {createEvent, EVENT_TIME} from "@welshman/util"
|
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
||||||
import {publishThunk} from "@welshman/app"
|
import {publishThunk} from "@welshman/app"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import {daysBetween} from "@lib/util"
|
import {daysBetween} from "@lib/util"
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {PROTECTED, GENERAL, tagRoom} from "@app/state"
|
import {PROTECTED} from "@app/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
const event = createEvent(EVENT_TIME, {
|
const event = makeEvent(EVENT_TIME, {
|
||||||
content: ed.getText({blockSeparator: "\n"}).trim(),
|
content: ed.getText({blockSeparator: "\n"}).trim(),
|
||||||
tags: [
|
tags: [
|
||||||
["d", initialValues?.d || randomId()],
|
["d", initialValues?.d || randomId()],
|
||||||
@@ -73,7 +73,6 @@
|
|||||||
["end", end.toString()],
|
["end", end.toString()],
|
||||||
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
||||||
...ed.storage.nostr.getEditorTags(),
|
...ed.storage.nostr.getEditorTags(),
|
||||||
tagRoom(GENERAL, url),
|
|
||||||
PROTECTED,
|
PROTECTED,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,23 +11,23 @@
|
|||||||
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: string
|
url: string
|
||||||
room: string
|
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
replyTo?: (event: TrustedEvent) => void
|
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())
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<Content {event} {url} />
|
<Content minimalQuote {event} {url} />
|
||||||
{#if thunk}
|
{#if thunk}
|
||||||
<ThunkStatus {thunk} class="mt-2" />
|
<ThunkStatus {thunk} class="mt-2" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -95,7 +95,10 @@
|
|||||||
<button
|
<button
|
||||||
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
||||||
class:group-hover:opacity-100={!isMobile}>
|
class:group-hover:opacity-100={!isMobile}>
|
||||||
<ChannelMessageEmojiButton {url} {room} {event} />
|
{#if ENABLE_ZAPS}
|
||||||
|
<ChannelMessageZapButton {url} {event} />
|
||||||
|
{/if}
|
||||||
|
<ChannelMessageEmojiButton {url} {event} />
|
||||||
{#if replyTo}
|
{#if replyTo}
|
||||||
<Button class="btn join-item btn-xs" onclick={reply}>
|
<Button class="btn join-item btn-xs" onclick={reply}>
|
||||||
<Icon icon="reply" size={4} />
|
<Icon icon="reply" size={4} />
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
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 EventDeleteConfirm from "@app/components/EventDeleteConfirm.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"
|
||||||
|
|
||||||
@@ -40,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}
|
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {int, nthNe, MINUTE, sortBy, remove, formatTimestampAsDate} from "@welshman/lib"
|
import {
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
int,
|
||||||
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
|
ms,
|
||||||
|
partition,
|
||||||
|
spec,
|
||||||
|
nthEq,
|
||||||
|
nthNe,
|
||||||
|
MINUTE,
|
||||||
|
sortBy,
|
||||||
|
remove,
|
||||||
|
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,
|
||||||
@@ -61,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()
|
||||||
}
|
}
|
||||||
@@ -191,7 +249,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PageBar>
|
</PageBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col-reverse pt-4">
|
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
||||||
<div bind:this={dynamicPadding}></div>
|
<div bind:this={dynamicPadding}></div>
|
||||||
{#if missingInboxes.includes($pubkey!)}
|
{#if missingInboxes.includes($pubkey!)}
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
||||||
|
|
||||||
|
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if ($uploading) return
|
if ($uploading) return
|
||||||
|
|
||||||
@@ -40,11 +42,21 @@
|
|||||||
submit,
|
submit,
|
||||||
uploading,
|
uploading,
|
||||||
aggressive: true,
|
aggressive: true,
|
||||||
disableFileUpload: true,
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
||||||
|
<Button
|
||||||
|
data-tip="Add an image"
|
||||||
|
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
|
||||||
|
disabled={$uploading}
|
||||||
|
onclick={uploadFiles}>
|
||||||
|
{#if $uploading}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Icon icon="gallery-send" />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
<div class="chat-editor flex-grow overflow-hidden">
|
<div class="chat-editor flex-grow overflow-hidden">
|
||||||
<EditorContent {editor} />
|
<EditorContent {editor} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
showEntire?: boolean
|
showEntire?: boolean
|
||||||
hideMediaAtDepth?: number
|
hideMediaAtDepth?: number
|
||||||
expandMode?: string
|
expandMode?: string
|
||||||
|
minimalQuote?: boolean
|
||||||
depth?: number
|
depth?: number
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
showEntire = $bindable(false),
|
showEntire = $bindable(false),
|
||||||
hideMediaAtDepth = 1,
|
hideMediaAtDepth = 1,
|
||||||
expandMode = "block",
|
expandMode = "block",
|
||||||
|
minimalQuote = false,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
url,
|
url,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
@@ -153,7 +155,13 @@
|
|||||||
<ContentMention value={parsed.value} {url} />
|
<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} {url} {hideMediaAtDepth} value={parsed.value} {event} />
|
<ContentQuote
|
||||||
|
{depth}
|
||||||
|
{url}
|
||||||
|
{hideMediaAtDepth}
|
||||||
|
value={parsed.value}
|
||||||
|
{event}
|
||||||
|
minimal={minimalQuote} />
|
||||||
{:else}
|
{:else}
|
||||||
<Link
|
<Link
|
||||||
external
|
external
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<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"
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</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" />
|
||||||
@@ -52,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">
|
||||||
|
|||||||
@@ -1,49 +1,52 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy} from "svelte"
|
import {onMount, onDestroy} from "svelte"
|
||||||
import {now} from "@welshman/lib"
|
import {displayUrl} from "@welshman/lib"
|
||||||
import {BLOSSOM_AUTH, makeEvent, getTags, getTagValue, tagsFromIMeta} from "@welshman/util"
|
import {getTags, decryptFile, getTagValue, tagsFromIMeta} from "@welshman/util"
|
||||||
import {signer} from "@welshman/app"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {imgproxy} from "@app/state"
|
import {imgproxy} from "@app/state"
|
||||||
|
|
||||||
const {value, event, ...props} = $props()
|
const {value, event, ...props} = $props()
|
||||||
|
|
||||||
const url = value.url.toString()
|
const url = value.url.toString()
|
||||||
|
const meta =
|
||||||
// If we fail to fetch the image, try authenticating if we have a blossom hash
|
getTags("imeta", event.tags)
|
||||||
const onerror = async () => {
|
|
||||||
const meta = getTags("imeta", event.tags)
|
|
||||||
.map(tagsFromIMeta)
|
.map(tagsFromIMeta)
|
||||||
.find(meta => getTagValue("url", meta) === url)
|
.find(meta => getTagValue("url", meta) === url) || event.tags
|
||||||
const hash = meta ? getTagValue("x", meta) : undefined
|
|
||||||
|
|
||||||
if (hash && $signer) {
|
const key = getTagValue("decryption-key", meta)
|
||||||
const event = await signer.get().sign(
|
const nonce = getTagValue("decryption-nonce", meta)
|
||||||
makeEvent(BLOSSOM_AUTH, {
|
const algorithm = getTagValue("encryption-algorithm", meta)
|
||||||
tags: [
|
|
||||||
["t", "get"],
|
|
||||||
["x", hash],
|
|
||||||
["expiration", String(now() + 30)],
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const onError = () => {
|
||||||
headers: {
|
hasError = true
|
||||||
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.status === 200) {
|
|
||||||
src = URL.createObjectURL(await res.blob())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasError = $state(false)
|
||||||
let src = $state(imgproxy(url))
|
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(() => {
|
onDestroy(() => {
|
||||||
URL.revokeObjectURL(src)
|
URL.revokeObjectURL(src)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img alt="" {src} {onerror} {...props} />
|
{#if hasError}
|
||||||
|
<a href={url} class="link-content whitespace-nowrap">
|
||||||
|
<Icon icon="link-round" size={3} class="inline-block" />
|
||||||
|
{displayUrl(url)}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<img alt="" {src} onerror={onError} {...props} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {removeNil} from "@welshman/lib"
|
import {removeNil} from "@welshman/lib"
|
||||||
import type {ProfilePointer} from "@welshman/content"
|
import type {ProfilePointer} from "@welshman/content"
|
||||||
import {displayProfile} from "@welshman/util"
|
import {deriveProfileDisplay} from "@welshman/app"
|
||||||
import {deriveProfile} from "@welshman/app"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
@@ -14,11 +13,11 @@
|
|||||||
|
|
||||||
const {value, url}: Props = $props()
|
const {value, url}: Props = $props()
|
||||||
|
|
||||||
const profile = deriveProfile(value.pubkey, removeNil([url]))
|
const display = deriveProfileDisplay(value.pubkey, removeNil([url]))
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button onclick={openProfile} class="link-content">
|
<Button onclick={openProfile} class="link-content">
|
||||||
@{displayProfile($profile)}
|
@{$display}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {goto} from "$app/navigation"
|
|
||||||
import {nthEq} from "@welshman/lib"
|
|
||||||
import {Router} from "@welshman/router"
|
import {Router} from "@welshman/router"
|
||||||
import {tracker, repository} from "@welshman/app"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD, EVENT_TIME} from "@welshman/util"
|
import {Address, MESSAGE} from "@welshman/util"
|
||||||
import {scrollToEvent} from "@lib/html"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
import NoteContent from "@app/components/NoteContent.svelte"
|
import 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"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: any
|
value: any
|
||||||
@@ -20,9 +16,10 @@
|
|||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
depth: number
|
depth: number
|
||||||
url?: string
|
url?: string
|
||||||
|
minimal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {value, event, depth, hideMediaAtDepth, url}: Props = $props()
|
const {value, event, depth, hideMediaAtDepth, url, minimal}: Props = $props()
|
||||||
|
|
||||||
const {id, identifier, kind, pubkey, relays = []} = value
|
const {id, identifier, kind, pubkey, relays = []} = value
|
||||||
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
|
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
|
||||||
@@ -37,67 +34,28 @@
|
|||||||
? nip19.neventEncode({id, relays: mergedRelays})
|
? nip19.neventEncode({id, relays: mergedRelays})
|
||||||
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
|
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
|
||||||
|
|
||||||
const openMessage = (url: string, room: string, id: string) => {
|
|
||||||
const event = repository.getEvent(id)
|
|
||||||
|
|
||||||
if (event) {
|
|
||||||
goto(makeRoomPath(url, room))
|
|
||||||
scrollToEvent(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Boolean(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onclick = () => {
|
const onclick = () => {
|
||||||
if ($quote) {
|
if ($quote) {
|
||||||
if ($quote.kind === DIRECT_MESSAGE) {
|
goToEvent($quote)
|
||||||
return scrollToEvent($quote.id)
|
} else {
|
||||||
}
|
window.open(entityLink(entity))
|
||||||
|
|
||||||
const [url] = tracker.getRelays($quote.id)
|
|
||||||
const room = $quote.tags.find(nthEq(0, ROOM))?.[1]
|
|
||||||
|
|
||||||
if (url && room) {
|
|
||||||
if ($quote.kind === THREAD) {
|
|
||||||
return goto(makeThreadPath(url, $quote.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($quote.kind === EVENT_TIME) {
|
|
||||||
return goto(makeCalendarPath(url, $quote.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($quote.kind === MESSAGE) {
|
|
||||||
return scrollToEvent($quote.id) || openMessage(url, room, $quote.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const kind = $quote.tags.find(nthEq(0, "K"))?.[1]
|
|
||||||
const id = $quote.tags.find(nthEq(0, "E"))?.[1]
|
|
||||||
|
|
||||||
if (id && kind) {
|
|
||||||
if (parseInt(kind) === THREAD) {
|
|
||||||
return goto(makeThreadPath(url, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parseInt(kind) === EVENT_TIME) {
|
|
||||||
return goto(makeCalendarPath(url, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parseInt(kind) === MESSAGE) {
|
|
||||||
return scrollToEvent(id) || openMessage(url, room, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(entityLink(entity))
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button class="my-2 block max-w-full text-left" {onclick}>
|
<Button class="my-2 block max-w-full text-left" {onclick}>
|
||||||
{#if $quote}
|
{#if $quote}
|
||||||
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
|
{#if minimal && $quote.kind === MESSAGE}
|
||||||
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
|
<div
|
||||||
</NoteCard>
|
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
||||||
|
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
|
||||||
|
<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>
|
||||||
@@ -6,18 +6,21 @@
|
|||||||
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"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
noun: string
|
noun: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
|
hideZap?: boolean
|
||||||
customActions?: Snippet
|
customActions?: Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, noun, event, customActions}: Props = $props()
|
const {url, noun, event, hideZap, customActions}: Props = $props()
|
||||||
|
|
||||||
const showPopover = () => popover?.show()
|
const showPopover = () => popover?.show()
|
||||||
|
|
||||||
@@ -30,6 +33,11 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
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"
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
const tags = [...ed.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
|
const tags = [...ed.storage.nostr.getEditorTags(), PROTECTED]
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -33,18 +33,20 @@
|
|||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
if (controller.loading) return
|
if (controller.loading) return
|
||||||
|
|
||||||
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(controller.bunker)
|
|
||||||
|
|
||||||
if (!signerPubkey || relays.length === 0) {
|
|
||||||
return pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: "Sorry, it looks like that's an invalid bunker link.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.loading = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(controller.bunker)
|
||||||
|
|
||||||
|
console.log({signerPubkey, connectSecret, relays})
|
||||||
|
|
||||||
|
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 {clientSecret} = controller
|
||||||
const broker = new Nip46Broker({relays, clientSecret, signerPubkey})
|
const broker = new Nip46Broker({relays, clientSecret, signerPubkey})
|
||||||
const result = await broker.connect(connectSecret, NIP46_PERMS)
|
const result = await broker.connect(connectSecret, NIP46_PERMS)
|
||||||
@@ -64,6 +66,13 @@
|
|||||||
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 {
|
||||||
controller.loading = false
|
controller.loading = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {displayRelayUrl} 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,14 +13,19 @@
|
|||||||
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 {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
@@ -27,10 +33,14 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -55,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()
|
||||||
@@ -68,18 +85,20 @@
|
|||||||
})
|
})
|
||||||
</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" />
|
||||||
@@ -109,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}
|
||||||
@@ -125,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,30 +5,34 @@
|
|||||||
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} 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 $userRoomsByUrl.size > 0}
|
{#each PLATFORM_RELAYS as url (url)}
|
||||||
{#each $userRoomsByUrl.keys() as url (url)}
|
<MenuSpacesItem {url} />
|
||||||
<MenuSpacesItem {url} />
|
{:else}
|
||||||
{/each}
|
{#if $userRoomsByUrl.size > 0}
|
||||||
<Divider />
|
{#each $userRoomsByUrl.keys() as url (url)}
|
||||||
{/if}
|
<MenuSpacesItem {url} />
|
||||||
<Button onclick={addSpace}>
|
{/each}
|
||||||
<CardButton>
|
<Divider />
|
||||||
{#snippet icon()}
|
{/if}
|
||||||
<div><Icon icon="login-2" size={7} /></div>
|
<Button onclick={addSpace}>
|
||||||
{/snippet}
|
<CardButton>
|
||||||
{#snippet title()}
|
{#snippet icon()}
|
||||||
<div>Add a space</div>
|
<div><Icon icon="login-2" size={7} /></div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#snippet title()}
|
||||||
<div>Join or create a new space</div>
|
<div>Add a space</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CardButton>
|
{#snippet info()}
|
||||||
</Button>
|
<div>Join or create a new space</div>
|
||||||
|
{/snippet}
|
||||||
|
</CardButton>
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import {formatTimestampRelative} from "@welshman/lib"
|
|
||||||
import type {Filter} from "@welshman/util"
|
|
||||||
import {deriveEvents} from "@welshman/store"
|
|
||||||
import {load} from "@welshman/net"
|
|
||||||
import {Router} from "@welshman/router"
|
|
||||||
import {repository, loadRelaySelections} 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 {pubkeyLink} from "@app/state"
|
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||||
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
@@ -19,37 +14,21 @@
|
|||||||
|
|
||||||
const {pubkey, url}: Props = $props()
|
const {pubkey, url}: Props = $props()
|
||||||
|
|
||||||
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
|
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||||
const events = deriveEvents(repository, {filters})
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
// 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: Router.get().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} {url} />
|
<Profile {pubkey} {url} />
|
||||||
<Link external href={pubkeyLink(pubkey)} class="btn btn-primary hidden sm:flex">
|
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
|
||||||
<Icon icon="user-circle" />
|
<Icon icon="user-circle" />
|
||||||
See Complete Profile
|
View Profile
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ProfileInfo {pubkey} {url} />
|
<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)}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<Link external href={pubkeyLink(pubkey)} class="btn btn-primary sm:hidden">
|
|
||||||
<Icon icon="user-circle" />
|
<Icon icon="user-circle" />
|
||||||
See Complete Profile
|
View Profile
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.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"
|
||||||
@@ -57,8 +57,8 @@
|
|||||||
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
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" />
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
<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
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
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>
|
||||||
{#if !PLATFORM_RELAY}
|
{#if PLATFORM_RELAYS.length !== 1}
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
title="Spaces"
|
title="Spaces"
|
||||||
onclick={showSpacesMenu}
|
onclick={showSpacesMenu}
|
||||||
|
|||||||
@@ -1,54 +1,63 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {removeNil} from "@welshman/lib"
|
import {removeNil} from "@welshman/lib"
|
||||||
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
|
import {displayPubkey} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
session,
|
|
||||||
userFollows,
|
|
||||||
deriveUserWotScore,
|
|
||||||
deriveHandleForPubkey,
|
deriveHandleForPubkey,
|
||||||
displayHandle,
|
displayHandle,
|
||||||
deriveProfile,
|
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"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
url?: string
|
url?: string
|
||||||
|
showPubkey?: boolean
|
||||||
|
avatarSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const {pubkey, url}: Props = $props()
|
const {pubkey, url, showPubkey, avatarSize = 10}: Props = $props()
|
||||||
|
|
||||||
const relays = removeNil([url])
|
const relays = removeNil([url])
|
||||||
const profile = deriveProfile(pubkey, relays)
|
const profile = deriveProfile(pubkey, relays)
|
||||||
const profileDisplay = deriveProfileDisplay(pubkey, relays)
|
const profileDisplay = deriveProfileDisplay(pubkey, relays)
|
||||||
const handle = deriveHandleForPubkey(pubkey)
|
const handle = deriveHandleForPubkey(pubkey)
|
||||||
const score = deriveUserWotScore(pubkey)
|
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
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>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex pr-3">
|
<div class="flex pr-3">
|
||||||
{#each props.pubkeys.slice(0, 15) as pubkey (pubkey)}
|
{#each props.pubkeys.toSorted().slice(0, 15) as pubkey (pubkey)}
|
||||||
<div class="z-feature -mr-3 inline-block">
|
<div class="z-feature -mr-3 inline-block">
|
||||||
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {...props} />
|
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {...props} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {chunk, sleep, uniq} from "@welshman/lib"
|
import {chunk, sleep, uniq} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
createEvent,
|
makeEvent,
|
||||||
createProfile,
|
createProfile,
|
||||||
PROFILE,
|
PROFILE,
|
||||||
DELETE,
|
DELETE,
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chunks = chunk(500, repository.query([{authors: [$pubkey!]}]))
|
const chunks = chunk(500, repository.query([{authors: [$pubkey!]}]))
|
||||||
const profileEvent = createEvent(PROFILE, createProfile({name: "[deleted]"}))
|
const profileEvent = makeEvent(PROFILE, createProfile({name: "[deleted]"}))
|
||||||
const vanishEvent = createEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
|
const vanishEvent = makeEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
|
||||||
const denominator = chunks.length + 2
|
const denominator = chunks.length + 2
|
||||||
const relays = uniq([
|
const relays = uniq([
|
||||||
...INDEXER_RELAYS,
|
...INDEXER_RELAYS,
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await publishThunk({relays, event: createEvent(DELETE, {tags})})
|
await publishThunk({relays, event: makeEvent(DELETE, {tags})})
|
||||||
|
|
||||||
await incrementProgress()
|
await incrementProgress()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {removeNil} from "@welshman/lib"
|
|
||||||
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
|
|
||||||
import {
|
|
||||||
session,
|
|
||||||
userFollows,
|
|
||||||
deriveUserWotScore,
|
|
||||||
deriveHandleForPubkey,
|
|
||||||
displayHandle,
|
|
||||||
deriveProfile,
|
|
||||||
deriveProfileDisplay,
|
|
||||||
} from "@welshman/app"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Avatar from "@lib/components/Avatar.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"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
|
||||||
import WotScore from "@lib/components/WotScore.svelte"
|
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import Profile from "@app/components/Profile.svelte"
|
||||||
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||||
|
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||||
import {canDecrypt, pubkeyLink} from "@app/state"
|
import {canDecrypt, pubkeyLink} from "@app/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
@@ -30,41 +20,17 @@
|
|||||||
|
|
||||||
const {pubkey, url}: Props = $props()
|
const {pubkey, url}: Props = $props()
|
||||||
|
|
||||||
const relays = removeNil([url])
|
|
||||||
const profile = deriveProfile(pubkey, relays)
|
|
||||||
const display = deriveProfileDisplay(pubkey, relays)
|
|
||||||
const handle = deriveHandleForPubkey(pubkey)
|
|
||||||
const score = deriveUserWotScore(pubkey)
|
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const chatPath = makeChatPath([pubkey])
|
const chatPath = makeChatPath([pubkey])
|
||||||
|
|
||||||
const openChat = () => ($canDecrypt ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath}))
|
const openChat = () => ($canDecrypt ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath}))
|
||||||
|
|
||||||
const following = $derived(
|
|
||||||
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="column gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex max-w-full gap-3">
|
<Profile showPubkey avatarSize={14} {pubkey} {url} />
|
||||||
<span class="py-1">
|
|
||||||
<Avatar src={$profile?.picture} size={10} />
|
|
||||||
</span>
|
|
||||||
<div class="flex min-w-0 flex-col">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-bold overflow-hidden text-ellipsis">
|
|
||||||
{$display}
|
|
||||||
</span>
|
|
||||||
<WotScore score={$score} active={following} />
|
|
||||||
</div>
|
|
||||||
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
|
|
||||||
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ProfileInfo {pubkey} {url} />
|
<ProfileInfo {pubkey} {url} />
|
||||||
|
<ProfileBadges {pubkey} {url} />
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button onclick={back} class="hidden md:btn md:btn-link">
|
<Button onclick={back} class="hidden md:btn md:btn-link">
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon="alt-arrow-left" />
|
||||||
@@ -72,8 +38,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
|
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
|
||||||
<Icon icon="user-circle" />
|
<Avatar src="/coracle.png" />
|
||||||
See Complete Profile
|
Open in Coracle
|
||||||
</Link>
|
</Link>
|
||||||
<Button onclick={openChat} class="btn btn-primary">
|
<Button onclick={openChat} class="btn btn-primary">
|
||||||
<Icon icon="letter" />
|
<Icon icon="letter" />
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {nthNe} from "@welshman/lib"
|
||||||
import type {Profile} from "@welshman/util"
|
import type {Profile} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
getTag,
|
getTag,
|
||||||
createEvent,
|
makeEvent,
|
||||||
makeProfile,
|
makeProfile,
|
||||||
editProfile,
|
editProfile,
|
||||||
createProfile,
|
createProfile,
|
||||||
@@ -24,16 +25,19 @@
|
|||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
|
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
|
||||||
|
const router = Router.get()
|
||||||
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
|
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
|
||||||
const relays = [...getMembershipUrls($userMembership)]
|
const scenarios = [router.FromRelays(getMembershipUrls($userMembership))]
|
||||||
|
|
||||||
if (shouldBroadcast) {
|
if (shouldBroadcast) {
|
||||||
relays.push(...Router.get().FromUser().getUrls())
|
scenarios.push(router.FromUser(), router.Index())
|
||||||
|
template.tags = template.tags.filter(nthNe(0, "-"))
|
||||||
} else {
|
} else {
|
||||||
template.tags = uniqTags([...template.tags, PROTECTED])
|
template.tags = uniqTags([...template.tags, PROTECTED])
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = createEvent(template.kind, template)
|
const event = makeEvent(template.kind, template)
|
||||||
|
const relays = router.merge(scenarios).getUrls()
|
||||||
|
|
||||||
publishThunk({event, relays})
|
publishThunk({event, relays})
|
||||||
pushToast({message: "Your profile has been updated!"})
|
pushToast({message: "Your profile has been updated!"})
|
||||||
|
|||||||
@@ -14,5 +14,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $profile}
|
{#if $profile}
|
||||||
<Content event={{content: $profile.about, tags: []}} hideMediaAtDepth={0} />
|
<Content event={{content: $profile.about || "", tags: []}} hideMediaAtDepth={0} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
|
import {load} from "@welshman/net"
|
||||||
|
import {NOTE} from "@welshman/util"
|
||||||
|
import {fly} from "@lib/transition"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import NoteItem from "@app/components/NoteItem.svelte"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string
|
||||||
|
pubkey: string
|
||||||
|
limit?: number
|
||||||
|
fallback?: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, pubkey, limit = 1, fallback}: Props = $props()
|
||||||
|
|
||||||
|
const events = load({
|
||||||
|
relays: [url],
|
||||||
|
filters: [{authors: [pubkey], kinds: [NOTE], limit}],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#await events}
|
||||||
|
<p class="center my-12 flex">
|
||||||
|
<Spinner loading />
|
||||||
|
</p>
|
||||||
|
{:then events}
|
||||||
|
{#each events as event (event.id)}
|
||||||
|
<div in:fly>
|
||||||
|
<NoteItem {url} {event} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render fallback?.()}
|
||||||
|
{/each}
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
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"
|
||||||
@@ -8,13 +9,15 @@
|
|||||||
type Props = {
|
type Props = {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
url?: string
|
url?: string
|
||||||
|
class?: string
|
||||||
|
unstyled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {pubkey, url}: Props = $props()
|
const {pubkey, url, unstyled, ...props}: Props = $props()
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button onclick={preventDefault(openProfile)} class="link-content">
|
<Button onclick={preventDefault(openProfile)} class={cx(props.class, {"link-content": !unstyled})}>
|
||||||
@<ProfileName {pubkey} {url} />
|
@<ProfileName {pubkey} {url} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
|
import type {Writable} from "svelte/store"
|
||||||
import {type Instance} from "tippy.js"
|
import {type Instance} from "tippy.js"
|
||||||
import {append, remove, uniq} from "@welshman/lib"
|
import {append, remove, uniq} from "@welshman/lib"
|
||||||
import {profileSearch} from "@welshman/app"
|
import {profileSearch} from "@welshman/app"
|
||||||
@@ -15,11 +16,10 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
value: string[]
|
value: string[]
|
||||||
autofocus?: boolean
|
autofocus?: boolean
|
||||||
|
term?: Writable<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
let {value = $bindable(), autofocus = false}: Props = $props()
|
let {value = $bindable(), term = writable(""), autofocus = false}: Props = $props()
|
||||||
|
|
||||||
const term = writable("")
|
|
||||||
|
|
||||||
const search = (term: string) => $profileSearch.searchValues(term)
|
const search = (term: string) => $profileSearch.searchValues(term)
|
||||||
|
|
||||||
@@ -44,6 +44,9 @@
|
|||||||
let instance: any = $state()
|
let instance: any = $state()
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// @ts-ignore
|
||||||
|
oninput?.($term)
|
||||||
|
|
||||||
if ($term) {
|
if ($term) {
|
||||||
popover?.show()
|
popover?.show()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
|
||||||
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
import {getMembershipUrls, membershipsByPubkey} from "@app/state"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {pubkey}: Props = $props()
|
||||||
|
|
||||||
|
const spaceUrls = $derived(getMembershipUrls($membershipsByPubkey.get(pubkey)))
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each spaceUrls as url (url)}
|
||||||
|
<div class="card2 bg-alt flex flex-row items-center gap-2">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<SpaceAvatar {url} />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-grow flex-col">
|
||||||
|
<RelayName {url} />
|
||||||
|
<div class="text-sm opacity-75">
|
||||||
|
{url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link class="btn btn-primary" href={makeSpacePath(url)}>
|
||||||
|
Go to space
|
||||||
|
<Icon icon="alt-arrow-right" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card2 bg-alt text-center">
|
||||||
|
<p class="opacity-75">No spaces found for this user</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onclick={back} class="hidden md:btn md:btn-link">
|
||||||
|
<Icon icon="alt-arrow-left" />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {groupBy, uniq, uniqBy, batch, displayList} from "@welshman/lib"
|
import {groupBy, sum, uniq, uniqBy, batch, displayList} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
REACTION,
|
REACTION,
|
||||||
|
ZAP_RESPONSE,
|
||||||
getReplyFilters,
|
getReplyFilters,
|
||||||
getEmojiTags,
|
getEmojiTags,
|
||||||
getEmojiTag,
|
getEmojiTag,
|
||||||
|
fromMsats,
|
||||||
getTag,
|
getTag,
|
||||||
REPORT,
|
REPORT,
|
||||||
DELETE,
|
DELETE,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent, Zap} from "@welshman/util"
|
||||||
import {deriveEvents} from "@welshman/store"
|
import {deriveEvents, deriveEventsMapped} from "@welshman/store"
|
||||||
import {load} from "@welshman/net"
|
import {load} from "@welshman/net"
|
||||||
import {pubkey, repository, displayProfileByPubkey} from "@welshman/app"
|
import {pubkey, repository, getValidZap, displayProfileByPubkey} from "@welshman/app"
|
||||||
import {isMobile, preventDefault, stopPropagation} from "@lib/html"
|
import {isMobile, preventDefault, stopPropagation} from "@lib/html"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Reaction from "@app/components/Reaction.svelte"
|
import Reaction from "@app/components/Reaction.svelte"
|
||||||
import EventReportDetails from "@app/components/EventReportDetails.svelte"
|
import EventReportDetails from "@app/components/EventReportDetails.svelte"
|
||||||
|
import {REACTION_KINDS} from "@app/state"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -49,6 +52,12 @@
|
|||||||
filters: [{kinds: [REACTION], "#e": [event.id]}],
|
filters: [{kinds: [REACTION], "#e": [event.id]}],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const zaps = deriveEventsMapped<Zap>(repository, {
|
||||||
|
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||||
|
itemToEvent: item => item.response,
|
||||||
|
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
||||||
|
})
|
||||||
|
|
||||||
const onReactionClick = (events: TrustedEvent[]) => {
|
const onReactionClick = (events: TrustedEvent[]) => {
|
||||||
const reaction = events.find(e => e.pubkey === $pubkey)
|
const reaction = events.find(e => e.pubkey === $pubkey)
|
||||||
|
|
||||||
@@ -77,6 +86,8 @@
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const groupedZaps = $derived(groupBy(e => getReactionKey(e.request), $zaps))
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
||||||
@@ -84,7 +95,7 @@
|
|||||||
load({
|
load({
|
||||||
relays: [url],
|
relays: [url],
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
filters: getReplyFilters([event], {kinds: [REACTION, REPORT, DELETE]}),
|
filters: getReplyFilters([event], {kinds: [REPORT, DELETE, ...REACTION_KINDS]}),
|
||||||
onEvent: batch(300, (events: TrustedEvent[]) => {
|
onEvent: batch(300, (events: TrustedEvent[]) => {
|
||||||
load({
|
load({
|
||||||
relays: [url],
|
relays: [url],
|
||||||
@@ -100,7 +111,7 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $reactions.length > 0 || $reports.length > 0}
|
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0}
|
||||||
<div class="flex min-w-0 flex-wrap gap-2">
|
<div class="flex min-w-0 flex-wrap gap-2">
|
||||||
{#if url && $reports.length > 0}
|
{#if url && $reports.length > 0}
|
||||||
<button
|
<button
|
||||||
@@ -113,6 +124,24 @@
|
|||||||
<span>{$reports.length}</span>
|
<span>{$reports.length}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#each groupedZaps.entries() as [key, zaps]}
|
||||||
|
{@const amount = fromMsats(sum(zaps.map(zap => zap.invoiceAmount)))}
|
||||||
|
{@const pubkeys = uniq(zaps.map(zap => zap.request.pubkey))}
|
||||||
|
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
|
||||||
|
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
|
||||||
|
{@const tooltip = `${info} zapped`}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-tip={tooltip}
|
||||||
|
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full {reactionClass}"
|
||||||
|
class:tooltip={!noTooltip && !isMobile}
|
||||||
|
class:border={isOwn}
|
||||||
|
class:border-solid={isOwn}
|
||||||
|
class:border-primary={isOwn}>
|
||||||
|
<Reaction event={zaps[0].request} />
|
||||||
|
<span>{amount}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
{#each groupedReactions.entries() as [key, events]}
|
{#each groupedReactions.entries() as [key, events]}
|
||||||
{@const pubkeys = events.map(e => e.pubkey)}
|
{@const pubkeys = events.map(e => e.pubkey)}
|
||||||
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
|
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {randomId} from "@welshman/lib"
|
import {uniqBy, nth} from "@welshman/lib"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl, makeRoomMeta} from "@welshman/util"
|
||||||
import {deriveRelay} from "@welshman/app"
|
import {deriveRelay, getThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
@@ -10,41 +10,41 @@
|
|||||||
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 {hasNip29} from "@app/state"
|
import {hasNip29, loadChannel} from "@app/state"
|
||||||
import {addRoomMembership, nip29, getThunkError} from "@app/commands"
|
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
|
|
||||||
const room = randomId()
|
const room = makeRoomMeta()
|
||||||
const relay = deriveRelay(url)
|
const relay = deriveRelay(url)
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const tryCreate = async () => {
|
const tryCreate = async () => {
|
||||||
if (hasNip29($relay)) {
|
room.tags = uniqBy(nth(0), [...room.tags, ["name", name]])
|
||||||
const createMessage = await getThunkError(nip29.createRoom(url, room))
|
|
||||||
|
|
||||||
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
|
const createMessage = await getThunkError(createRoom(url, room))
|
||||||
return pushToast({theme: "error", message: createMessage})
|
|
||||||
}
|
|
||||||
|
|
||||||
const editMessage = await getThunkError(nip29.editMeta(url, room, {name}))
|
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
|
||||||
|
return pushToast({theme: "error", message: createMessage})
|
||||||
if (editMessage) {
|
|
||||||
return pushToast({theme: "error", message: editMessage})
|
|
||||||
}
|
|
||||||
|
|
||||||
const joinMessage = await getThunkError(nip29.joinRoom(url, room))
|
|
||||||
|
|
||||||
if (joinMessage && !joinMessage.includes("already")) {
|
|
||||||
return pushToast({theme: "error", message: joinMessage})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addRoomMembership(url, room, name)
|
const editMessage = await getThunkError(editRoom(url, room))
|
||||||
goto(makeSpacePath(url, room))
|
|
||||||
|
if (editMessage) {
|
||||||
|
return pushToast({theme: "error", message: editMessage})
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinMessage = await getThunkError(joinRoom(url, room))
|
||||||
|
|
||||||
|
if (joinMessage && !joinMessage.includes("already")) {
|
||||||
|
return pushToast({theme: "error", message: joinMessage})
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadChannel(url, room.id)
|
||||||
|
|
||||||
|
goto(makeSpacePath(url, room.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
@@ -72,23 +72,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<Field>
|
{#if hasNip29($relay)}
|
||||||
{#snippet label()}
|
<Field>
|
||||||
<p>Room Name</p>
|
{#snippet label()}
|
||||||
{/snippet}
|
<p>Room Name</p>
|
||||||
{#snippet input()}
|
{/snippet}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
{#snippet input()}
|
||||||
<Icon icon="hashtag" />
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
<input bind:value={name} class="grow" type="text" />
|
<Icon icon="hashtag" />
|
||||||
</label>
|
<input bind:value={name} class="grow" type="text" />
|
||||||
{/snippet}
|
</label>
|
||||||
</Field>
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
{:else}
|
||||||
|
<p class="bg-alt card2 row-2">
|
||||||
|
<Icon icon="danger" />
|
||||||
|
This relay does not support creating rooms.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon="alt-arrow-left" />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="btn btn-primary" disabled={!name || loading}>
|
<Button type="submit" class="btn btn-primary" disabled={!name || loading || !hasNip29($relay)}>
|
||||||
<Spinner {loading}>Create Room</Spinner>
|
<Spinner {loading}>Create Room</Spinner>
|
||||||
<Icon icon="alt-arrow-right" />
|
<Icon icon="alt-arrow-right" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {encrypt} from "nostr-tools/nip49"
|
import {encrypt} from "nostr-tools/nip49"
|
||||||
import {hexToBytes} from "@noble/hashes/utils"
|
import {hexToBytes} from "@welshman/lib"
|
||||||
import {makeSecret} from "@welshman/signer"
|
import {makeSecret} from "@welshman/signer"
|
||||||
import {preventDefault, downloadText} from "@lib/html"
|
import {preventDefault, downloadText} from "@lib/html"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Profile} from "@welshman/util"
|
import type {Profile} from "@welshman/util"
|
||||||
import {PROFILE, createProfile, makeProfile, createEvent} from "@welshman/util"
|
import {PROFILE, createProfile, makeProfile, makeEvent} from "@welshman/util"
|
||||||
import {loginWithNip01, publishThunk} from "@welshman/app"
|
import {loginWithNip01, publishThunk} from "@welshman/app"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
|
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
|
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
|
||||||
const event = createEvent(PROFILE, createProfile(profile))
|
const event = makeEvent(PROFILE, createProfile(profile))
|
||||||
const relays = shouldBroadcast ? INDEXER_RELAYS : []
|
const relays = shouldBroadcast ? INDEXER_RELAYS : []
|
||||||
|
|
||||||
loginWithNip01(secret)
|
loginWithNip01(secret)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {displayUrl} from "@welshman/lib"
|
||||||
|
import {Pool, AuthStatus} from "@welshman/net"
|
||||||
|
import {preventDefault} from "@lib/html"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import SpaceJoinConfirm, {confirmSpaceJoin} from "@app/components/SpaceJoinConfirm.svelte"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {attemptRelayAccess} from "@app/commands"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url}: Props = $props()
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const joinRelay = async () => {
|
||||||
|
const error = await attemptRelayAccess(url, claim)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return pushToast({theme: "error", message: error, timeout: 30_000})
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = Pool.get().get(url)
|
||||||
|
|
||||||
|
if (socket.auth.status === AuthStatus.None) {
|
||||||
|
pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
|
||||||
|
} else {
|
||||||
|
await confirmSpaceJoin(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const join = async () => {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await joinRelay()
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let claim = $state("")
|
||||||
|
let loading = $state(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="column gap-4" onsubmit={preventDefault(join)}>
|
||||||
|
<ModalHeader>
|
||||||
|
{#snippet title()}
|
||||||
|
<div>Request Access</div>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
<div>Enter an invite code below to request access to {displayUrl(url)}.</div>
|
||||||
|
{/snippet}
|
||||||
|
</ModalHeader>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Invite code*</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<Icon icon="link-round" />
|
||||||
|
<input bind:value={claim} 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={loading}>
|
||||||
|
<Spinner {loading}>Join Space</Spinner>
|
||||||
|
<Icon icon="alt-arrow-right" />
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
@@ -1,49 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
import {parse, renderAsHtml} from "@welshman/content"
|
import {parse, renderAsHtml} from "@welshman/content"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
|
||||||
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 {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import {ucFirst} from "@lib/util"
|
import {ucFirst} from "@lib/util"
|
||||||
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 {pushToast} from "@app/toast"
|
import SpaceAccessRequest from "@app/components/SpaceAccessRequest.svelte"
|
||||||
import {clearModals} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {attemptRelayAccess} from "@app/commands"
|
|
||||||
|
|
||||||
const {url, error} = $props()
|
const {url, error} = $props()
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const joinRelay = async () => {
|
const requestAccess = () => pushModal(SpaceAccessRequest, {url})
|
||||||
const error = await attemptRelayAccess(url)
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return pushToast({theme: "error", message: error})
|
|
||||||
}
|
|
||||||
|
|
||||||
pushToast({
|
|
||||||
message: "You have successfully joined the space!",
|
|
||||||
})
|
|
||||||
|
|
||||||
clearModals()
|
|
||||||
}
|
|
||||||
|
|
||||||
const join = async () => {
|
|
||||||
loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await joinRelay()
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let loading = $state(false)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="column gap-4" onsubmit={preventDefault(join)}>
|
<form class="column gap-4" onsubmit={preventDefault(requestAccess)}>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
{#snippet title()}
|
{#snippet title()}
|
||||||
<div>Access Error</div>
|
<div>Access Error</div>
|
||||||
@@ -63,8 +37,8 @@
|
|||||||
<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}>
|
<Button type="submit" class="btn btn-primary">
|
||||||
<Spinner {loading}>Request Access</Spinner>
|
Request Access
|
||||||
<Icon icon="alt-arrow-right" />
|
<Icon icon="alt-arrow-right" />
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|||||||
@@ -28,13 +28,19 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<p>
|
<p>
|
||||||
<Link class="text-primary" external href="https://relay.tools">relay.tools</Link> is a third-party
|
<Link class="link" external href="https://relay.tools">relay.tools</Link> is a third-party service
|
||||||
service that allows anyone to run their own relay for use with {PLATFORM_NAME}, or any other
|
that allows anyone to run their own relay for use with {PLATFORM_NAME}, or any other
|
||||||
nostr-compatible app.
|
nostr-compatible app.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Once you've created a relay of your own, come back here to link {PLATFORM_NAME} with your new relay.
|
Once you've created a relay of your own, come back here to link {PLATFORM_NAME} with your new relay.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Alternatively, you can
|
||||||
|
<Link external class="link" href="https://github.com/coracle-social/frith"
|
||||||
|
>run your own community relay</Link
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon="alt-arrow-left" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {tryCatch} from "@welshman/lib"
|
import {tryCatch, first, removeNil} from "@welshman/lib"
|
||||||
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
||||||
import {Pool, AuthStatus} from "@welshman/net"
|
import {Pool, AuthStatus} from "@welshman/net"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
@@ -17,21 +17,36 @@
|
|||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const joinRelay = async (invite: string) => {
|
const joinRelay = async () => {
|
||||||
const [raw, claim] = invite.split("|")
|
const promises: Promise<string | undefined>[] = []
|
||||||
const url = normalizeRelayUrl(raw)
|
|
||||||
const error = await attemptRelayAccess(url, claim)
|
const [rawUrl, rawClaim] = url.split("|")
|
||||||
|
const normalizedUrl = normalizeRelayUrl(rawUrl)
|
||||||
|
|
||||||
|
if (claim) {
|
||||||
|
promises.push(attemptRelayAccess(normalizedUrl, claim))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawClaim) {
|
||||||
|
promises.push(attemptRelayAccess(normalizedUrl, rawClaim))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promises.length === 0) {
|
||||||
|
promises.push(attemptRelayAccess(normalizedUrl, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = first(removeNil(await Promise.all(promises)))
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return pushToast({theme: "error", message: error, timeout: 30_000})
|
return pushToast({theme: "error", message: error, timeout: 30_000})
|
||||||
}
|
}
|
||||||
|
|
||||||
const socket = Pool.get().get(url)
|
const socket = Pool.get().get(normalizedUrl)
|
||||||
|
|
||||||
if (socket.auth.status === AuthStatus.None) {
|
if (socket.auth.status === AuthStatus.None) {
|
||||||
pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
|
pushModal(SpaceJoinConfirm, {url: normalizedUrl}, {replaceState: true})
|
||||||
} else {
|
} else {
|
||||||
await confirmSpaceJoin(url)
|
await confirmSpaceJoin(normalizedUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,13 +54,14 @@
|
|||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await joinRelay(url)
|
await joinRelay()
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = $state("")
|
let url = $state("")
|
||||||
|
let claim = $state("")
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
|
|
||||||
const linkIsValid = $derived(
|
const linkIsValid = $derived(
|
||||||
@@ -59,12 +75,12 @@
|
|||||||
<div>Join a Space</div>
|
<div>Join a Space</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#snippet info()}
|
||||||
<div>Enter an invite code below to join an existing space.</div>
|
<div>Enter a relay URL below to join an existing space.</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<Field>
|
<Field>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
<p>Invite code*</p>
|
<p>Relay URL*</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
@@ -74,11 +90,25 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#snippet info()}
|
||||||
<p>
|
<p>
|
||||||
You can also directly join any relay by entering its URL here.
|
Enter the URL of the relay that hosts the space you'd like to join.
|
||||||
<Button class="link" onclick={() => pushModal(InfoRelay)}>What is a relay?</Button>
|
<Button class="link" onclick={() => pushModal(InfoRelay)}>What is a relay?</Button>
|
||||||
</p>
|
</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Invite Code (optional)</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<Icon icon="ticket" />
|
||||||
|
<input bind:value={claim} class="grow" type="text" />
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
<p>If you have an invite code, enter it here to get access.</p>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-link" onclick={back}>
|
<Button class="btn btn-link" onclick={back}>
|
||||||
<Icon icon="alt-arrow-left" />
|
<Icon icon="alt-arrow-left" />
|
||||||
|
|||||||
@@ -7,7 +7,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 {clearModals} from "@app/modal"
|
import {clearModals} from "@app/modal"
|
||||||
import {addSpaceMembership} from "@app/commands"
|
import {addSpaceMembership, broadcastUserData} from "@app/commands"
|
||||||
|
|
||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
const tryJoin = async () => {
|
const tryJoin = async () => {
|
||||||
await addSpaceMembership(url)
|
await addSpaceMembership(url)
|
||||||
|
|
||||||
|
broadcastUserData([url])
|
||||||
clearModals()
|
clearModals()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
<script module lang="ts">
|
<script module lang="ts">
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
|
import {ROOM_META} from "@welshman/util"
|
||||||
|
import {load} from "@welshman/net"
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
import {addSpaceMembership} from "@app/commands"
|
import {addSpaceMembership, broadcastUserData} from "@app/commands"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
export const confirmSpaceJoin = async (url: string) => {
|
export const confirmSpaceJoin = async (url: string) => {
|
||||||
await addSpaceMembership(url)
|
await addSpaceMembership(url)
|
||||||
|
|
||||||
goto(makeSpacePath(url), {replaceState: true})
|
const path = makeSpacePath(url)
|
||||||
|
|
||||||
|
if (window.location.pathname === path) {
|
||||||
|
load({
|
||||||
|
relays: [url],
|
||||||
|
filters: [{kinds: [ROOM_META]}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastUserData([url])
|
||||||
|
|
||||||
|
goto(path, {replaceState: true})
|
||||||
|
|
||||||
pushToast({
|
pushToast({
|
||||||
message: "Welcome to the space!",
|
message: "Welcome to the space!",
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {deriveRelay} from "@welshman/app"
|
||||||
|
import {fade} from "@lib/transition"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||||
|
import ChannelName from "@app/components/ChannelName.svelte"
|
||||||
|
import {makeThreadPath, makeCalendarPath, makeRoomPath, makeSpacePath} from "@app/routes"
|
||||||
|
import {
|
||||||
|
hasNip29,
|
||||||
|
deriveUserRooms,
|
||||||
|
deriveOtherRooms,
|
||||||
|
makeChannelId,
|
||||||
|
channelsById,
|
||||||
|
} from "@app/state"
|
||||||
|
import {notifications} from "@app/notifications"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url}: Props = $props()
|
||||||
|
const relay = deriveRelay(url)
|
||||||
|
const userRooms = deriveUserRooms(url)
|
||||||
|
const otherRooms = deriveOtherRooms(url)
|
||||||
|
const chatPath = makeSpacePath(url, "chat")
|
||||||
|
const threadsPath = makeThreadPath(url)
|
||||||
|
const calendarPath = makeCalendarPath(url)
|
||||||
|
|
||||||
|
const addRoom = () => pushModal(RoomCreate, {url})
|
||||||
|
|
||||||
|
const filteredRooms = $derived(() => {
|
||||||
|
if (!term) return [...$userRooms, ...$otherRooms]
|
||||||
|
|
||||||
|
const query = term.toLowerCase()
|
||||||
|
const allRooms = [...$userRooms, ...$otherRooms]
|
||||||
|
|
||||||
|
return allRooms.filter(room => {
|
||||||
|
const channel = $channelsById.get(makeChannelId(url, room))
|
||||||
|
const roomName = channel?.name || room
|
||||||
|
return roomName.toLowerCase().includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let term = $state("")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card2 bg-alt md:hidden">
|
||||||
|
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Icon icon="compass-big" />
|
||||||
|
Quick Links
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Link href={threadsPath} class="btn btn-primary w-full justify-start">
|
||||||
|
<div class="relative flex items-center gap-2">
|
||||||
|
<Icon icon="notes-minimalistic" />
|
||||||
|
Threads
|
||||||
|
{#if $notifications.has(threadsPath)}
|
||||||
|
<div
|
||||||
|
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary-content"
|
||||||
|
transition:fade>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link href={calendarPath} class="btn btn-secondary w-full justify-start">
|
||||||
|
<div class="relative flex items-center gap-2">
|
||||||
|
<Icon icon="calendar-minimalistic" />
|
||||||
|
Calendar
|
||||||
|
{#if $notifications.has(calendarPath)}
|
||||||
|
<div
|
||||||
|
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary-content"
|
||||||
|
transition:fade>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{#if hasNip29($relay)}
|
||||||
|
{#if $userRooms.length + $otherRooms.length > 10}
|
||||||
|
<label class="input input-sm input-bordered flex flex-grow items-center gap-2">
|
||||||
|
<Icon icon="magnifer" size={4} />
|
||||||
|
<input bind:value={term} class="grow" type="text" placeholder="Search rooms..." />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#each filteredRooms() as room (room)}
|
||||||
|
{@const roomPath = makeRoomPath(url, room)}
|
||||||
|
{@const channel = $channelsById.get(makeChannelId(url, room))}
|
||||||
|
<Link href={roomPath} class="btn btn-neutral btn-sm relative w-full justify-start">
|
||||||
|
<div class="flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
|
||||||
|
{#if channel?.closed || channel?.private}
|
||||||
|
<Icon icon="lock" size={4} />
|
||||||
|
{:else}
|
||||||
|
<Icon icon="hashtag" />
|
||||||
|
{/if}
|
||||||
|
<ChannelName {url} {room} />
|
||||||
|
</div>
|
||||||
|
{#if $notifications.has(roomPath)}
|
||||||
|
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary" transition:fade>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Link>
|
||||||
|
{/each}
|
||||||
|
<Button onclick={addRoom} class="btn btn-neutral btn-sm w-full justify-start">
|
||||||
|
<Icon icon="add-circle" />
|
||||||
|
Create Room
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Link href={chatPath} class="btn btn-neutral w-full justify-start">
|
||||||
|
<div class="relative flex items-center gap-2">
|
||||||
|
<Icon icon="chat-round" />
|
||||||
|
Chat
|
||||||
|
{#if $notifications.has(chatPath)}
|
||||||
|
<div class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary" transition:fade>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {derived} from "svelte/store"
|
||||||
|
import {groupBy, ago, MONTH, first, last, uniq, avg, overlappingPairs} from "@welshman/lib"
|
||||||
|
import {MESSAGE, getTagValue} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ConversationCard from "@app/components/ConversationCard.svelte"
|
||||||
|
import {deriveEventsForUrl} from "@app/state"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url}: Props = $props()
|
||||||
|
const since = ago(MONTH)
|
||||||
|
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}])
|
||||||
|
|
||||||
|
const conversations = derived(messages, $messages => {
|
||||||
|
const convs = []
|
||||||
|
|
||||||
|
for (const [room, messages] of groupBy(e => getTagValue("h", e.tags), $messages).entries()) {
|
||||||
|
const avgTime = avg(overlappingPairs(messages).map(([a, b]) => a.created_at - b.created_at))
|
||||||
|
const groups: TrustedEvent[][] = []
|
||||||
|
const group: TrustedEvent[] = []
|
||||||
|
|
||||||
|
// Group conversations by time between messages
|
||||||
|
let prevCreatedAt = messages[0].created_at
|
||||||
|
for (const message of messages) {
|
||||||
|
if (prevCreatedAt - message.created_at < avgTime) {
|
||||||
|
group.push(message)
|
||||||
|
} else {
|
||||||
|
groups.push(group.splice(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
prevCreatedAt = message.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.length > 0) {
|
||||||
|
groups.push(group.splice(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert each group into a conversation
|
||||||
|
for (const events of groups) {
|
||||||
|
if (events.length < 2) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = first(events)!
|
||||||
|
const earliest = last(events)!
|
||||||
|
const participants = uniq(events.map(msg => msg.pubkey))
|
||||||
|
|
||||||
|
convs.push({room, events, latest, earliest, participants})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return convs
|
||||||
|
})
|
||||||
|
|
||||||
|
const viewMore = () => {
|
||||||
|
limit += 3
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = $state(3)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card2 bg-alt">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Icon icon="chat-round" />
|
||||||
|
Recent Conversations
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#if $conversations.length === 0}
|
||||||
|
{#if $messages.length > 0}
|
||||||
|
{@const events = $messages.slice(0, 1)}
|
||||||
|
{@const event = events[0]}
|
||||||
|
{@const room = getTagValue("h", event.tags)}
|
||||||
|
<ConversationCard
|
||||||
|
{url}
|
||||||
|
{room}
|
||||||
|
{events}
|
||||||
|
latest={event}
|
||||||
|
earliest={event}
|
||||||
|
participants={[event.pubkey]} />
|
||||||
|
{:else}
|
||||||
|
<div class="py-8 text-center opacity-70">
|
||||||
|
<p>No recent conversations</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#each $conversations.slice(0, limit) as { room, events, latest, earliest, participants } (latest.id)}
|
||||||
|
<ConversationCard {url} {room} {events} {latest} {earliest} {participants} />
|
||||||
|
{/each}
|
||||||
|
{#if $conversations.length > limit}
|
||||||
|
<Button class="btn btn-primary" onclick={viewMore}>
|
||||||
|
View more conversations
|
||||||
|
<Icon icon="alt-arrow-down" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {deriveRelay} from "@welshman/app"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import SocketStatusIndicator from "@lib/components/SocketStatusIndicator.svelte"
|
||||||
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url}: Props = $props()
|
||||||
|
|
||||||
|
const relay = deriveRelay(url)
|
||||||
|
const owner = $derived($relay?.profile?.pubkey)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card2 bg-alt flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Icon icon="server" />
|
||||||
|
Relay Details
|
||||||
|
</h3>
|
||||||
|
<SocketStatusIndicator {url} />
|
||||||
|
</div>
|
||||||
|
{#if $relay?.profile}
|
||||||
|
{@const {software, version, supported_nips, limitation} = $relay.profile}
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#if owner}
|
||||||
|
<div class="badge badge-neutral">
|
||||||
|
<span class="ellipsize">Administrator: <ProfileLink unstyled pubkey={owner} /></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if $relay?.profile?.contact}
|
||||||
|
<div class="badge badge-neutral">
|
||||||
|
<span class="ellipsize">Contact: {$relay.profile.contact}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if software}
|
||||||
|
<div class="badge badge-neutral">
|
||||||
|
<span class="ellipsize">Software: {software}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if version}
|
||||||
|
<div class="badge badge-neutral">
|
||||||
|
<span class="ellipsize">Version: {version}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if Array.isArray(supported_nips)}
|
||||||
|
<p class="badge badge-neutral">
|
||||||
|
<span class="ellipsize">Supported NIPs: {supported_nips.join(", ")}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if limitation?.auth_required}
|
||||||
|
<p class="badge badge-warning">
|
||||||
|
<span class="ellipsize">Auth Required</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if limitation?.payment_required}
|
||||||
|
<p class="badge badge-warning">
|
||||||
|
<span class="ellipsize">Payment Required</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if limitation?.min_pow_difficulty}
|
||||||
|
<p class="badge badge-warning">
|
||||||
|
<span class="ellipsize">Min PoW: {limitation?.min_pow_difficulty}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import {createEvent, THREAD} from "@welshman/util"
|
import {makeEvent, THREAD} from "@welshman/util"
|
||||||
import {publishThunk} from "@welshman/app"
|
import {publishThunk} from "@welshman/app"
|
||||||
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"
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
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 {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {GENERAL, tagRoom, PROTECTED} from "@app/state"
|
import {PROTECTED} from "@app/state"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
|
||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
@@ -41,16 +41,11 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = [
|
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title], PROTECTED]
|
||||||
...ed.storage.nostr.getEditorTags(),
|
|
||||||
tagRoom(GENERAL, url),
|
|
||||||
["title", title],
|
|
||||||
PROTECTED,
|
|
||||||
]
|
|
||||||
|
|
||||||
publishThunk({
|
publishThunk({
|
||||||
relays: [url],
|
relays: [url],
|
||||||
event: createEvent(THREAD, {content, tags}),
|
event: makeEvent(THREAD, {content, tags}),
|
||||||
})
|
})
|
||||||
|
|
||||||
history.back()
|
history.back()
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {debounce} from "throttle-debounce"
|
||||||
|
import {nwc} from "@getalby/sdk"
|
||||||
|
import {sleep} from "@welshman/lib"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Scanner from "@lib/components/Scanner.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import type {NWCInfo} from "@app/state"
|
||||||
|
import {wallet, getWebLn} from "@app/state"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const connectWithWebLn = async () => {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([sleep(800), getWebLn().enable()])
|
||||||
|
const info = await getWebLn().getInfo()
|
||||||
|
|
||||||
|
if (!info?.supports?.includes("lightning")) {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Your extension does not support lightning payments",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
wallet.set({type: "webln", info})
|
||||||
|
pushToast({message: "Wallet successfully connected!"})
|
||||||
|
|
||||||
|
await sleep(400)
|
||||||
|
|
||||||
|
back()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Wallet failed to connect",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectWithNWC = async () => {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new nwc.NWCClient({nostrWalletConnectUrl})
|
||||||
|
const [_, info] = await Promise.all([sleep(800), client.getInfo()])
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Wallet failed to connect",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
wallet.set({type: "nwc", info: client.options as unknown as NWCInfo})
|
||||||
|
pushToast({message: "Wallet successfully connected!"})
|
||||||
|
|
||||||
|
await sleep(400)
|
||||||
|
|
||||||
|
back()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: "Wallet failed to connect",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleScanner = () => {
|
||||||
|
showScanner = !showScanner
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScan = debounce(1000, async (data: string) => {
|
||||||
|
showScanner = false
|
||||||
|
nostrWalletConnectUrl = data
|
||||||
|
await connectWithNWC()
|
||||||
|
})
|
||||||
|
|
||||||
|
let nostrWalletConnectUrl = $state("")
|
||||||
|
let showScanner = $state(false)
|
||||||
|
let loading = $state(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="column gap-4">
|
||||||
|
<ModalHeader>
|
||||||
|
{#snippet title()}
|
||||||
|
<div>Connect a Wallet</div>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
Use Nostr Wallet Connect to send Bitcoin payments over Lightning.
|
||||||
|
{/snippet}
|
||||||
|
</ModalHeader>
|
||||||
|
{#if getWebLn()}
|
||||||
|
<Button
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={Boolean(nostrWalletConnectUrl || loading)}
|
||||||
|
onclick={connectWithWebLn}>
|
||||||
|
<Spinner loading={!nostrWalletConnectUrl && loading}>
|
||||||
|
{#if !nostrWalletConnectUrl && loading}
|
||||||
|
Connecting...
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon="cpu" />
|
||||||
|
Connect with WebLN
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Spinner>
|
||||||
|
</Button>
|
||||||
|
<Divider>Or</Divider>
|
||||||
|
{/if}
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
Connection Secret*
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<Icon icon="lock" />
|
||||||
|
<input
|
||||||
|
bind:value={nostrWalletConnectUrl}
|
||||||
|
autocomplete="off"
|
||||||
|
name="flotilla-nwc"
|
||||||
|
class="grow"
|
||||||
|
type="password" />
|
||||||
|
<Button onclick={toggleScanner}>
|
||||||
|
<Icon icon="qr-code" />
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
You can find this in any wallet that supports
|
||||||
|
<Link external href="https://nwc.getalby.com/about" class="text-primary"
|
||||||
|
>Nostr Wallet Connect</Link
|
||||||
|
>.
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
{#if showScanner}
|
||||||
|
<Scanner onscan={onScan} />
|
||||||
|
{/if}
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon="alt-arrow-left" />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={!nostrWalletConnectUrl || loading}
|
||||||
|
onclick={connectWithNWC}>
|
||||||
|
<Spinner loading={Boolean(nostrWalletConnectUrl && loading)}>
|
||||||
|
{#if nostrWalletConnectUrl && loading}
|
||||||
|
Connecting...
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
Connect Wallet
|
||||||
|
<Icon icon="alt-arrow-right" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Spinner>
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
|
import {wallet} from "@app/state"
|
||||||
|
import {clearModals} from "@app/modal"
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
wallet.set(undefined)
|
||||||
|
|
||||||
|
clearModals()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Confirm
|
||||||
|
{confirm}
|
||||||
|
title="Disconnect Wallet"
|
||||||
|
message="Are you sure you want to disconnect your bitcoin wallet?" />
|
||||||
@@ -15,19 +15,21 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {clamp} from "@welshman/lib"
|
import {clamp} from "@welshman/lib"
|
||||||
|
import {pubkey, getFollows, deriveUserWotScore} from "@welshman/app"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
score: any
|
pubkey: string
|
||||||
max?: number
|
|
||||||
active?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {score, max = 100, active = false}: Props = $props()
|
const {pubkey: target}: Props = $props()
|
||||||
|
|
||||||
|
const max = 100
|
||||||
const radius = 6
|
const radius = 6
|
||||||
const center = radius + 1
|
const center = radius + 1
|
||||||
|
|
||||||
const normalizedScore = $derived(clamp([0, max], score) / max)
|
const score = deriveUserWotScore(target)
|
||||||
|
const active = $derived(getFollows($pubkey!).includes(target))
|
||||||
|
const normalizedScore = $derived(clamp([0, max], $score) / max)
|
||||||
const dashOffset = $derived(100 - 44 * normalizedScore)
|
const dashOffset = $derived(100 - 44 * normalizedScore)
|
||||||
const style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`)
|
const style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`)
|
||||||
const stroke = $derived(active ? "var(--primary)" : "var(--base-content)")
|
const stroke = $derived(active ? "var(--primary)" : "var(--base-content)")
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
|
import {signer, deriveZapperForPubkey} from "@welshman/app"
|
||||||
|
import {load} from "@welshman/net"
|
||||||
|
import {Router} from "@welshman/router"
|
||||||
|
import {requestZap, makeZapRequest, getZapResponseFilter} from "@welshman/util"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||||
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
import {payInvoice} from "@app/commands"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
pubkey: string
|
||||||
|
eventId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, pubkey, eventId}: Props = $props()
|
||||||
|
|
||||||
|
const minPos = 1
|
||||||
|
const maxPos = 1000
|
||||||
|
const minVal = 21
|
||||||
|
const maxVal = 1000000
|
||||||
|
const zapperStore = deriveZapperForPubkey(pubkey)
|
||||||
|
|
||||||
|
const posToAmount = (pos: number) => {
|
||||||
|
const normalizedPos = (pos - minPos) / (maxPos - minPos)
|
||||||
|
const logMin = Math.log(minVal)
|
||||||
|
const logMax = Math.log(maxVal)
|
||||||
|
const logValue = logMin + normalizedPos * (logMax - logMin)
|
||||||
|
return Math.round(Math.exp(logValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountToPos = (amount: number) => {
|
||||||
|
const clampedAmount = Math.max(minVal, Math.min(maxVal, amount))
|
||||||
|
const logMin = Math.log(minVal)
|
||||||
|
const logMax = Math.log(maxVal)
|
||||||
|
const logValue = Math.log(clampedAmount)
|
||||||
|
const normalizedPos = (logValue - logMin) / (logMax - logMin)
|
||||||
|
return Math.round(minPos + normalizedPos * (maxPos - minPos))
|
||||||
|
}
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const onEmoji = (emoji: NativeEmoji) => {
|
||||||
|
content = emoji.unicode
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendZap = async () => {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const zapper = $zapperStore!
|
||||||
|
const msats = amount * 1000
|
||||||
|
const relays = url ? [url] : Router.get().ForPubkey(pubkey).getUrls()
|
||||||
|
const filters = [getZapResponseFilter({zapper, pubkey, eventId})]
|
||||||
|
const params = {pubkey, content, eventId, msats, relays, zapper}
|
||||||
|
const event = await $signer!.sign(makeZapRequest(params))
|
||||||
|
const res = await requestZap({zapper, event})
|
||||||
|
|
||||||
|
if (!res.invoice) {
|
||||||
|
return pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: `Failed to zap: ${res.error || "no error given"}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await payInvoice(res.invoice)
|
||||||
|
await load({relays, filters})
|
||||||
|
|
||||||
|
pushToast({message: "Zap successfully sent!"})
|
||||||
|
back()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
|
||||||
|
const message = String(e).replace(/^.*Error: /, "")
|
||||||
|
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: `Failed to zap: ${message}`,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pos = $state(minPos)
|
||||||
|
let amount = $state(minVal)
|
||||||
|
let content = $state("⚡️")
|
||||||
|
let loading = $state(false)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
amount = posToAmount(pos)
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const newPos = amountToPos(amount)
|
||||||
|
if (newPos !== pos) {
|
||||||
|
pos = newPos
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="column gap-4">
|
||||||
|
<ModalHeader>
|
||||||
|
{#snippet title()}
|
||||||
|
<div>Send a Zap</div>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
<div>To <ProfileLink {pubkey} class="!text-primary" /></div>
|
||||||
|
{/snippet}
|
||||||
|
</ModalHeader>
|
||||||
|
<FieldInline class="!grid-cols-3">
|
||||||
|
{#snippet label()}
|
||||||
|
Emoji Reaction
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<div class="flex flex-grow items-center justify-end gap-4">
|
||||||
|
<EmojiButton {onEmoji} class="btn btn-neutral">
|
||||||
|
{content}
|
||||||
|
</EmojiButton>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline class="!grid-cols-3">
|
||||||
|
{#snippet label()}
|
||||||
|
Amount
|
||||||
|
{/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-24" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<input
|
||||||
|
class="range range-primary -mt-2"
|
||||||
|
type="range"
|
||||||
|
min={minPos}
|
||||||
|
max={maxPos}
|
||||||
|
bind:value={pos} />
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon="alt-arrow-left" />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button class="btn btn-primary" onclick={sendZap} disabled={loading}>
|
||||||
|
<Spinner {loading}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if !loading}
|
||||||
|
<Icon icon="bolt" />
|
||||||
|
{/if}
|
||||||
|
Send Zap
|
||||||
|
</div>
|
||||||
|
</Spinner>
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {deriveZapperForPubkey} from "@welshman/app"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Zap from "@app/components/Zap.svelte"
|
||||||
|
import InfoZapperError from "@app/components/InfoZapperError.svelte"
|
||||||
|
import WalletConnect from "@app/components/WalletConnect.svelte"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {wallet} from "@app/state"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
children: Snippet
|
||||||
|
replaceState?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, children, replaceState, ...props}: Props = $props()
|
||||||
|
|
||||||
|
const zapper = deriveZapperForPubkey(event.pubkey)
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
if (!$zapper?.allowsNostr) {
|
||||||
|
pushModal(InfoZapperError, {url, pubkey: event.pubkey, eventId: event.id}, {replaceState})
|
||||||
|
} else if ($wallet) {
|
||||||
|
pushModal(Zap, {url, pubkey: event.pubkey, eventId: event.id}, {replaceState})
|
||||||
|
} else {
|
||||||
|
pushModal(WalletConnect, {}, {replaceState})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button onclick={onClick} {...props}>
|
||||||
|
{@render children?.()}
|
||||||
|
</Button>
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {removeNil} from "@welshman/lib"
|
import {removeNil} from "@welshman/lib"
|
||||||
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
|
import {displayPubkey} from "@welshman/util"
|
||||||
import {
|
import {deriveHandleForPubkey, displayHandle, deriveProfileDisplay} from "@welshman/app"
|
||||||
userFollows,
|
import WotScore from "@app/components/WotScore.svelte"
|
||||||
deriveUserWotScore,
|
|
||||||
deriveHandleForPubkey,
|
|
||||||
displayHandle,
|
|
||||||
deriveProfileDisplay,
|
|
||||||
} from "@welshman/app"
|
|
||||||
import WotScore from "@lib/components/WotScore.svelte"
|
|
||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -21,9 +15,6 @@
|
|||||||
const pubkey = value
|
const pubkey = value
|
||||||
const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
|
const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
|
||||||
const handle = deriveHandleForPubkey(pubkey)
|
const handle = deriveHandleForPubkey(pubkey)
|
||||||
const score = deriveUserWotScore(pubkey)
|
|
||||||
|
|
||||||
const following = $derived(getPubkeyTagValues(getListTags($userFollows)).includes(pubkey))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex max-w-full gap-3">
|
<div class="flex max-w-full gap-3">
|
||||||
@@ -35,7 +26,7 @@
|
|||||||
<div class="text-bold overflow-hidden text-ellipsis text-base">
|
<div class="text-bold overflow-hidden text-ellipsis text-base">
|
||||||
{$profileDisplay}
|
{$profileDisplay}
|
||||||
</div>
|
</div>
|
||||||
<WotScore score={$score} active={following} />
|
<WotScore {pubkey} />
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
|
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
|
||||||
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
|
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
|
||||||
|
|||||||
+88
-68
@@ -1,71 +1,33 @@
|
|||||||
import {mount} from "svelte"
|
import {mount} from "svelte"
|
||||||
import type {Writable} from "svelte/store"
|
import type {Writable} from "svelte/store"
|
||||||
import {get} from "svelte/store"
|
import {get} from "svelte/store"
|
||||||
import type {StampedEvent} from "@welshman/util"
|
import {sha256} from "@welshman/lib"
|
||||||
import {makeEvent, getTagValues, getListTags, BLOSSOM_AUTH} from "@welshman/util"
|
import {
|
||||||
import {simpleCache, normalizeUrl, removeNil, now} from "@welshman/lib"
|
getTagValues,
|
||||||
|
encryptFile,
|
||||||
|
uploadBlob,
|
||||||
|
makeBlossomAuthEvent,
|
||||||
|
getListTags,
|
||||||
|
} from "@welshman/util"
|
||||||
import {Router} from "@welshman/router"
|
import {Router} from "@welshman/router"
|
||||||
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
import {signer, profileSearch, userBlossomServers} from "@welshman/app"
|
import {signer, profileSearch, userBlossomServers} from "@welshman/app"
|
||||||
|
import type {FileAttributes} from "@welshman/editor"
|
||||||
import {Editor, MentionSuggestion, WelshmanExtension} from "@welshman/editor"
|
import {Editor, MentionSuggestion, WelshmanExtension} from "@welshman/editor"
|
||||||
import {makeMentionNodeView} from "./MentionNodeView"
|
import {makeMentionNodeView} from "./MentionNodeView"
|
||||||
import ProfileSuggestion from "./ProfileSuggestion.svelte"
|
import ProfileSuggestion from "./ProfileSuggestion.svelte"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
export const hasBlossomSupport = simpleCache(async ([url]: [string]) => {
|
export const getBlossomServer = () => {
|
||||||
const $signer = signer.get()
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"X-Content-Type": "text/plain",
|
|
||||||
"X-Content-Length": "1",
|
|
||||||
"X-SHA-256": "73cb3858a687a8494ca3323053016282f3dad39d42cf62ca4e79dda2aac7d9ac",
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($signer) {
|
|
||||||
const event = await signer.get().sign(
|
|
||||||
makeEvent(BLOSSOM_AUTH, {
|
|
||||||
tags: [
|
|
||||||
["t", "upload"],
|
|
||||||
["server", url],
|
|
||||||
["expiration", String(now() + 30)],
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
headers.Authorization = `Nostr ${btoa(JSON.stringify(event))}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(normalizeUrl(url) + "/upload", {method: "head", headers})
|
|
||||||
|
|
||||||
return res.status === 200
|
|
||||||
} catch (e) {
|
|
||||||
if (!String(e).includes("Failed to fetch")) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getUploadUrl = async (spaceUrl?: string) => {
|
|
||||||
const userUrls = getTagValues("server", getListTags(userBlossomServers.get()))
|
const userUrls = getTagValues("server", getListTags(userBlossomServers.get()))
|
||||||
const allUrls = removeNil([spaceUrl, ...userUrls])
|
|
||||||
|
|
||||||
for (let url of allUrls) {
|
for (const url of userUrls) {
|
||||||
url = url.replace(/^ws/, "http")
|
return url.replace(/^ws/, "http")
|
||||||
|
|
||||||
if (await hasBlossomSupport(url)) {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "https://cdn.satellite.earth"
|
return "https://cdn.satellite.earth"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const signWithAssert = async (template: StampedEvent) => {
|
|
||||||
const event = await signer.get().sign(template)
|
|
||||||
|
|
||||||
return event!
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeEditor = async ({
|
export const makeEditor = async ({
|
||||||
aggressive = false,
|
aggressive = false,
|
||||||
autofocus = false,
|
autofocus = false,
|
||||||
@@ -76,7 +38,6 @@ export const makeEditor = async ({
|
|||||||
submit,
|
submit,
|
||||||
uploading,
|
uploading,
|
||||||
wordCount,
|
wordCount,
|
||||||
disableFileUpload,
|
|
||||||
}: {
|
}: {
|
||||||
aggressive?: boolean
|
aggressive?: boolean
|
||||||
autofocus?: boolean
|
autofocus?: boolean
|
||||||
@@ -87,7 +48,6 @@ export const makeEditor = async ({
|
|||||||
submit: () => void
|
submit: () => void
|
||||||
uploading?: Writable<boolean>
|
uploading?: Writable<boolean>
|
||||||
wordCount?: Writable<number>
|
wordCount?: Writable<number>
|
||||||
disableFileUpload?: boolean
|
|
||||||
}) => {
|
}) => {
|
||||||
return new Editor({
|
return new Editor({
|
||||||
content,
|
content,
|
||||||
@@ -96,9 +56,6 @@ export const makeEditor = async ({
|
|||||||
extensions: [
|
extensions: [
|
||||||
WelshmanExtension.configure({
|
WelshmanExtension.configure({
|
||||||
submit,
|
submit,
|
||||||
sign: signWithAssert,
|
|
||||||
defaultUploadType: "blossom",
|
|
||||||
defaultUploadUrl: await getUploadUrl(url),
|
|
||||||
extensions: {
|
extensions: {
|
||||||
placeholder: {
|
placeholder: {
|
||||||
config: {
|
config: {
|
||||||
@@ -110,18 +67,81 @@ export const makeEditor = async ({
|
|||||||
aggressive,
|
aggressive,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fileUpload: disableFileUpload
|
fileUpload: {
|
||||||
? false
|
config: {
|
||||||
: {
|
upload: async (attrs: FileAttributes) => {
|
||||||
config: {
|
let file: Blob = attrs.file
|
||||||
onDrop() {
|
|
||||||
uploading?.set(true)
|
if (!file.type.match("image/(webp|gif)")) {
|
||||||
},
|
const {default: Compressor} = await import("compressorjs")
|
||||||
onComplete() {
|
|
||||||
uploading?.set(false)
|
file = await new Promise((resolve, _reject) => {
|
||||||
},
|
new Compressor(file, {
|
||||||
},
|
maxWidth: 1024,
|
||||||
|
maxHeight: 1024,
|
||||||
|
convertSize: 2 * 1024 * 1024,
|
||||||
|
success: resolve,
|
||||||
|
error: e => {
|
||||||
|
// Non-images break compressor
|
||||||
|
if (e.toString().includes("File or Blob")) {
|
||||||
|
return resolve(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
_reject(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const {ciphertext, key, nonce, algorithm} = await encryptFile(file)
|
||||||
|
const tags = [
|
||||||
|
["decryption-key", key],
|
||||||
|
["decryption-nonce", nonce],
|
||||||
|
["encryption-algorithm", algorithm],
|
||||||
|
]
|
||||||
|
|
||||||
|
file = new File([new Blob([ciphertext])], attrs.file.name, {type: attrs.file.type})
|
||||||
|
|
||||||
|
const server = getBlossomServer()
|
||||||
|
const hashes = [await sha256(await file.arrayBuffer())]
|
||||||
|
const $signer = signer.get() || Nip01Signer.ephemeral()
|
||||||
|
const authTemplate = makeBlossomAuthEvent({action: "upload", server, hashes})
|
||||||
|
const authEvent = await $signer.sign(authTemplate)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await uploadBlob(server, file, {authEvent})
|
||||||
|
let {uploaded, url, ...task} = await res.json()
|
||||||
|
|
||||||
|
if (!uploaded) {
|
||||||
|
return {error: "Server refused to process the file"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always append file extension if missing
|
||||||
|
if (new URL(url).pathname.split(".").length === 1) {
|
||||||
|
url += "." + attrs.file.type.split("/")[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {...task, tags, url}
|
||||||
|
|
||||||
|
return {result}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e)
|
||||||
|
return {error: e.toString()}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
onDrop() {
|
||||||
|
uploading?.set(true)
|
||||||
|
},
|
||||||
|
onComplete() {
|
||||||
|
uploading?.set(false)
|
||||||
|
},
|
||||||
|
onUploadError(currentEditor, task) {
|
||||||
|
currentEditor.commands.removeFailedUploads()
|
||||||
|
pushToast({theme: "error", message: "Failed to upload file"})
|
||||||
|
uploading?.set(false)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
nprofile: {
|
nprofile: {
|
||||||
extend: {
|
extend: {
|
||||||
addNodeView: () => makeMentionNodeView(url),
|
addNodeView: () => makeMentionNodeView(url),
|
||||||
|
|||||||
+32
-14
@@ -1,6 +1,6 @@
|
|||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
import {synced, throttled} from "@welshman/store"
|
import {synced, localStorageProvider, throttled} from "@welshman/store"
|
||||||
import {pubkey} from "@welshman/app"
|
import {pubkey, relaysByUrl} from "@welshman/app"
|
||||||
import {prop, spec, identity, now, groupBy} from "@welshman/lib"
|
import {prop, spec, identity, now, groupBy} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {EVENT_TIME, MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util"
|
import {EVENT_TIME, MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util"
|
||||||
@@ -9,13 +9,18 @@ import {
|
|||||||
makeChatPath,
|
makeChatPath,
|
||||||
makeThreadPath,
|
makeThreadPath,
|
||||||
makeCalendarPath,
|
makeCalendarPath,
|
||||||
|
makeSpaceChatPath,
|
||||||
makeRoomPath,
|
makeRoomPath,
|
||||||
} from "@app/routes"
|
} from "@app/routes"
|
||||||
import {chats, getUrlsForEvent, userRoomsByUrl, repositoryStore} from "@app/state"
|
import {chats, hasNip29, getUrlsForEvent, userRoomsByUrl, repositoryStore} from "@app/state"
|
||||||
|
|
||||||
// Checked state
|
// Checked state
|
||||||
|
|
||||||
export const checked = synced<Record<string, number>>("checked", {})
|
export const checked = synced<Record<string, number>>({
|
||||||
|
key: "checked",
|
||||||
|
defaultValue: {},
|
||||||
|
storage: localStorageProvider,
|
||||||
|
})
|
||||||
|
|
||||||
export const deriveChecked = (key: string) => derived(checked, prop(key))
|
export const deriveChecked = (key: string) => derived(checked, prop(key))
|
||||||
|
|
||||||
@@ -26,9 +31,12 @@ export const setChecked = (key: string) => checked.update(state => ({...state, [
|
|||||||
export const notifications = derived(
|
export const notifications = derived(
|
||||||
throttled(
|
throttled(
|
||||||
1000,
|
1000,
|
||||||
derived([pubkey, checked, chats, userRoomsByUrl, repositoryStore, getUrlsForEvent], identity),
|
derived(
|
||||||
|
[pubkey, checked, chats, userRoomsByUrl, repositoryStore, getUrlsForEvent, relaysByUrl],
|
||||||
|
identity,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
([$pubkey, $checked, $chats, $userRoomsByUrl, $repository, $getUrlsForEvent]) => {
|
([$pubkey, $checked, $chats, $userRoomsByUrl, $repository, $getUrlsForEvent, $relaysByUrl]) => {
|
||||||
const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => {
|
const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => {
|
||||||
if (!latestEvent || latestEvent.pubkey === $pubkey) {
|
if (!latestEvent || latestEvent.pubkey === $pubkey) {
|
||||||
return false
|
return false
|
||||||
@@ -75,8 +83,10 @@ export const notifications = derived(
|
|||||||
const spacePath = makeSpacePath(url)
|
const spacePath = makeSpacePath(url)
|
||||||
const threadPath = makeThreadPath(url)
|
const threadPath = makeThreadPath(url)
|
||||||
const calendarPath = makeCalendarPath(url)
|
const calendarPath = makeCalendarPath(url)
|
||||||
|
const messagesPath = makeSpaceChatPath(url)
|
||||||
const threadEvents = allThreadEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
|
const threadEvents = allThreadEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||||
const calendarEvents = allCalendarEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
|
const calendarEvents = allCalendarEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||||
|
const messagesEvents = allMessageEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||||
|
|
||||||
if (hasNotification(threadPath, threadEvents[0])) {
|
if (hasNotification(threadPath, threadEvents[0])) {
|
||||||
paths.add(spacePath)
|
paths.add(spacePath)
|
||||||
@@ -114,16 +124,24 @@ export const notifications = derived(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const room of rooms) {
|
if (hasNip29($relaysByUrl.get(url))) {
|
||||||
const roomPath = makeRoomPath(url, room)
|
for (const room of rooms) {
|
||||||
const latestEvent = allMessageEvents.find(
|
const roomPath = makeRoomPath(url, room)
|
||||||
e =>
|
const latestEvent = allMessageEvents.find(
|
||||||
$getUrlsForEvent(e.id).includes(url) && e.tags.find(t => t[0] === "h" && t[1] === room),
|
e =>
|
||||||
)
|
$getUrlsForEvent(e.id).includes(url) &&
|
||||||
|
e.tags.find(t => t[0] === "h" && t[1] === room),
|
||||||
|
)
|
||||||
|
|
||||||
if (hasNotification(roomPath, latestEvent)) {
|
if (hasNotification(roomPath, latestEvent)) {
|
||||||
|
paths.add(spacePath)
|
||||||
|
paths.add(roomPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (hasNotification(messagesPath, messagesEvents[0])) {
|
||||||
paths.add(spacePath)
|
paths.add(spacePath)
|
||||||
paths.add(roomPath)
|
paths.add(messagesPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
|
import {Capacitor} from "@capacitor/core"
|
||||||
|
import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications"
|
||||||
|
import {PushNotifications} from "@capacitor/push-notifications"
|
||||||
|
import {parseJson, poll} from "@welshman/lib"
|
||||||
|
import {isSignedEvent} from "@welshman/util"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
|
import {ucFirst} from "@lib/util"
|
||||||
|
import {VAPID_PUBLIC_KEY} from "@app/state"
|
||||||
|
|
||||||
|
export const platform = Capacitor.getPlatform()
|
||||||
|
|
||||||
|
export const platformName = platform === "ios" ? "iOS" : ucFirst(platform)
|
||||||
|
|
||||||
|
export const initializePushNotifications = () => {
|
||||||
|
if (platform === "web") return
|
||||||
|
|
||||||
|
PushNotifications.addListener("pushNotificationActionPerformed", (action: ActionPerformed) => {
|
||||||
|
const event = parseJson(action.notification.data.event)
|
||||||
|
const parsedRelays = parseJson(action.notification.data.relays)
|
||||||
|
const relays = Array.isArray(parsedRelays) ? parsedRelays : []
|
||||||
|
|
||||||
|
if (isSignedEvent(event)) {
|
||||||
|
goto("/" + nip19.neventEncode({id: event.id, relays}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const canSendPushNotifications = () => ["web", "android", "ios"].includes(platform)
|
||||||
|
|
||||||
|
export const getWebPushInfo = async () => {
|
||||||
|
if (!("serviceWorker" in navigator)) {
|
||||||
|
throw new Error("Service Worker not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("PushManager" in window)) {
|
||||||
|
throw new Error("Push notifications are not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === "denied") {
|
||||||
|
throw new Error("Push notifications are blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission !== "granted") {
|
||||||
|
const permission = await Notification.requestPermission()
|
||||||
|
|
||||||
|
if (permission !== "granted") {
|
||||||
|
throw new Error("Push notification permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
let subscription = await registration.pushManager.getSubscription()
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: VAPID_PUBLIC_KEY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const {keys} = subscription.toJSON()
|
||||||
|
|
||||||
|
if (!keys) {
|
||||||
|
throw new Error(`Failed to get push info: no keys were returned`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
p256dh: keys.p256dh,
|
||||||
|
auth: keys.auth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PushInfo = {
|
||||||
|
device_token: string
|
||||||
|
bundle_identifier?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCapacitorPushInfo = async () => {
|
||||||
|
let status = await PushNotifications.checkPermissions()
|
||||||
|
|
||||||
|
if (status.receive === "prompt") {
|
||||||
|
status = await PushNotifications.requestPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.receive !== "granted") {
|
||||||
|
throw new Error("Failed to register for push notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_token = ""
|
||||||
|
let error = "Failed to register for push notifications"
|
||||||
|
|
||||||
|
PushNotifications.addListener("registration", (token: Token) => {
|
||||||
|
device_token = token.value
|
||||||
|
})
|
||||||
|
|
||||||
|
PushNotifications.addListener("registrationError", (_error: RegistrationError) => {
|
||||||
|
error = _error.error
|
||||||
|
})
|
||||||
|
|
||||||
|
await PushNotifications.register()
|
||||||
|
await poll({
|
||||||
|
condition: () => Boolean(device_token),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!device_token) {
|
||||||
|
throw new Error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const info: PushInfo = {device_token}
|
||||||
|
|
||||||
|
if (platform === "ios") {
|
||||||
|
info.bundle_identifier = "social.flotilla"
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPushInfo = (): Promise<Record<string, string>> => {
|
||||||
|
switch (platform) {
|
||||||
|
case "web":
|
||||||
|
return getWebPushInfo()
|
||||||
|
case "ios":
|
||||||
|
case "android":
|
||||||
|
return getCapacitorPushInfo()
|
||||||
|
default:
|
||||||
|
throw new Error(`Invalid push platform: ${platform}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-4
@@ -13,13 +13,22 @@ import {
|
|||||||
sortBy,
|
sortBy,
|
||||||
assoc,
|
assoc,
|
||||||
now,
|
now,
|
||||||
|
isNotNil,
|
||||||
|
filterVals,
|
||||||
|
fromPairs,
|
||||||
} from "@welshman/lib"
|
} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
MESSAGE,
|
MESSAGE,
|
||||||
DELETE,
|
DELETE,
|
||||||
THREAD,
|
THREAD,
|
||||||
EVENT_TIME,
|
EVENT_TIME,
|
||||||
|
AUTH_INVITE,
|
||||||
COMMENT,
|
COMMENT,
|
||||||
|
ALERT_EMAIL,
|
||||||
|
ALERT_WEB,
|
||||||
|
ALERT_IOS,
|
||||||
|
ALERT_ANDROID,
|
||||||
|
ALERT_STATUS,
|
||||||
matchFilters,
|
matchFilters,
|
||||||
getTagValues,
|
getTagValues,
|
||||||
getTagValue,
|
getTagValue,
|
||||||
@@ -48,8 +57,6 @@ import {
|
|||||||
import {createScroller} from "@lib/html"
|
import {createScroller} from "@lib/html"
|
||||||
import {daysBetween} from "@lib/util"
|
import {daysBetween} from "@lib/util"
|
||||||
import {
|
import {
|
||||||
ALERT,
|
|
||||||
ALERT_STATUS,
|
|
||||||
NOTIFIER_RELAY,
|
NOTIFIER_RELAY,
|
||||||
INDEXER_RELAYS,
|
INDEXER_RELAYS,
|
||||||
getDefaultPubkeys,
|
getDefaultPubkeys,
|
||||||
@@ -343,7 +350,7 @@ export const makeCalendarFeed = ({
|
|||||||
export const loadAlerts = (pubkey: string) =>
|
export const loadAlerts = (pubkey: string) =>
|
||||||
load({
|
load({
|
||||||
relays: [NOTIFIER_RELAY],
|
relays: [NOTIFIER_RELAY],
|
||||||
filters: [{kinds: [ALERT], authors: [pubkey]}],
|
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID], authors: [pubkey]}],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadAlertStatuses = (pubkey: string) =>
|
export const loadAlertStatuses = (pubkey: string) =>
|
||||||
@@ -359,7 +366,7 @@ export const listenForNotifications = () => {
|
|||||||
|
|
||||||
for (const [url, allRooms] of userRoomsByUrl.get()) {
|
for (const [url, allRooms] of userRoomsByUrl.get()) {
|
||||||
// Limit how many rooms we load at a time, since we have to send a separate filter
|
// Limit how many rooms we load at a time, since we have to send a separate filter
|
||||||
// for each one due to nip 29 breaking postel's law
|
// for each one due to relay29 being picky
|
||||||
const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
|
const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
|
||||||
|
|
||||||
load({
|
load({
|
||||||
@@ -367,6 +374,7 @@ export const listenForNotifications = () => {
|
|||||||
relays: [url],
|
relays: [url],
|
||||||
filters: [
|
filters: [
|
||||||
{kinds: [THREAD], limit: 1},
|
{kinds: [THREAD], limit: 1},
|
||||||
|
{kinds: [MESSAGE], limit: 1},
|
||||||
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
|
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
|
||||||
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
|
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
|
||||||
],
|
],
|
||||||
@@ -377,6 +385,7 @@ export const listenForNotifications = () => {
|
|||||||
relays: [url],
|
relays: [url],
|
||||||
filters: [
|
filters: [
|
||||||
{kinds: [THREAD], since: now()},
|
{kinds: [THREAD], since: now()},
|
||||||
|
{kinds: [MESSAGE], since: now()},
|
||||||
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
|
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
|
||||||
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
|
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
|
||||||
],
|
],
|
||||||
@@ -430,3 +439,18 @@ export const discoverRelays = (lists: List[]) =>
|
|||||||
.filter(isShareableRelayUrl)
|
.filter(isShareableRelayUrl)
|
||||||
.map(url => loadRelay(url)),
|
.map(url => loadRelay(url)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const requestRelayClaim = async (url: string) => {
|
||||||
|
const filters = [{kinds: [AUTH_INVITE], limit: 1}]
|
||||||
|
const events = await load({filters, relays: [url]})
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
return getTagValue("claim", events[0].tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestRelayClaims = async (urls: string[]) =>
|
||||||
|
filterVals(
|
||||||
|
isNotNil,
|
||||||
|
fromPairs(await Promise.all(urls.map(async url => [url, await requestRelayClaim(url)]))),
|
||||||
|
)
|
||||||
|
|||||||
+85
-1
@@ -1,6 +1,21 @@
|
|||||||
import type {Page} from "@sveltejs/kit"
|
import type {Page} from "@sveltejs/kit"
|
||||||
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
|
import {nthEq, sleep} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {tracker} from "@welshman/app"
|
||||||
|
import {scrollToEvent} from "@lib/html"
|
||||||
import {identity} from "@welshman/lib"
|
import {identity} from "@welshman/lib"
|
||||||
import {makeChatId, decodeRelay, encodeRelay, userRoomsByUrl} from "@app/state"
|
import {
|
||||||
|
getTagValue,
|
||||||
|
DIRECT_MESSAGE,
|
||||||
|
DIRECT_MESSAGE_FILE,
|
||||||
|
MESSAGE,
|
||||||
|
THREAD,
|
||||||
|
ZAP_GOAL,
|
||||||
|
EVENT_TIME,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import {makeChatId, entityLink, decodeRelay, encodeRelay, userRoomsByUrl, ROOM} from "@app/state"
|
||||||
|
|
||||||
export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) => {
|
export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) => {
|
||||||
let path = `/spaces/${encodeRelay(url)}`
|
let path = `/spaces/${encodeRelay(url)}`
|
||||||
@@ -21,6 +36,10 @@ export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}
|
|||||||
|
|
||||||
export const makeRoomPath = (url: string, room: string) => `/spaces/${encodeRelay(url)}/${room}`
|
export const makeRoomPath = (url: string, room: string) => `/spaces/${encodeRelay(url)}/${room}`
|
||||||
|
|
||||||
|
export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
|
||||||
|
|
||||||
|
export const makeGoalPath = (url: string, eventId?: string) => makeSpacePath(url, "goals", eventId)
|
||||||
|
|
||||||
export const makeThreadPath = (url: string, eventId?: string) =>
|
export const makeThreadPath = (url: string, eventId?: string) =>
|
||||||
makeSpacePath(url, "threads", eventId)
|
makeSpacePath(url, "threads", eventId)
|
||||||
|
|
||||||
@@ -46,3 +65,68 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const goToEvent = async (event: TrustedEvent, options: Record<string, any> = {}) => {
|
||||||
|
if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) {
|
||||||
|
await scrollToEvent(event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = Array.from(tracker.getRelays(event.id))
|
||||||
|
const path = await getEventPath(event, urls)
|
||||||
|
|
||||||
|
if (path.includes("://")) {
|
||||||
|
window.open(path)
|
||||||
|
} else {
|
||||||
|
goto(path, options)
|
||||||
|
|
||||||
|
await sleep(300)
|
||||||
|
await scrollToEvent(event.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||||
|
const room = getTagValue(ROOM, event.tags)
|
||||||
|
|
||||||
|
if (urls.length > 0) {
|
||||||
|
const url = urls[0]
|
||||||
|
|
||||||
|
if (event.kind === ZAP_GOAL) {
|
||||||
|
return makeGoalPath(url, event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === THREAD) {
|
||||||
|
return makeThreadPath(url, event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === EVENT_TIME) {
|
||||||
|
return makeCalendarPath(url, event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === MESSAGE) {
|
||||||
|
return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = event.tags.find(nthEq(0, "K"))?.[1]
|
||||||
|
const id = event.tags.find(nthEq(0, "E"))?.[1]
|
||||||
|
|
||||||
|
if (id && kind) {
|
||||||
|
if (parseInt(kind) === ZAP_GOAL) {
|
||||||
|
return makeGoalPath(url, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseInt(kind) === THREAD) {
|
||||||
|
return makeThreadPath(url, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseInt(kind) === EVENT_TIME) {
|
||||||
|
return makeCalendarPath(url, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseInt(kind) === MESSAGE) {
|
||||||
|
return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
|
||||||
|
}
|
||||||
|
|||||||
+227
-135
@@ -1,8 +1,12 @@
|
|||||||
import twColors from "tailwindcss/colors"
|
import twColors from "tailwindcss/colors"
|
||||||
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {get, derived} from "svelte/store"
|
import {get, derived} from "svelte/store"
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {
|
import {
|
||||||
|
on,
|
||||||
|
call,
|
||||||
remove,
|
remove,
|
||||||
|
uniqBy,
|
||||||
sortBy,
|
sortBy,
|
||||||
sort,
|
sort,
|
||||||
uniq,
|
uniq,
|
||||||
@@ -15,23 +19,43 @@ import {
|
|||||||
memoize,
|
memoize,
|
||||||
addToMapKey,
|
addToMapKey,
|
||||||
identity,
|
identity,
|
||||||
|
groupBy,
|
||||||
always,
|
always,
|
||||||
} from "@welshman/lib"
|
} from "@welshman/lib"
|
||||||
import {load} from "@welshman/net"
|
import type {Socket} from "@welshman/net"
|
||||||
import {collection} from "@welshman/store"
|
import {Pool, load, AuthStateEvent, SocketEvent} from "@welshman/net"
|
||||||
|
import {
|
||||||
|
collection,
|
||||||
|
custom,
|
||||||
|
deriveEvents,
|
||||||
|
deriveEventsMapped,
|
||||||
|
withGetter,
|
||||||
|
synced,
|
||||||
|
localStorageProvider,
|
||||||
|
} from "@welshman/store"
|
||||||
import {
|
import {
|
||||||
getIdFilters,
|
getIdFilters,
|
||||||
WRAP,
|
WRAP,
|
||||||
CLIENT_AUTH,
|
CLIENT_AUTH,
|
||||||
AUTH_JOIN,
|
AUTH_JOIN,
|
||||||
REACTION,
|
REACTION,
|
||||||
|
ZAP_REQUEST,
|
||||||
ZAP_RESPONSE,
|
ZAP_RESPONSE,
|
||||||
DIRECT_MESSAGE,
|
DIRECT_MESSAGE,
|
||||||
GROUP_META,
|
DIRECT_MESSAGE_FILE,
|
||||||
|
ROOM_META,
|
||||||
MESSAGE,
|
MESSAGE,
|
||||||
GROUPS,
|
ROOMS,
|
||||||
THREAD,
|
THREAD,
|
||||||
COMMENT,
|
COMMENT,
|
||||||
|
ROOM_JOIN,
|
||||||
|
ROOM_ADD_USER,
|
||||||
|
ROOM_REMOVE_USER,
|
||||||
|
ALERT_EMAIL,
|
||||||
|
ALERT_WEB,
|
||||||
|
ALERT_IOS,
|
||||||
|
ALERT_ANDROID,
|
||||||
|
ALERT_STATUS,
|
||||||
getGroupTags,
|
getGroupTags,
|
||||||
getRelayTagValues,
|
getRelayTagValues,
|
||||||
getPubkeyTagValues,
|
getPubkeyTagValues,
|
||||||
@@ -41,6 +65,9 @@ import {
|
|||||||
getListTags,
|
getListTags,
|
||||||
asDecryptedEvent,
|
asDecryptedEvent,
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
|
getTag,
|
||||||
|
getTagValue,
|
||||||
|
getTagValues,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
|
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
|
||||||
import {Nip59, decrypt} from "@welshman/signer"
|
import {Nip59, decrypt} from "@welshman/signer"
|
||||||
@@ -65,24 +92,23 @@ import {
|
|||||||
appContext,
|
appContext,
|
||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
import type {Thunk, Relay} from "@welshman/app"
|
import type {Thunk, Relay} from "@welshman/app"
|
||||||
import {deriveEvents, deriveEventsMapped, withGetter, synced} from "@welshman/store"
|
|
||||||
|
|
||||||
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
|
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
|
||||||
|
|
||||||
export const ROOM = "h"
|
export const ROOM = "h"
|
||||||
|
|
||||||
export const GENERAL = "_"
|
|
||||||
|
|
||||||
export const PROTECTED = ["-"]
|
export const PROTECTED = ["-"]
|
||||||
|
|
||||||
export const ALERT = 32830
|
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
|
||||||
|
|
||||||
export const ALERT_STATUS = 32831
|
export const REACTION_KINDS = ENABLE_ZAPS ? [REACTION, ZAP_RESPONSE] : [REACTION]
|
||||||
|
|
||||||
export const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
|
export const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
|
||||||
|
|
||||||
export const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY
|
export const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY
|
||||||
|
|
||||||
|
export const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
||||||
|
|
||||||
export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS)
|
export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS)
|
||||||
|
|
||||||
export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS)
|
export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS)
|
||||||
@@ -97,7 +123,7 @@ export const PLATFORM_LOGO = PLATFORM_URL + "/pwa-192x192.png"
|
|||||||
|
|
||||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
|
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
|
||||||
|
|
||||||
export const PLATFORM_RELAY = import.meta.env.VITE_PLATFORM_RELAY
|
export const PLATFORM_RELAYS = fromCsv(import.meta.env.VITE_PLATFORM_RELAYS)
|
||||||
|
|
||||||
export const PLATFORM_ACCENT = import.meta.env.VITE_PLATFORM_ACCENT
|
export const PLATFORM_ACCENT = import.meta.env.VITE_PLATFORM_ACCENT
|
||||||
|
|
||||||
@@ -111,11 +137,9 @@ export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
|
|||||||
|
|
||||||
export const IMGPROXY_URL = "https://imgproxy.coracle.social"
|
export const IMGPROXY_URL = "https://imgproxy.coracle.social"
|
||||||
|
|
||||||
export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
|
|
||||||
|
|
||||||
export const NIP46_PERMS =
|
export const NIP46_PERMS =
|
||||||
"nip44_encrypt,nip44_decrypt," +
|
"nip44_encrypt,nip44_decrypt," +
|
||||||
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, GROUPS, WRAP, REACTION]
|
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION, ZAP_REQUEST]
|
||||||
.map(k => `sign_event:${k}`)
|
.map(k => `sign_event:${k}`)
|
||||||
.join(",")
|
.join(",")
|
||||||
|
|
||||||
@@ -162,8 +186,6 @@ export const entityLink = (entity: string) => `https://coracle.social/${entity}`
|
|||||||
export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) =>
|
export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) =>
|
||||||
entityLink(nip19.nprofileEncode({pubkey, relays}))
|
entityLink(nip19.nprofileEncode({pubkey, relays}))
|
||||||
|
|
||||||
export const tagRoom = (room: string, url: string) => [ROOM, room]
|
|
||||||
|
|
||||||
export const getDefaultPubkeys = () => {
|
export const getDefaultPubkeys = () => {
|
||||||
const appPubkeys = DEFAULT_PUBKEYS.split(",")
|
const appPubkeys = DEFAULT_PUBKEYS.split(",")
|
||||||
const userPubkeys = shuffle(getPubkeyTagValues(getListTags(get(userFollows))))
|
const userPubkeys = shuffle(getPubkeyTagValues(getListTags(get(userFollows))))
|
||||||
@@ -284,7 +306,11 @@ routerContext.getIndexerRelays = always(INDEXER_RELAYS)
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
|
|
||||||
export const canDecrypt = synced("canDecrypt", false)
|
export const canDecrypt = synced({
|
||||||
|
key: "canDecrypt",
|
||||||
|
defaultValue: false,
|
||||||
|
storage: localStorageProvider,
|
||||||
|
})
|
||||||
|
|
||||||
export const SETTINGS = 38489
|
export const SETTINGS = 38489
|
||||||
|
|
||||||
@@ -329,6 +355,43 @@ export const {
|
|||||||
load: makeOutboxLoader(SETTINGS),
|
load: makeOutboxLoader(SETTINGS),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Wallets
|
||||||
|
|
||||||
|
export type WebLNInfo = {
|
||||||
|
methods?: string[]
|
||||||
|
supports?: string[]
|
||||||
|
version?: string
|
||||||
|
node?: {
|
||||||
|
alias: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NWCInfo = {
|
||||||
|
lud16: string
|
||||||
|
secret: string
|
||||||
|
relayUrl: string
|
||||||
|
walletPubkey: string
|
||||||
|
nostrWalletConnectUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Wallet =
|
||||||
|
| {
|
||||||
|
type: "webln"
|
||||||
|
info: WebLNInfo
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "nwc"
|
||||||
|
info: NWCInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wallet = synced<Wallet | undefined>({
|
||||||
|
key: "wallet",
|
||||||
|
defaultValue: undefined,
|
||||||
|
storage: localStorageProvider,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getWebLn = () => (window as any).webln
|
||||||
|
|
||||||
// Alerts
|
// Alerts
|
||||||
|
|
||||||
export type Alert = {
|
export type Alert = {
|
||||||
@@ -336,15 +399,17 @@ export type Alert = {
|
|||||||
tags: string[][]
|
tags: string[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const alerts = deriveEventsMapped<Alert>(repository, {
|
export const alerts = withGetter(
|
||||||
filters: [{kinds: [ALERT]}],
|
deriveEventsMapped<Alert>(repository, {
|
||||||
itemToEvent: item => item.event,
|
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID]}],
|
||||||
eventToItem: async event => {
|
itemToEvent: item => item.event,
|
||||||
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
|
eventToItem: async event => {
|
||||||
|
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
|
||||||
|
|
||||||
return {event, tags}
|
return {event, tags}
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
// Alert Statuses
|
// Alert Statuses
|
||||||
|
|
||||||
@@ -353,15 +418,20 @@ export type AlertStatus = {
|
|||||||
tags: string[][]
|
tags: string[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const alertStatuses = deriveEventsMapped<AlertStatus>(repository, {
|
export const alertStatuses = withGetter(
|
||||||
filters: [{kinds: [ALERT_STATUS]}],
|
deriveEventsMapped<AlertStatus>(repository, {
|
||||||
itemToEvent: item => item.event,
|
filters: [{kinds: [ALERT_STATUS]}],
|
||||||
eventToItem: async event => {
|
itemToEvent: item => item.event,
|
||||||
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
|
eventToItem: async event => {
|
||||||
|
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
|
||||||
|
|
||||||
return {event, tags}
|
return {event, tags}
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const deriveAlertStatus = (address: string) =>
|
||||||
|
derived(alertStatuses, statuses => statuses.find(s => getTagValue("d", s.event.tags) === address))
|
||||||
|
|
||||||
// Membership
|
// Membership
|
||||||
|
|
||||||
@@ -390,25 +460,27 @@ export const getMembershipRoomsByUrl = (url: string, list?: List) =>
|
|||||||
sort(getGroupTags(getListTags(list)).filter(nthEq(2, url)).map(nth(1)))
|
sort(getGroupTags(getListTags(list)).filter(nthEq(2, url)).map(nth(1)))
|
||||||
|
|
||||||
export const memberships = deriveEventsMapped<PublishedList>(repository, {
|
export const memberships = deriveEventsMapped<PublishedList>(repository, {
|
||||||
filters: [{kinds: [GROUPS]}],
|
filters: [{kinds: [ROOMS]}],
|
||||||
itemToEvent: item => item.event,
|
itemToEvent: item => item.event,
|
||||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
indexStore: membershipByPubkey,
|
indexStore: membershipsByPubkey,
|
||||||
deriveItem: deriveMembership,
|
deriveItem: deriveMembership,
|
||||||
loadItem: loadMembership,
|
loadItem: loadMembership,
|
||||||
} = collection({
|
} = collection({
|
||||||
name: "memberships",
|
name: "memberships",
|
||||||
store: memberships,
|
store: memberships,
|
||||||
getKey: list => list.event.pubkey,
|
getKey: list => list.event.pubkey,
|
||||||
load: makeOutboxLoader(GROUPS),
|
load: makeOutboxLoader(ROOMS),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Chats
|
// Chats
|
||||||
|
|
||||||
export const chatMessages = deriveEvents(repository, {filters: [{kinds: [DIRECT_MESSAGE]}]})
|
export const chatMessages = deriveEvents(repository, {
|
||||||
|
filters: [{kinds: [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]}],
|
||||||
|
})
|
||||||
|
|
||||||
export type Chat = {
|
export type Chat = {
|
||||||
id: string
|
id: string
|
||||||
@@ -476,126 +548,93 @@ export const chatSearch = derived(chats, $chats =>
|
|||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
|
|
||||||
export const messages = derived(
|
export const messages = deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]})
|
||||||
deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]}),
|
|
||||||
$events => $events,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Nip29
|
|
||||||
|
|
||||||
export const groupMeta = deriveEvents(repository, {filters: [{kinds: [GROUP_META]}]})
|
|
||||||
|
|
||||||
export const hasNip29 = (relay?: Relay) =>
|
|
||||||
relay?.profile?.supported_nips?.map?.(String)?.includes?.("29")
|
|
||||||
|
|
||||||
// Channels
|
// Channels
|
||||||
|
|
||||||
export type ChannelMeta = {
|
export type Channel = {
|
||||||
access: "public" | "private"
|
id: string
|
||||||
membership: "open" | "closed"
|
url: string
|
||||||
|
room: string
|
||||||
|
name: string
|
||||||
|
event: TrustedEvent
|
||||||
|
closed: boolean
|
||||||
|
private: boolean
|
||||||
picture?: string
|
picture?: string
|
||||||
about?: string
|
about?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Channel = {
|
export const makeChannelId = (url: string, room: string) => `${url}'${room}`
|
||||||
url: string
|
|
||||||
room: string
|
|
||||||
name: string
|
|
||||||
meta?: ChannelMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeChannelId = (url: string, room: string) => `${url}|${room}`
|
export const splitChannelId = (id: string) => id.split("'")
|
||||||
|
|
||||||
export const splitChannelId = (id: string) => id.split("|")
|
export const hasNip29 = (relay?: Relay) =>
|
||||||
|
relay?.profile?.supported_nips?.map?.(String)?.includes?.("29")
|
||||||
|
|
||||||
export const channelsById = withGetter(
|
export const channelEvents = deriveEvents(repository, {filters: [{kinds: [ROOM_META]}]})
|
||||||
derived(
|
|
||||||
[groupMeta, memberships, messages, getUrlsForEvent],
|
|
||||||
([$groupMeta, $memberships, $messages, $getUrlsForEvent]) => {
|
|
||||||
const channelsById = new Map<string, Channel>()
|
|
||||||
|
|
||||||
// Add meta using group meta events
|
export const channels = derived(
|
||||||
for (const event of $groupMeta) {
|
[channelEvents, getUrlsForEvent],
|
||||||
const meta = fromPairs(event.tags)
|
([$channelEvents, $getUrlsForEvent]) => {
|
||||||
const room = meta.d
|
const $channels: Channel[] = []
|
||||||
|
|
||||||
if (room) {
|
for (const event of $channelEvents) {
|
||||||
for (const url of $getUrlsForEvent(event.id)) {
|
const meta = fromPairs(event.tags)
|
||||||
const id = makeChannelId(url, room)
|
const room = meta.d
|
||||||
|
|
||||||
channelsById.set(id, {
|
if (room) {
|
||||||
url,
|
for (const url of $getUrlsForEvent(event.id)) {
|
||||||
room,
|
|
||||||
name: meta.name || room,
|
|
||||||
meta: {
|
|
||||||
access: meta.private ? "private" : "public",
|
|
||||||
membership: meta.closed ? "closed" : "open",
|
|
||||||
picture: meta.picture,
|
|
||||||
about: meta.about,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add known rooms based on membership events
|
|
||||||
for (const membership of $memberships) {
|
|
||||||
for (const {url, room, name} of getMembershipRooms(membership)) {
|
|
||||||
const id = makeChannelId(url, room)
|
const id = makeChannelId(url, room)
|
||||||
|
|
||||||
if (!channelsById.has(id)) {
|
$channels.push({
|
||||||
channelsById.set(id, {url, room, name})
|
id,
|
||||||
}
|
url,
|
||||||
|
room,
|
||||||
|
event,
|
||||||
|
name: meta.name || room,
|
||||||
|
closed: Boolean(getTag("closed", event.tags)),
|
||||||
|
private: Boolean(getTag("private", event.tags)),
|
||||||
|
picture: meta.picture,
|
||||||
|
about: meta.about,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add rooms based on known messages
|
return uniqBy(c => c.id, $channels)
|
||||||
for (const event of $messages) {
|
},
|
||||||
const [_, room] = event.tags.find(nthEq(0, ROOM)) || []
|
|
||||||
|
|
||||||
if (room) {
|
|
||||||
for (const url of $getUrlsForEvent(event.id)) {
|
|
||||||
const id = makeChannelId(url, room)
|
|
||||||
|
|
||||||
if (!channelsById.has(id)) {
|
|
||||||
channelsById.set(id, {url, room, name: room})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return channelsById
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const deriveChannel = (url: string, room: string) =>
|
export const channelsByUrl = derived(channels, $channels => groupBy(c => c.url, $channels))
|
||||||
derived(channelsById, $channelsById => $channelsById.get(makeChannelId(url, room)))
|
|
||||||
|
|
||||||
export const channelsByUrl = derived(channelsById, $channelsById => {
|
export const {
|
||||||
const $channelsByUrl = new Map<string, Channel[]>()
|
indexStore: channelsById,
|
||||||
|
deriveItem: _deriveChannel,
|
||||||
|
loadItem: _loadChannel,
|
||||||
|
} = collection({
|
||||||
|
name: "channels",
|
||||||
|
store: channels,
|
||||||
|
getKey: channel => channel.id,
|
||||||
|
load: async (id: string) => {
|
||||||
|
const [url, room] = splitChannelId(id)
|
||||||
|
|
||||||
for (const channel of $channelsById.values()) {
|
await load({
|
||||||
pushToMapKey($channelsByUrl, channel.url, channel)
|
relays: [url],
|
||||||
}
|
filters: [{kinds: [ROOM_META], "#d": [room]}],
|
||||||
|
})
|
||||||
return $channelsByUrl
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const displayChannel = (url: string, room: string) => {
|
export const deriveChannel = (url: string, room: string) => _deriveChannel(makeChannelId(url, room))
|
||||||
if (room === GENERAL) {
|
|
||||||
return "general"
|
|
||||||
}
|
|
||||||
|
|
||||||
return channelsById.get().get(makeChannelId(url, room))?.name || room
|
export const loadChannel = (url: string, room: string) => _loadChannel(makeChannelId(url, room))
|
||||||
}
|
|
||||||
|
export const displayChannel = (url: string, room: string) =>
|
||||||
|
channelsById.get().get(makeChannelId(url, room))?.name || room
|
||||||
|
|
||||||
export const roomComparator = (url: string) => (room: string) =>
|
export const roomComparator = (url: string) => (room: string) =>
|
||||||
displayChannel(url, room).toLowerCase()
|
displayChannel(url, room).toLowerCase()
|
||||||
|
|
||||||
export const channelIsLocked = (channel?: Channel) =>
|
|
||||||
channel?.meta?.access === "private" && channel?.meta?.membership === "closed"
|
|
||||||
|
|
||||||
// User stuff
|
// User stuff
|
||||||
|
|
||||||
export const userSettings = withGetter(
|
export const userSettings = withGetter(
|
||||||
@@ -615,26 +654,28 @@ export const userSettingValues = withGetter(
|
|||||||
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingValues.get()[key] as T
|
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingValues.get()[key] as T
|
||||||
|
|
||||||
export const userMembership = withGetter(
|
export const userMembership = withGetter(
|
||||||
derived([pubkey, membershipByPubkey], ([$pubkey, $membershipByPubkey]) => {
|
derived([pubkey, membershipsByPubkey], ([$pubkey, $membershipsByPubkey]) => {
|
||||||
if (!$pubkey) return undefined
|
if (!$pubkey) return undefined
|
||||||
|
|
||||||
loadMembership($pubkey)
|
loadMembership($pubkey)
|
||||||
|
|
||||||
return $membershipByPubkey.get($pubkey)
|
return $membershipsByPubkey.get($pubkey)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const userRoomsByUrl = withGetter(
|
export const userRoomsByUrl = withGetter(
|
||||||
derived(userMembership, $userMembership => {
|
derived([userMembership, channelsById], ([$userMembership, $channelsById]) => {
|
||||||
const tags = getListTags($userMembership)
|
const tags = getListTags($userMembership)
|
||||||
const $userRoomsByUrl = new Map<string, Set<string>>()
|
const $userRoomsByUrl = new Map<string, Set<string>>()
|
||||||
|
|
||||||
for (const [_, room, url] of getGroupTags(tags)) {
|
for (const url of getRelayTagValues(tags)) {
|
||||||
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
|
$userRoomsByUrl.set(normalizeRelayUrl(url), new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const url of getRelayTagValues(tags)) {
|
for (const [_, room, url] of getGroupTags(tags)) {
|
||||||
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), GENERAL)
|
if ($channelsById.has(makeChannelId(url, room))) {
|
||||||
|
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $userRoomsByUrl
|
return $userRoomsByUrl
|
||||||
@@ -643,7 +684,7 @@ export const userRoomsByUrl = withGetter(
|
|||||||
|
|
||||||
export const deriveUserRooms = (url: string) =>
|
export const deriveUserRooms = (url: string) =>
|
||||||
derived(userRoomsByUrl, $userRoomsByUrl =>
|
derived(userRoomsByUrl, $userRoomsByUrl =>
|
||||||
sortBy(roomComparator(url), uniq(Array.from($userRoomsByUrl.get(url) || [GENERAL]))),
|
sortBy(roomComparator(url), uniq(Array.from($userRoomsByUrl.get(url) || []))),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const deriveOtherRooms = (url: string) =>
|
export const deriveOtherRooms = (url: string) =>
|
||||||
@@ -654,6 +695,41 @@ export const deriveOtherRooms = (url: string) =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export enum MembershipStatus {
|
||||||
|
Initial,
|
||||||
|
Pending,
|
||||||
|
Granted,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deriveUserMembershipStatus = (url: string, room: string) =>
|
||||||
|
derived(
|
||||||
|
[
|
||||||
|
pubkey,
|
||||||
|
deriveEventsForUrl(url, [
|
||||||
|
{kinds: [ROOM_JOIN, ROOM_ADD_USER, ROOM_REMOVE_USER], "#h": [room]},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
([$pubkey, $events]) => {
|
||||||
|
let status = MembershipStatus.Initial
|
||||||
|
|
||||||
|
for (const event of $events) {
|
||||||
|
if (event.kind === ROOM_JOIN && event.pubkey === $pubkey) {
|
||||||
|
status = MembershipStatus.Pending
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === ROOM_REMOVE_USER && getTagValues("p", event.tags).includes($pubkey!)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === ROOM_ADD_USER && getTagValues("p", event.tags).includes($pubkey!)) {
|
||||||
|
return MembershipStatus.Granted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// Other utils
|
// Other utils
|
||||||
|
|
||||||
export const encodeRelay = (url: string) =>
|
export const encodeRelay = (url: string) =>
|
||||||
@@ -670,3 +746,19 @@ export const displayReaction = (content: string) => {
|
|||||||
if (content === "-") return "👎"
|
if (content === "-") return "👎"
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const deriveSocket = (url: string) =>
|
||||||
|
custom<Socket>(set => {
|
||||||
|
const pool = Pool.get()
|
||||||
|
const socket = pool.get(url)
|
||||||
|
|
||||||
|
set(socket)
|
||||||
|
|
||||||
|
const subs = [
|
||||||
|
on(socket, SocketEvent.Error, () => set(socket)),
|
||||||
|
on(socket, SocketEvent.Status, () => set(socket)),
|
||||||
|
on(socket.auth, AuthStateEvent.Status, () => set(socket)),
|
||||||
|
]
|
||||||
|
|
||||||
|
return () => subs.forEach(call)
|
||||||
|
})
|
||||||
|
|||||||
+6
-2
@@ -1,3 +1,7 @@
|
|||||||
import {synced} from "@welshman/store"
|
import {synced, localStorageProvider} from "@welshman/store"
|
||||||
|
|
||||||
export const theme = synced<string>("theme", "dark")
|
export const theme = synced({
|
||||||
|
key: "theme",
|
||||||
|
defaultValue: "dark",
|
||||||
|
storage: localStorageProvider,
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18.7491 9.70957V9.00497C18.7491 5.13623 15.7274 2 12 2C8.27256 2 5.25087 5.13623 5.25087 9.00497V9.70957C5.25087 10.5552 5.00972 11.3818 4.5578 12.0854L3.45036 13.8095C2.43882 15.3843 3.21105 17.5249 4.97036 18.0229C9.57274 19.3257 14.4273 19.3257 19.0296 18.0229C20.789 17.5249 21.5612 15.3843 20.5496 13.8095L19.4422 12.0854C18.9903 11.3818 18.7491 10.5552 18.7491 9.70957Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||||
|
<path d="M7.5 19C8.15503 20.7478 9.92246 22 12 22C14.0775 22 15.845 20.7478 16.5 19" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 674 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16.755 2H7.24502C6.08614 2 5.50671 2 5.03939 2.16261C4.15322 2.47096 3.45748 3.18719 3.15795 4.09946C3 4.58055 3 5.17705 3 6.37006V20.3742C3 21.2324 3.985 21.6878 4.6081 21.1176C4.97417 20.7826 5.52583 20.7826 5.8919 21.1176L6.375 21.5597C7.01659 22.1468 7.98341 22.1468 8.625 21.5597C9.26659 20.9726 10.2334 20.9726 10.875 21.5597C11.5166 22.1468 12.4834 22.1468 13.125 21.5597C13.7666 20.9726 14.7334 20.9726 15.375 21.5597C16.0166 22.1468 16.9834 22.1468 17.625 21.5597L18.1081 21.1176C18.4742 20.7826 19.0258 20.7826 19.3919 21.1176C20.015 21.6878 21 21.2324 21 20.3742V6.37006C21 5.17705 21 4.58055 20.842 4.09946C20.5425 3.18719 19.8468 2.47096 18.9606 2.16261C18.4933 2 17.9139 2 16.755 2Z" stroke="#1C274C" stroke-width="1.5"/>
|
||||||
|
<path d="M10.5 11L17 11" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M7 11H7.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M7 7.5H7.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M7 14.5H7.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M10.5 7.5H17" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M10.5 14.5H17" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user