Compare commits

..

99 Commits

Author SHA1 Message Date
Jon Staab 3e832af3e4 Update version 2025-07-17 15:41:30 -07:00
Jon Staab 84b8650fa4 Shorten goals title for mobile 2025-07-17 15:40:07 -07:00
Jon Staab 83abb5aa94 Fix chat notifications so they take nip 29 into account 2025-07-17 15:35:49 -07:00
Jon Staab a12eddb47b Fix zaps on mobile 2025-07-17 15:29:26 -07:00
Jon Staab c87166247c Update zapstore.yaml 2025-07-17 15:21:05 -07:00
Jon Staab 037c8cb41b Disable zaps on ios 2025-07-17 14:39:59 -07:00
Jon Staab 79de2e1176 Bump version 2025-07-17 14:30:18 -07:00
Jon Staab d4b026a3ad Add zaps to threads/events 2025-07-15 15:56:55 -07:00
Jon Staab 00f383ff2e Add qr scanning for wallet connect 2025-07-15 15:49:26 -07:00
Jon Staab 6f6bb508db Handle invalid bunker url, update synced stores 2025-07-15 11:34:29 -07:00
Jon Staab e2a0672ca5 load messages in general on room relay 2025-07-09 14:28:07 -07:00
Jon Staab e2a5fe7a79 Fix sidebar overflow 2025-07-09 14:22:59 -07:00
Jon Staab 5d02ae75dc Bump welshman 2025-07-09 14:00:42 -07:00
Jon Staab 2460bbbc83 Fix balance coming from webln 2025-07-09 13:19:33 -07:00
Jon Staab 084d8d931b Load relay selections whenever we see a new pubkey 2025-07-09 09:17:45 -07:00
Jon Staab 6ee4ac1a89 Add funding goals 2025-07-07 15:28:36 -07:00
Jon Staab 1d07097350 Fix some zap bugs 2025-07-07 13:58:43 -07:00
Jon Staab 63d6b362c7 Remove info missing rooms 2025-07-07 12:46:17 -07:00
Jon Staab bfed277ea9 Add zaps 2025-07-04 06:22:19 -07:00
Jon Staab 9e8aa2ef3a show and copy npub 2025-07-02 16:48:44 -07:00
Jon Staab 4bbc0878f7 Bump apple version, add vapid key 2025-07-01 12:53:09 -07:00
Jon Staab 16a3ba2a9b Bump version 2025-06-30 11:08:42 -07:00
Jon Staab 7c11eb8947 Allow mark all as read on desktop 2025-06-30 11:03:02 -07:00
Jon Staab 6bdc8d4d9f Space alerts dialog 2025-06-30 10:41:42 -07:00
Jon Staab b9048936ba Tweak alerts button layout 2025-06-30 09:38:43 -07:00
Jon Staab b9620f4443 Add claim to alert add 2025-06-27 14:36:09 -07:00
Jon Staab f2249fe592 Handle conversations with no room 2025-06-27 09:44:01 -07:00
Jon Staab fd42a0e8d4 Clear badge when opening app 2025-06-27 09:41:30 -07:00
Jon Staab 37d52ba35f Show latest note as conversation 2025-06-27 09:00:43 -07:00
Jon Staab 3037323dc0 Add support for ios push notifications 2025-06-27 08:33:31 -07:00
Jon Staab 5301ef876d Fix notification badge for global chat 2025-06-24 17:36:14 -07:00
Jon Staab aa054d8b1a Fix ContentMention display 2025-06-24 17:23:07 -07:00
Jon Staab 3655790e5f Add fcm push notifications 2025-06-24 14:27:16 -07:00
Jon Staab 6cca823ed4 Get web push working 2025-06-23 11:16:25 -07:00
Jon Staab 18a383edab Update alert form to include push notifications 2025-06-19 10:01:16 -07:00
Jon Staab 43da7d628e Replace bunker with claim on alerts page 2025-06-18 17:02:32 -07:00
Jon Staab 2fae3ca248 Fix broadcasting user profiles when protected 2025-06-16 16:56:59 -07:00
Jon Staab d99ada44f5 Show link url if no title is available 2025-06-16 11:45:05 -07:00
Jon Staab cb0119b9b8 Update welshman 2025-06-16 10:12:24 -07:00
Jon Staab dac9ef8e4e Move some stuff to welshman, broadcast profile updates 2025-06-13 15:17:20 -07:00
Jon Staab 528917b90e Fix sort order of thread comments 2025-06-13 10:14:12 -07:00
Jon Staab a22db78967 rename createEvent to makeEvent 2025-06-10 13:35:57 -07:00
Jon Staab 5718510779 Bump versions 2025-06-09 15:29:13 -07:00
Jon Staab f877dc7fbe Add chat quick link 2025-06-09 15:27:00 -07:00
Jon Staab df03fb1116 Remove old docker workflow 2025-06-09 15:20:09 -07:00
Jon Staab 7455b49f8d Bump version 2025-06-09 15:15:58 -07:00
Jon Staab ae00eb0b9c Bump welshman and nostr-editor 2025-06-09 15:04:51 -07:00
Jon Staab b82e434c70 Try turning ci off 2025-06-09 14:02:06 -07:00
Jon Staab 576c9c2c95 Bump welshman 2025-06-09 13:48:46 -07:00
Jon Staab cef046b3ae Increase maxLength for recent activity 2025-06-09 13:48:46 -07:00
Jon Staab 18ae6f6044 Use new editor uploads 2025-06-09 13:48:46 -07:00
Jon Staab 664f3c01e0 Bump nostr-tools 2025-06-09 13:48:46 -07:00
Jon Staab 15e82c4e41 Link to frith in SpaceCreateExternal 2025-06-09 13:48:46 -07:00
Jon Staab 397ecf773e Show warning for non-nip29 relays 2025-06-09 13:48:46 -07:00
Jon Staab 45397e7fb8 Show image link if image fails to load 2025-06-09 13:48:46 -07:00
Jon Staab 11aa841241 Add profile room list 2025-06-09 13:48:46 -07:00
Jon Staab cc1c18d55f Update context file 2025-06-09 13:48:46 -07:00
Jon Staab e3fbd69e6e Tweak profiles/search 2025-06-09 13:48:46 -07:00
Jon Staab ac756bf266 tweak relay status component 2025-06-09 13:48:46 -07:00
Jon Staab 8e28ff13e9 Generalize goToMessage 2025-06-09 13:48:46 -07:00
Jon Staab d8b87db784 Flesh out recent activity component 2025-06-09 13:48:46 -07:00
Jon Staab 0b8c6c4a49 Add claude context file and mock up recent activity 2025-06-09 13:48:46 -07:00
Jon Staab 9f4f468bf0 Add tos and pp links 2025-06-09 13:48:46 -07:00
Jon Staab 7563dff621 Move relay status to its own component 2025-06-09 13:48:46 -07:00
Jon Staab f782898b62 Factor space recent activity into its own component 2025-06-09 13:48:46 -07:00
Jon Staab d0601400cd Move space quick links to its own file 2025-06-09 13:48:45 -07:00
Jon Staab d262da39e5 Tweak room search and owner display 2025-06-09 13:48:45 -07:00
Jon Staab 7d617d8399 Add latest note from admin to relay dashboard 2025-06-09 13:48:45 -07:00
Jon Staab d2b7db18af Show socket status on space dashboard 2025-06-09 13:48:45 -07:00
Jon Staab 89c2690254 Add new room dashboard layout 2025-06-09 13:48:45 -07:00
Jon Staab 34945d1c42 Fix chat menu item active state 2025-06-09 13:48:45 -07:00
Jon Staab 43b207c4dc Use kind 15 to send images in DMs 2025-06-09 13:48:45 -07:00
Jon Staab 55efb3fdfd Use encrypted uploads 2025-06-09 13:48:45 -07:00
Jon Staab c4a1ad2e33 Ignore roocode 2025-06-09 13:48:45 -07:00
Jon Staab fd8442c632 Remove space blossom detection 2025-06-09 13:48:45 -07:00
Jon Staab e0875eb9b9 Add minimal style for quotes of chat messages 2025-06-09 13:48:45 -07:00
Jon Staab 962ac7d80c Support copying and pasting npubs better 2025-06-09 13:48:45 -07:00
Jon Staab 5338ee11bc Update changelog 2025-06-09 13:48:45 -07:00
Jon Staab 6d2e9a037d Allow for multiple platform relays 2025-06-09 13:48:45 -07:00
Jon Staab ac8530bd9a Add non-nip29 chat, add leave room 2025-06-09 13:48:45 -07:00
Jon Staab f7d11cf124 Improve group membership detection 2025-06-09 13:48:45 -07:00
Jon Staab 72d85e5740 Unnest nip29 commands 2025-06-09 13:48:45 -07:00
Jon Staab e57b5721f6 Detect nip29 support for create room button 2025-06-09 13:48:45 -07:00
Jon Staab 4ba6c72459 Remove unmanaged groups 2025-06-09 13:48:45 -07:00
Jon Staab c33698c662 Remove general room 2025-06-09 13:48:45 -07:00
Jon Staab cf4e40c4cf Add github action to publish dockerfile 2025-06-09 13:48:45 -07:00
Jon Staab 664da505cd Improve forms for entering invite codes 2025-05-27 15:00:30 -07:00
Jon Staab 573d4e3cfb Add support for customizable accent content color 2025-05-19 16:56:19 -07:00
Jon Staab b2dc41f25b Re-arrange the readme 2025-05-19 15:17:26 -07:00
Jon Staab b3bc0e4957 Add github action to publish dockerfile 2025-05-19 11:54:19 -07:00
Jon Staab 0e79e5b9cc Add dockerfile 2025-05-15 16:20:46 -07:00
Jon Staab 34c7bfcffb Get rid of overries, bump welshman to lockstep versioning 2025-05-15 15:52:17 -07:00
Jon Staab fd9fee8f50 Add indexer, maybe improve safe area support 2025-05-15 10:15:41 -07:00
Jon Staab b14c3ab345 Bump version 2025-05-14 13:52:37 -07:00
Jon Staab 823058e335 Add setting for font size 2025-05-13 14:31:34 -07:00
Jon Staab 60ec6924f3 Fix thunks status layout 2025-05-13 10:35:42 -07:00
Jon Staab 18fc895fcb Tweak navigation to improve white labeled instances 2025-05-13 10:14:20 -07:00
Jon Staab 42295159a0 Update remove-pnpm-overrides to use package version of welshman (hack) 2025-05-13 09:06:53 -07:00
Jon Staab db408ac30d Stop propagation on thunk status 2025-05-12 15:35:13 -07:00
148 changed files with 5405 additions and 1473 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
android
ios
build
+3 -2
View File
@@ -5,13 +5,14 @@ VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_RELAY=
VITE_PLATFORM_RELAYS=
VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/,wss://indexer.coracle.social/
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+1 -1
View File
@@ -1 +1 @@
package-lock.json -diff
pnpm-lock.yaml -diff
+59
View File
@@ -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
+1
View File
@@ -60,6 +60,7 @@ google-services.json
GoogleService-Info.plist
# IDEs and editors
.roo
.idea/
.vscode/
+6
View File
@@ -1,2 +1,8 @@
pnpm run lint
pnpm run check
if [[ ! -z $(cat package.json | grep 'link:') ]]; then
echo "Some packages are linked to local files!"
exit 1
fi
+48
View File
@@ -1,5 +1,53 @@
# Changelog
# 1.2.2
* Fix phantom chat notifications
* Fix zaps on mobile
# 1.2.1
* Add zaps to chat, threads, and events
* Add funding goals
* Add NWC support
* Add wallet settings page
* Handle invalid bunker url
* Fix sidebar overflow
* Fix profile npub display
# 1.2.0
* Fix sort order of thread comments
* Fix link display when no title is available
* Fix making profiles non-protected
* Replace bunker url with relay claims for notifier auth
* Add push notifications on all platforms
* Add "mark all as read" on desktop
* Re-design space dashboard
# 1.1.1
* Add chat quick link
# 1.1.0
* Add better theming support
* Improve forms for entering invite codes
* Rely more heavily on NIP 29 for rooms
* Support multiple platform relays
* Remove default general room
* Remove room tag from threads/calendars
* Show pubkey on profile detail
* Support pasting pubkey into chat start dialog
* Add minimal style for quoted messages
# 1.0.4
* Fix thunk status click handler
* Remove duplicate dependencies
* Improve navigation on white-labeled instances
* Add setting for font size
# 1.0.3
* Add light theme
+26
View File
@@ -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
View File
@@ -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"]
+15 -78
View File
@@ -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
# Deploy
To run your own Flotilla, it's as simple as:
- `pnpm install`
- `pnpm run build`
- `npx serve build`
## Environment
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_NAME` - The name of 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_DESCRIPTION` - A description of the app
- `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.
## 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.
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.
To run your own Flotilla, it's as simple as:
```sh
# Replace with your password
PASSWORD=<YOUR PASSWORD HERE>
# 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
pnpm install
pnpm run build
npx serve build
```
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
server {
listen 80;
server_name <SERVER NAME>;
root /home/flotilla/flotilla/build;
index index.html;
location / {
try_files $uri /index.html;
}
}
```sh
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
```
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.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 17
versionName "1.0.3"
versionCode 23
versionName "1.2.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+3
View File
@@ -9,8 +9,11 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-push-notifications')
implementation project(':capawesome-capacitor-badge')
implementation project(':nostr-signer-capacitor-plugin')
}
+1
View File
@@ -34,4 +34,5 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>
+9
View File
@@ -2,11 +2,20 @@
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')
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'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications/android')
include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin'
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')
+6 -7
View File
@@ -1,16 +1,15 @@
#!/usr/bin/env bash
set -e
# Fetch tags and set to env vars
git fetch --prune --unshallow --tags
git describe --tags --abbrev=0
git fetch --prune --unshallow --tags || true
git describe --tags --abbrev=0 || true
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
# Remove link overrides
node remove-pnpm-overrides.js package.json
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
pnpm i --no-frozen-lockfile
# Install dependencies
CI=0 pnpm i
# Rebuild sharp
pnpm rebuild
+4
View File
@@ -15,6 +15,10 @@ const config: CapacitorConfig = {
style: "DARK",
resizeOnFullScreen: true,
},
Badge: {
persist: true,
autoClear: true
},
},
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// server: {
+8 -4
View File
@@ -18,6 +18,7 @@
/* End PBXBuildFile 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>"; };
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>"; };
@@ -57,6 +58,7 @@
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
@@ -349,16 +351,17 @@
baseConfigurationReference = 7B9FA71C362B734D9F965709 /* Pods-Flotilla Chat.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.3;
MARKETING_VERSION = 1.2.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -374,16 +377,17 @@
baseConfigurationReference = 1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.3;
MARKETING_VERSION = 1.2.2;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+9
View File
@@ -46,4 +46,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
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)
}
}
+4
View File
@@ -49,5 +49,9 @@
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>
+8
View File
@@ -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>
+3
View File
@@ -11,8 +11,11 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
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 '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 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
end
+25 -31
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "1.0.3",
"version": "1.2.2",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -15,6 +15,7 @@
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.26.0",
"@sentry/cli": "^2.40.0",
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
@@ -37,32 +38,36 @@
},
"type": "module",
"dependencies": {
"@capacitor-community/safe-area": "7.0.0-alpha.1",
"@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0",
"@capacitor/cli": "^7.0.0",
"@capacitor/core": "^7.0.1",
"@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@noble/curves": "^1.5.0",
"@noble/hashes": "^1.4.0",
"@capacitor/push-notifications": "^7.0.1",
"@capawesome/capacitor-badge": "^7.0.1",
"@getalby/sdk": "^5.1.0",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.35.0",
"@sveltejs/adapter-static": "^3.0.4",
"@tiptap/core": "^2.12.0",
"@types/qrcode": "^1.5.5",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "^0.2.5",
"@welshman/content": "^0.2.2",
"@welshman/dvm": "^0.2.0",
"@welshman/editor": "^0.2.4",
"@welshman/feeds": "^0.2.2",
"@welshman/lib": "^0.2.2",
"@welshman/net": "^0.2.3",
"@welshman/relay": "^0.2.0",
"@welshman/router": "^0.2.0",
"@welshman/signer": "^0.2.3",
"@welshman/store": "^0.2.0",
"@welshman/util": "^0.2.3",
"@welshman/app": "^0.4.0",
"@welshman/content": "^0.4.0",
"@welshman/editor": "^0.4.0",
"@welshman/feeds": "^0.4.0",
"@welshman/lib": "^0.4.0",
"@welshman/net": "^0.4.0",
"@welshman/relay": "^0.4.0",
"@welshman/router": "^0.4.0",
"@welshman/signer": "^0.4.0",
"@welshman/store": "^0.4.0",
"@welshman/util": "^0.4.0",
"compressorjs": "^1.2.1",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
@@ -71,25 +76,14 @@
"husky": "^9.1.6",
"idb": "^8.0.0",
"nostr-signer-capacitor-plugin": "^0.0.4",
"nostr-tools": "^2.7.2",
"nostr-tools": "^2.14.2",
"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": {
"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": [
"@sentry/cli",
"esbuild"
+958 -58
View File
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
// This script is necessary for installing stuff on a host, since our links don't exist there.
import fs from "fs"
const pkgName = process.argv[2]
if (!pkgName?.endsWith("package.json")) {
console.log("File passed was not a package.json file")
process.exit(1)
}
const pkg = JSON.parse(fs.readFileSync(pkgName, "utf8"))
if (pkg.pnpm && pkg.pnpm.overrides) {
delete pkg.pnpm.overrides
fs.writeFileSync(pkgName, JSON.stringify(pkg, null, 2) + "\n")
console.log("Removed pnpm.overrides from package.json")
} else {
console.log("No pnpm.overrides found in package.json")
}
+4
View File
@@ -388,6 +388,10 @@ progress[value]::-webkit-progress-value {
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cw-full {
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
}
.cb {
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
}
+104 -94
View File
@@ -1,6 +1,7 @@
import {nwc} from "@getalby/sdk"
import * as nip19 from "nostr-tools/nip19"
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 {TrustedEvent, EventContent} from "@welshman/util"
import {
@@ -12,14 +13,14 @@ import {
FOLLOWS,
REACTION,
AUTH_JOIN,
GROUP_JOIN,
GROUP_LEAVE,
GROUP_CREATE,
GROUP_EDIT_META,
GROUPS,
ROOMS,
COMMENT,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
isSignedEvent,
createEvent,
makeEvent,
displayProfile,
normalizeRelayUrl,
makeList,
@@ -33,7 +34,7 @@ import {
getRelaysFromList,
RelayMode,
} from "@welshman/util"
import {Pool, PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
import {
pubkey,
@@ -52,15 +53,14 @@ import {
dropSession,
tagEventForComment,
tagEventForQuote,
thunkIsComplete,
getThunkError,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {
tagRoom,
wallet,
getWebLn,
PROTECTED,
userMembership,
INDEXER_RELAYS,
ALERT,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
userRoomsByUrl,
@@ -83,21 +83,6 @@ export const getPubkeyPetname = (pubkey: string) => {
return display
}
export const getThunkError = (thunk: Thunk) =>
new Promise<string>(resolve => {
thunk.subscribe($thunk => {
for (const [relay, status] of Object.entries($thunk.status)) {
if (status === PublishStatus.Failure) {
resolve($thunk.details[relay])
}
}
if (thunkIsComplete($thunk)) {
resolve("")
}
})
})
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) {
const nevent = nip19.neventEncode({
@@ -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
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 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) => {
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 event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
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})
}
export const addRoomMembership = async (url: string, room: string, name: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
export const addRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: ROOMS})
const newTags = [
["r", url],
["group", room, url, name],
["group", room, url],
]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
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) => {
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 event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
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({
event: createEvent(list.kind, {tags}),
event: makeEvent(list.kind, {tags}),
relays: [
url,
...INDEXER_RELAYS,
@@ -244,7 +202,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
}
return publishThunk({
event: createEvent(list.kind, {tags}),
event: makeEvent(list.kind, {tags}),
relays: [
...INDEXER_RELAYS,
...Router.get().FromUser().getUrls(),
@@ -256,13 +214,18 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
// 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 = "") => {
const socket = Pool.get().get(url)
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
await attemptAuth(url)
const thunk = publishThunk({
event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url],
})
@@ -281,6 +244,9 @@ export const checkRelayAccess = async (url: string, claim = "") => {
// Ignore messages about the relay ignoring ours
if (error?.startsWith("mute: ")) return
// Ignore rejected empty claims
if (!claim && error?.includes("invite code")) return
return message
}
}
@@ -312,7 +278,7 @@ export const checkRelayAuth = async (url: string, timeout = 3000) => {
const socket = Pool.get().get(url)
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
await attemptAuth(url)
// Only raise an error if it's not a timeout.
// 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
export const makeDelete = ({event}: {event: TrustedEvent}) => {
const tags = [["k", String(event.kind)], ...tagEvent(event)]
export const makeDelete = ({event, tags = []}: {event: TrustedEvent; tags?: string[][]}) => {
const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags]
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(PROTECTED)
tags.push(groupTag)
thisTags.push(PROTECTED, groupTag)
}
return createEvent(DELETE, {tags})
return makeEvent(DELETE, {tags: thisTags})
}
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
publishThunk({event: makeDelete({event}), relays})
export const publishDelete = ({
relays,
event,
tags = [],
}: {
relays: string[]
event: TrustedEvent
tags?: string[][]
}) => publishThunk({event: makeDelete({event, tags}), relays})
export type ReportParams = {
event: TrustedEvent
@@ -366,7 +338,7 @@ export const makeReport = ({event, reason, content}: ReportParams) => {
["e", event.id, reason],
]
return createEvent(REPORT, {content, tags})
return makeEvent(REPORT, {content, tags})
}
export const publishReport = ({
@@ -393,7 +365,7 @@ export const makeReaction = ({content, event, tags: paramTags = []}: ReactionPar
tags.push(groupTag)
}
return createEvent(REACTION, {content, tags})
return makeEvent(REACTION, {content, tags})
}
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
@@ -406,42 +378,64 @@ export type 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[]}) =>
publishThunk({event: makeComment(params), relays})
export type AlertParams = {
feed: Feed
cron: string
email: string
bunker: string
secret: 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 = [
["feed", JSON.stringify(feed)],
["cron", cron],
["email", email],
["feed", JSON.stringify(params.feed)],
["locale", LOCALE],
["timezone", TIMEZONE],
["description", description],
["channel", "email"],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
["description", params.description],
]
if (bunker) {
tags.push(["nip46", secret, bunker])
for (const [relay, claim] of Object.entries(params.claims)) {
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)),
tags: [
["d", randomId()],
@@ -452,3 +446,19 @@ export const makeAlert = async ({cron, email, feed, bunker, secret, description}
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
export const payInvoice = async (invoice: string) => {
const $wallet = get(wallet)
if (!$wallet) {
throw new Error("No wallet is connected")
}
if ($wallet.type === "nwc") {
return new nwc.NWCClient($wallet.info).payInvoice({invoice})
} else if ($wallet.type === "webln") {
return getWebLn()
.enable()
.then(() => getWebLn().sendPayment(invoice))
}
}
+167 -107
View File
@@ -1,30 +1,56 @@
<script lang="ts">
import {onMount} from "svelte"
import {preventDefault} from "@lib/html"
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import {decrypt} from "@welshman/signer"
import {randomInt, parseJson, fromPairs, displayList, TIMEZONE, identity} from "@welshman/lib"
import {
displayRelayUrl,
getTagValue,
getAddress,
THREAD,
MESSAGE,
EVENT_TIME,
COMMENT,
} from "@welshman/util"
import type {Filter} from "@welshman/util"
import type {Nip46ResponseWithResult} from "@welshman/signer"
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 Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import {
GENERAL,
alerts,
getMembershipUrls,
getMembershipRoomsByUrl,
userMembership,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
} from "@app/state"
import {loadAlertStatuses} from "@app/requests"
import {publishAlert} from "@app/commands"
import {loadAlertStatuses, requestRelayClaim} from "@app/requests"
import {publishAlert, attemptAuth} from "@app/commands"
import type {AlertParams} from "@app/commands"
import {platform, platformName, canSendPushNotifications, getPushInfo} from "@app/push"
import {pushToast} from "@app/toast"
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 minute = randomInt(0, 59)
@@ -32,49 +58,22 @@
const WEEKLY = `0 ${minute} ${hour} * * 1`
const DAILY = `0 ${minute} ${hour} * * *`
let loading = false
let cron = WEEKLY
let email = $alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || ""
let relay = ""
let bunker = ""
let secret = ""
let notifyThreads = true
let notifyCalendar = true
let notifyChat = false
let showBunker = false
let loading = $state(false)
let cron = $state(WEEKLY)
let claim = $state("")
let email = $state($alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || "")
const back = () => history.back()
const 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 () => {
if (!email.includes("@")) {
if (channel === "email" && !email.includes("@")) {
return pushToast({
theme: "error",
message: "Please provide an email address",
})
}
if (!relay) {
if (!url) {
return pushToast({
theme: "error",
message: "Please select a space",
@@ -105,22 +104,69 @@
if (notifyChat) {
display.push("chat")
filters.push({
kinds: [MESSAGE],
"#h": [GENERAL, ...getMembershipRoomsByUrl(relay, $userMembership)],
})
filters.push({kinds: [MESSAGE]})
}
loading = true
try {
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
const description = `${cadence} alert for ${displayList(display)} on ${displayRelayUrl(relay)}, sent via email.`
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(relay))
const thunk = await publishAlert({cron, email, feed, bunker, secret, description})
const claims = claim ? {[url]: claim} : {}
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url))
const description = `for ${displayList(display)} on ${displayRelayUrl(url)}`
const params: AlertParams = {feed, claims, description}
await thunk.result
await loadAlertStatuses($pubkey!)
if (channel === "email") {
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
params.description = `${cadence} alert ${description}, sent via email.`
params.email = {
cron,
email,
handler: [
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
}
} else {
try {
// @ts-ignore
params[platform] = await getPushInfo()
params.description = `${platformName} push notification ${description}.`
} catch (e: any) {
return pushToast({
theme: "error",
message: String(e),
})
}
}
// If we don't do this we'll get an event rejection
await attemptAuth(NOTIFIER_RELAY)
const thunk = await publishAlert(params)
const error = await getThunkError(thunk)
if (error) {
return pushToast({
theme: "error",
message: `Failed to send your alert to the notification server (${error}).`,
})
}
// Fetch our new status to make sure it's active
const address = getAddress(thunk.event)
const statusEvents = await loadAlertStatuses($pubkey!)
const statusEvent = statusEvents.find(event => getTagValue("d", event.tags) === address)
const statusTags = statusEvent
? parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, statusEvent.content))
: []
const {status = "error", message = "Your alert was not activated"}: Record<string, string> =
fromPairs(statusTags)
if (status === "error") {
return pushToast({theme: "error", message})
}
pushToast({message: "Your alert has been successfully created!"})
back()
@@ -128,6 +174,20 @@
loading = false
}
}
onMount(() => {
if (!canSendPushNotifications()) {
channel = "email"
}
if (url) {
requestRelayClaim(url).then(code => {
if (code) {
claim = code
}
})
}
})
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
@@ -136,13 +196,20 @@
Add an Alert
{/snippet}
</ModalHeader>
{#if showBunker}
<div class="card2 flex flex-col items-center gap-4 bg-base-300">
<p>Scan using a nostr signer, or click to copy.</p>
<BunkerConnect {controller} />
<Button class="btn btn-neutral btn-sm" onclick={hideBunker}>Cancel</Button>
</div>
{:else}
{#if canSendPushNotifications()}
<FieldInline>
{#snippet label()}
<p>Alert Type*</p>
{/snippet}
{#snippet input()}
<select bind:value={channel} class="select select-bordered">
<option value="email">Email Digest</option>
<option value="push">Push Notification</option>
</select>
{/snippet}
</FieldInline>
{/if}
{#if channel === "email"}
<FieldInline>
{#snippet label()}
<p>Email Address*</p>
@@ -164,12 +231,14 @@
</select>
{/snippet}
</FieldInline>
{/if}
{#if !hideSpaceField}
<FieldInline>
{#snippet label()}
<p>Space*</p>
{/snippet}
{#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>
{#each getMembershipUrls($userMembership) as url (url)}
<option value={url}>{displayRelayUrl(url)}</option>
@@ -177,59 +246,50 @@
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Notifications*</p>
{/snippet}
{#snippet input()}
<div class="flex items-center justify-end gap-4">
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
Threads
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
Calendar
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
Chat
</span>
</div>
{/snippet}
</FieldInline>
<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}
{/if}
<FieldInline>
{#snippet label()}
<p>Notifications*</p>
{/snippet}
{#snippet input()}
<div class="flex items-center justify-end gap-4">
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
Threads
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
Calendar
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
Chat
</span>
</div>
<p class="text-sm">
Required for receiving alerts about spaces with access controls. You can get one from your
<Button class="text-primary" onclick={() => pushModal(InfoBunker)}>remote signer app</Button
>.
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Invite Code</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={claim} />
</label>
{/snippet}
{#snippet info()}
<p>
To get notifications from private spaces, please provide an invite code which grants access
to the space.
</p>
{#if bunker}
<Button class="btn btn-neutral btn-sm flex-grow" onclick={clearBunker}>Disconnect</Button>
{:else}
<Button class="btn btn-primary btn-sm w-full flex-grow" onclick={connectBunker}
>Connect</Button>
{/if}
</div>
{/if}
{/snippet}
</FieldInline>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || showBunker}>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
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 {pushToast} from "@app/toast"
@@ -12,7 +12,7 @@
const {alert}: Props = $props()
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!"})
history.back()
}
+8 -9
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import {parseJson, nthEq} from "@welshman/lib"
import {parseJson} from "@welshman/lib"
import {displayFeeds} from "@welshman/feeds"
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state"
import {alertStatuses} from "@app/state"
import {deriveAlertStatus} from "@app/state"
import {pushModal} from "@app/modal"
type Props = {
@@ -15,8 +15,7 @@
const {alert}: Props = $props()
const address = $derived(getAddress(alert.event))
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const status = deriveAlertStatus(getAddress(alert.event))
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags))
@@ -39,24 +38,24 @@
</Button>
<div class="flex-inline gap-1">{description}</div>
</div>
{#if status}
{@const statusText = getTagValue("status", status.tags) || "error"}
{#if $status}
{@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", status.tags)}>
data-tip={getTagValue("message", $status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", status.tags)}>
data-tip={getTagValue("message", $status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", status.tags)}>
data-tip={getTagValue("message", $status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
+15 -10
View File
@@ -1,20 +1,25 @@
<script lang="ts">
import {onMount} from "svelte"
import {pubkey} from "@welshman/app"
import {getTagValue} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte"
import {loadAlertStatuses, loadAlerts} from "@app/requests"
import {pushModal} from "@app/modal"
import {alerts} from "@app/state"
const startAlert = () => pushModal(AlertAdd)
type Props = {
url?: string
channel?: string
hideSpaceField?: boolean
}
onMount(() => {
loadAlertStatuses($pubkey!)
loadAlerts($pubkey!)
})
const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
const filteredAlerts = $derived(
url ? $alerts.filter(a => getTagValue("feed", a.tags)?.includes(url)) : $alerts,
)
</script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
@@ -29,10 +34,10 @@
</Button>
</div>
<div class="col-4">
{#each $alerts as alert (alert.event.id)}
{#each filteredAlerts as alert (alert.event.id)}
<AlertItem {alert} />
{: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}
</div>
</div>
+3 -4
View File
@@ -2,7 +2,7 @@
import type {Snippet} from "svelte"
import {writable} from "svelte/store"
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 {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
@@ -13,7 +13,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.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 {pushToast} from "@app/toast"
@@ -63,7 +63,7 @@
}
const ed = await editor
const event = createEvent(EVENT_TIME, {
const event = makeEvent(EVENT_TIME, {
content: ed.getText({blockSeparator: "\n"}).trim(),
tags: [
["d", initialValues?.d || randomId()],
@@ -73,7 +73,6 @@
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
PROTECTED,
],
})
+8 -5
View File
@@ -11,23 +11,23 @@
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ChannelMessageZapButton from "@app/components/ChannelMessageZapButton.svelte"
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
import {colors} from "@app/state"
import {colors, ENABLE_ZAPS} from "@app/state"
import {publishDelete, publishReaction} from "@app/commands"
import {pushModal} from "@app/modal"
interface Props {
url: string
room: string
event: TrustedEvent
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
inert?: boolean
}
const {url, room, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const thunk = $thunks[event.id]
const today = formatTimestampAsDate(now())
@@ -76,7 +76,7 @@
</div>
{/if}
<div class="text-sm">
<Content {event} {url} />
<Content minimalQuote {event} {url} />
{#if thunk}
<ThunkStatus {thunk} class="mt-2" />
{/if}
@@ -95,7 +95,10 @@
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
<ChannelMessageEmojiButton {url} {room} {event} />
{#if ENABLE_ZAPS}
<ChannelMessageZapButton {url} {event} />
{/if}
<ChannelMessageEmojiButton {url} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon="reply" size={4} />
@@ -1,14 +1,10 @@
<script lang="ts">
import {noop} from "@welshman/lib"
import type {NativeEmoji} from "emoji-picker-element/shared"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import Icon from "@lib/components/Icon.svelte"
import {publishReaction} from "@app/commands"
const {url, room, event} = $props()
// Tell svelte-check to shut up
noop(room)
const {url, event} = $props()
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, relays: [url], content: emoji.unicode})
@@ -5,8 +5,10 @@
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {ENABLE_ZAPS} from "@app/state"
import {publishReaction} from "@app/commands"
import {pushModal} from "@app/modal"
@@ -40,6 +42,12 @@
<Icon size={4} icon="smile-circle" />
Send Reaction
</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}>
<Icon size={4} icon="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>
+2 -6
View File
@@ -1,11 +1,7 @@
<script lang="ts">
import {GENERAL, channelsById, makeChannelId} from "@app/state"
import {channelsById, makeChannelId} from "@app/state"
const {url, room} = $props()
</script>
{#if room === GENERAL}
general
{:else}
{$channelsById.get(makeChannelId(url, room))?.name || room}
{/if}
{$channelsById.get(makeChannelId(url, room))?.name || room}
+69 -11
View File
@@ -1,9 +1,28 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {onMount} from "svelte"
import {int, nthNe, MINUTE, sortBy, remove, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
import {
int,
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 {
pubkey,
tagPubkey,
@@ -61,14 +80,53 @@
}
const onSubmit = async (params: EventContent) => {
// Remove p tags since they result in forking the conversation
const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)]
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
await sendWrapped({
pubkeys,
template: createEvent(DIRECT_MESSAGE, prependParent(parent, {...params, tags})),
delay: $userSettingValues.send_delay,
})
// Remove p tags since they result in forking the conversation
params.tags = params.tags.filter(nthNe(0, "p"))
// 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()
}
@@ -191,7 +249,7 @@
{/snippet}
</PageBar>
<PageContent class="flex flex-col-reverse pt-4">
<PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div>
{#if missingInboxes.includes($pubkey!)}
<div class="py-12">
+13 -1
View File
@@ -20,6 +20,8 @@
export const focus = () => editor.then(ed => ed.chain().focus().run())
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading) return
@@ -40,11 +42,21 @@
submit,
uploading,
aggressive: true,
disableFileUpload: true,
})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<Button
data-tip="Add an image"
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
disabled={$uploading}
onclick={uploadFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
{/if}
</Button>
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
+1 -1
View File
@@ -22,7 +22,7 @@
const {...props}: Props = $props()
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)
onMount(() => {
+35 -1
View File
@@ -1,5 +1,10 @@
<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 {tryCatch, uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util"
import {pubkey} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte"
@@ -14,7 +19,36 @@
const onSubmit = () => goto(makeChatPath([...pubkeys, $pubkey!]))
const addPubkey = (pubkey: string) => {
pubkeys = uniq([...pubkeys, pubkey])
term.set("")
}
const term = writable("")
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>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
@@ -28,7 +62,7 @@
</ModalHeader>
<Field>
{#snippet input()}
<ProfileMultiSelect autofocus bind:value={pubkeys} />
<ProfileMultiSelect autofocus bind:value={pubkeys} {term} />
{/snippet}
</Field>
<ModalFooter>
+35
View File
@@ -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>
+9 -1
View File
@@ -40,6 +40,7 @@
showEntire?: boolean
hideMediaAtDepth?: number
expandMode?: string
minimalQuote?: boolean
depth?: number
url?: string
}
@@ -51,6 +52,7 @@
showEntire = $bindable(false),
hideMediaAtDepth = 1,
expandMode = "block",
minimalQuote = false,
depth = 0,
url,
}: Props = $props()
@@ -153,7 +155,13 @@
<ContentMention value={parsed.value} {url} />
{:else if isEvent(parsed) || isAddress(parsed)}
{#if isBlock(i)}
<ContentQuote {depth} {url} {hideMediaAtDepth} value={parsed.value} {event} />
<ContentQuote
{depth}
{url}
{hideMediaAtDepth}
value={parsed.value}
{event}
minimal={minimalQuote} />
{:else}
<Link
external
+8 -10
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {ellipsize, postJson} from "@welshman/lib"
import {ellipsize, displayUrl, postJson} from "@welshman/lib"
import {dufflepud, imgproxy} from "@app/state"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
@@ -31,7 +31,7 @@
</script>
<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)$/)}
<video controls src={url} class="max-h-96 object-contain object-center">
<track kind="captions" />
@@ -52,15 +52,13 @@
alt="Link preview"
onerror={onError}
src={imgproxy(preview.image)}
class="bg-alt max-h-72 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>
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/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>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
+34 -31
View File
@@ -1,49 +1,52 @@
<script lang="ts">
import {onDestroy} from "svelte"
import {now} from "@welshman/lib"
import {BLOSSOM_AUTH, makeEvent, getTags, getTagValue, tagsFromIMeta} from "@welshman/util"
import {signer} from "@welshman/app"
import {onMount, onDestroy} from "svelte"
import {displayUrl} from "@welshman/lib"
import {getTags, decryptFile, getTagValue, tagsFromIMeta} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import {imgproxy} from "@app/state"
const {value, event, ...props} = $props()
const url = value.url.toString()
// If we fail to fetch the image, try authenticating if we have a blossom hash
const onerror = async () => {
const meta = getTags("imeta", event.tags)
const meta =
getTags("imeta", event.tags)
.map(tagsFromIMeta)
.find(meta => getTagValue("url", meta) === url)
const hash = meta ? getTagValue("x", meta) : undefined
.find(meta => getTagValue("url", meta) === url) || event.tags
if (hash && $signer) {
const event = await signer.get().sign(
makeEvent(BLOSSOM_AUTH, {
tags: [
["t", "get"],
["x", hash],
["expiration", String(now() + 30)],
],
}),
)
const key = getTagValue("decryption-key", meta)
const nonce = getTagValue("decryption-nonce", meta)
const algorithm = getTagValue("encryption-algorithm", meta)
const res = await fetch(url, {
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
},
})
if (res.status === 200) {
src = URL.createObjectURL(await res.blob())
}
}
const onError = () => {
hasError = true
}
let hasError = $state(false)
let src = $state(imgproxy(url))
onMount(async () => {
if (algorithm === "aes-gcm" && key && nonce) {
const response = await fetch(url)
if (response.ok) {
const ciphertext = new Uint8Array(await response.arrayBuffer())
const decryptedData = await decryptFile({ciphertext, key, nonce, algorithm})
src = URL.createObjectURL(new Blob([decryptedData]))
}
}
})
onDestroy(() => {
URL.revokeObjectURL(src)
})
</script>
<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}
+3 -4
View File
@@ -1,8 +1,7 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import type {ProfilePointer} from "@welshman/content"
import {displayProfile} from "@welshman/util"
import {deriveProfile} from "@welshman/app"
import {deriveProfileDisplay} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
@@ -14,11 +13,11 @@
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})
</script>
<Button onclick={openProfile} class="link-content">
@{displayProfile($profile)}
@{$display}
</Button>
+19 -61
View File
@@ -1,18 +1,14 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {nthEq} from "@welshman/lib"
import {Router} from "@welshman/router"
import {tracker, repository} from "@welshman/app"
import type {TrustedEvent} from "@welshman/util"
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD, EVENT_TIME} from "@welshman/util"
import {scrollToEvent} from "@lib/html"
import {Address, MESSAGE} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import {deriveEvent, entityLink, ROOM} from "@app/state"
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
import {deriveEvent, entityLink} from "@app/state"
import {goToEvent} from "@app/routes"
type Props = {
value: any
@@ -20,9 +16,10 @@
event: TrustedEvent
depth: number
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 idOrAddress = id || new Address(kind, pubkey, identifier).toString()
@@ -37,67 +34,28 @@
? nip19.neventEncode({id, relays: mergedRelays})
: 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 = () => {
if ($quote) {
if ($quote.kind === DIRECT_MESSAGE) {
return scrollToEvent($quote.id)
}
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)
}
}
}
goToEvent($quote)
} else {
window.open(entityLink(entity))
}
window.open(entityLink(entity))
}
</script>
<Button class="my-2 block max-w-full text-left" {onclick}>
{#if $quote}
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
</NoteCard>
{#if minimal && $quote.kind === MESSAGE}
<div
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}
<div class="rounded-box p-4">
<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>
+9 -1
View File
@@ -6,18 +6,21 @@
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import EventMenu from "@app/components/EventMenu.svelte"
import {ENABLE_ZAPS} from "@app/state"
import {publishReaction} from "@app/commands"
type Props = {
url: string
noun: string
event: TrustedEvent
hideZap?: boolean
customActions?: Snippet
}
const {url, noun, event, customActions}: Props = $props()
const {url, noun, event, hideZap, customActions}: Props = $props()
const showPopover = () => popover?.show()
@@ -30,6 +33,11 @@
</script>
<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">
<Icon icon="smile-circle" size={4} />
</EmojiButton>
+2 -2
View File
@@ -8,7 +8,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {publishComment} from "@app/commands"
import {tagRoom, GENERAL, PROTECTED} from "@app/state"
import {PROTECTED} from "@app/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
@@ -23,7 +23,7 @@
const ed = await editor
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) {
return pushToast({
+35
View File
@@ -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>
+146
View File
@@ -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>
+36
View File
@@ -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>
+49
View File
@@ -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>
+36
View File
@@ -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>
+20 -11
View File
@@ -33,18 +33,20 @@
const onSubmit = async () => {
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 {
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 broker = new Nip46Broker({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS)
@@ -64,6 +66,13 @@
message: "Something went wrong, please try again!",
})
}
} catch (e) {
console.error(e)
return pushToast({
theme: "error",
message: "Something went wrong, please try again!",
})
} finally {
controller.loading = false
}
+71 -27
View File
@@ -1,6 +1,7 @@
<script lang="ts">
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 Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -12,14 +13,19 @@
import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.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 MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
import {
ENABLE_ZAPS,
userRoomsByUrl,
hasMembershipUrl,
memberships,
deriveUserRooms,
deriveOtherRooms,
hasNip29,
alerts,
} from "@app/state"
import {notifications} from "@app/notifications"
import {pushModal} from "@app/modal"
@@ -27,10 +33,14 @@
const {url} = $props()
const relay = deriveRelay(url)
const chatPath = makeSpacePath(url, "chat")
const goalsPath = makeSpacePath(url, "goals")
const threadsPath = makeSpacePath(url, "threads")
const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url)))
const openMenu = () => {
showMenu = true
@@ -55,6 +65,13 @@
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 replaceState = $state(false)
let element: Element | undefined = $state()
@@ -68,18 +85,20 @@
})
</script>
<div bind:this={element}>
<SecondaryNavSection class="max-h-screen">
<div bind:this={element} class="flex h-full flex-col justify-between">
<SecondaryNavSection>
<div>
<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" />
</SecondaryNavItem>
{#if showMenu}
<Popover hideOnClick onClose={toggleMenu}>
<ul
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>
<Button onclick={showMembers}>
<Icon icon="user-rounded" />
@@ -109,10 +128,18 @@
</Popover>
{/if}
</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)}>
<Icon icon="home-smile" /> Home
</SecondaryNavItem>
{#if ENABLE_ZAPS}
<SecondaryNavItem
{replaceState}
href={goalsPath}
notification={$notifications.has(goalsPath)}>
<Icon icon="star-fall-minimalistic-2" /> Goals
</SecondaryNavItem>
{/if}
<SecondaryNavItem
{replaceState}
href={threadsPath}
@@ -125,28 +152,45 @@
notification={$notifications.has(calendarPath)}>
<Icon icon="calendar-minimalistic" /> Calendar
</SecondaryNavItem>
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{#each $userRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
{:else}
Rooms
{/if}
</SecondaryNavHeader>
{#if hasNip29($relay)}
{#if $userRooms.length > 0}
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
{: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}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} {url} {room} />
{/each}
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
</div>
</SecondaryNavSection>
<div class="p-4">
<button class="btn btn-neutral btn-sm w-full" onclick={manageAlerts}>
<Icon icon="bell" />
Manage Alerts
</button>
</div>
</div>
+2 -2
View File
@@ -3,7 +3,7 @@
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {makeRoomPath} from "@app/routes"
import {deriveChannel, channelIsLocked} from "@app/state"
import {deriveChannel} from "@app/state"
import {notifications} from "@app/notifications"
interface Props {
@@ -23,7 +23,7 @@
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
{#if channelIsLocked($channel)}
{#if $channel?.closed || $channel?.private}
<Icon icon="lock" size={4} />
{:else}
<Icon icon="hashtag" />
+11 -12
View File
@@ -5,23 +5,22 @@
import CardButton from "@lib/components/CardButton.svelte"
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte"
import {userRoomsByUrl, PLATFORM_RELAY} from "@app/state"
import {userRoomsByUrl, PLATFORM_RELAYS} from "@app/state"
import {pushModal} from "@app/modal"
const addSpace = () => pushModal(SpaceAdd)
</script>
<div class="column menu gap-2">
{#if PLATFORM_RELAY}
<MenuSpacesItem url={PLATFORM_RELAY} />
<Divider />
{:else if $userRoomsByUrl.size > 0}
{#each $userRoomsByUrl.keys() as url (url)}
<MenuSpacesItem {url} />
{/each}
<Divider />
{/if}
{#if !PLATFORM_RELAY}
{#each PLATFORM_RELAYS as url (url)}
<MenuSpacesItem {url} />
{:else}
{#if $userRoomsByUrl.size > 0}
{#each $userRoomsByUrl.keys() as url (url)}
<MenuSpacesItem {url} />
{/each}
<Divider />
{/if}
<Button onclick={addSpace}>
<CardButton>
{#snippet icon()}
@@ -35,5 +34,5 @@
{/snippet}
</CardButton>
</Button>
{/if}
{/each}
</div>
+15 -36
View File
@@ -1,16 +1,11 @@
<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 Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import {makeChatPath} from "@app/routes"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
type Props = {
pubkey: string
@@ -19,37 +14,21 @@
const {pubkey, url}: Props = $props()
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
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(),
})
})
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</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">
<Profile {pubkey} {url} />
<Link class="btn btn-primary hidden sm:flex" href={makeChatPath([pubkey])}>
<Icon icon="letter" />
Start a Chat
</Link>
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
<Icon icon="user-circle" />
View Profile
</Button>
</div>
<ProfileInfo {pubkey} {url} />
{#if $events.length > 0}
<div class="bg-alt badge badge-neutral border-none">
Last active {formatTimestampRelative($events[0].created_at)}
</div>
{/if}
<Link class="btn btn-primary sm:hidden" href={makeChatPath([pubkey])}>
<Icon icon="letter" />
Start a Chat
</Link>
<ProfileBadges {pubkey} {url} />
<Button onclick={openProfile} class="btn btn-primary sm:hidden">
<Icon icon="user-circle" />
View Profile
</Button>
</div>
+16 -9
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {splitAt} from "@welshman/lib"
@@ -12,12 +13,13 @@
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
import MenuSettings from "@app/components/MenuSettings.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 {makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications"
interface Props {
children?: import("svelte").Snippet
type Props = {
children?: Snippet
}
const {children}: Props = $props()
@@ -55,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">
<div class="flex h-full flex-col justify-between">
<div>
{#if PLATFORM_RELAY}
<PrimaryNavItemSpace url={PLATFORM_RELAY} />
{#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} />
{:else}
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
@@ -77,7 +79,7 @@
<PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right">
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
{/each}
</div>
<div>
<PrimaryNavItem
@@ -118,9 +120,14 @@
notification={$notifications.has("/chat")}>
<Avatar icon="letter" class="!h-10 !w-10" />
</PrimaryNavItem>
<PrimaryNavItem title="Spaces" onclick={showSpacesMenu} notification={anySpaceNotifications}>
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem
title="Spaces"
onclick={showSpacesMenu}
notification={anySpaceNotifications}>
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
</div>
<PrimaryNavItem title="Settings" onclick={showSettingsMenu}>
<Avatar icon="settings" src={$userProfile?.picture} class="!h-10 !w-10" />
+25 -16
View File
@@ -1,54 +1,63 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {displayPubkey} from "@welshman/util"
import {
session,
userFollows,
deriveUserWotScore,
deriveHandleForPubkey,
displayHandle,
deriveProfile,
deriveProfileDisplay,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.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 {pushModal} from "@app/modal"
import {clip} from "@app/toast"
type Props = {
pubkey: 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 profile = deriveProfile(pubkey, relays)
const profileDisplay = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
const following = $derived(
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
)
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
</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">
<Avatar src={$profile?.picture} size={10} />
<Avatar src={$profile?.picture} size={avatarSize} />
</Button>
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
</Button>
<WotScore score={$score} active={following} />
</div>
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
<WotScore {pubkey} />
</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>
+58
View File
@@ -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>
+1 -1
View File
@@ -5,7 +5,7 @@
</script>
<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">
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {...props} />
</div>
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {chunk, sleep, uniq} from "@welshman/lib"
import {
createEvent,
makeEvent,
createProfile,
PROFILE,
DELETE,
@@ -36,8 +36,8 @@
}
const chunks = chunk(500, repository.query([{authors: [$pubkey!]}]))
const profileEvent = createEvent(PROFILE, createProfile({name: "[deleted]"}))
const vanishEvent = createEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
const profileEvent = makeEvent(PROFILE, createProfile({name: "[deleted]"}))
const vanishEvent = makeEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
const denominator = chunks.length + 2
const relays = uniq([
...INDEXER_RELAYS,
@@ -75,7 +75,7 @@
}
}
await publishThunk({relays, event: createEvent(DELETE, {tags})})
await publishThunk({relays, event: makeEvent(DELETE, {tags})})
await incrementProgress()
}
+8 -42
View File
@@ -1,23 +1,13 @@
<script lang="ts">
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 Avatar from "@lib/components/Avatar.svelte"
import Link from "@lib/components/Link.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 Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import {canDecrypt, pubkeyLink} from "@app/state"
import {pushModal} from "@app/modal"
@@ -30,41 +20,17 @@
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 chatPath = makeChatPath([pubkey])
const openChat = () => ($canDecrypt ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath}))
const following = $derived(
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
)
</script>
<div class="column gap-4">
<div class="flex max-w-full gap-3">
<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>
<div class="flex flex-col gap-4">
<Profile showPubkey avatarSize={14} {pubkey} {url} />
<ProfileInfo {pubkey} {url} />
<ProfileBadges {pubkey} {url} />
<ModalFooter>
<Button onclick={back} class="hidden md:btn md:btn-link">
<Icon icon="alt-arrow-left" />
@@ -72,8 +38,8 @@
</Button>
<div class="flex gap-2">
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
<Icon icon="user-circle" />
See Complete Profile
<Avatar src="/coracle.png" />
Open in Coracle
</Link>
<Button onclick={openChat} class="btn btn-primary">
<Icon icon="letter" />
+8 -4
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import {nthNe} from "@welshman/lib"
import type {Profile} from "@welshman/util"
import {
getTag,
createEvent,
makeEvent,
makeProfile,
editProfile,
createProfile,
@@ -24,16 +25,19 @@
const back = () => history.back()
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const router = Router.get()
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const relays = [...getMembershipUrls($userMembership)]
const scenarios = [router.FromRelays(getMembershipUrls($userMembership))]
if (shouldBroadcast) {
relays.push(...Router.get().FromUser().getUrls())
scenarios.push(router.FromUser(), router.Index())
template.tags = template.tags.filter(nthNe(0, "-"))
} else {
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})
pushToast({message: "Your profile has been updated!"})
+1 -1
View File
@@ -14,5 +14,5 @@
</script>
{#if $profile}
<Content event={{content: $profile.about, tags: []}} hideMediaAtDepth={0} />
<Content event={{content: $profile.about || "", tags: []}} hideMediaAtDepth={0} />
{/if}
+40
View File
@@ -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>
+5 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {preventDefault} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
@@ -8,13 +9,15 @@
type Props = {
pubkey: 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})
</script>
<Button onclick={preventDefault(openProfile)} class="link-content">
<Button onclick={preventDefault(openProfile)} class={cx(props.class, {"link-content": !unstyled})}>
@<ProfileName {pubkey} {url} />
</Button>
+6 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {Writable} from "svelte/store"
import {type Instance} from "tippy.js"
import {append, remove, uniq} from "@welshman/lib"
import {profileSearch} from "@welshman/app"
@@ -15,11 +16,10 @@
interface Props {
value: string[]
autofocus?: boolean
term?: Writable<string>
}
let {value = $bindable(), autofocus = false}: Props = $props()
const term = writable("")
let {value = $bindable(), term = writable(""), autofocus = false}: Props = $props()
const search = (term: string) => $profileSearch.searchValues(term)
@@ -44,6 +44,9 @@
let instance: any = $state()
$effect(() => {
// @ts-ignore
oninput?.($term)
if ($term) {
popover?.show()
} else {
+50
View File
@@ -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>
+36 -7
View File
@@ -1,24 +1,27 @@
<script lang="ts">
import {onMount} 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 {
REACTION,
ZAP_RESPONSE,
getReplyFilters,
getEmojiTags,
getEmojiTag,
fromMsats,
getTag,
REPORT,
DELETE,
} from "@welshman/util"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import type {TrustedEvent, EventContent, Zap} from "@welshman/util"
import {deriveEvents, deriveEventsMapped} from "@welshman/store"
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 Icon from "@lib/components/Icon.svelte"
import Reaction from "@app/components/Reaction.svelte"
import EventReportDetails from "@app/components/EventReportDetails.svelte"
import {REACTION_KINDS} from "@app/state"
import {pushModal} from "@app/modal"
interface Props {
@@ -49,6 +52,12 @@
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 reaction = events.find(e => e.pubkey === $pubkey)
@@ -77,6 +86,8 @@
),
)
const groupedZaps = $derived(groupBy(e => getReactionKey(e.request), $zaps))
onMount(() => {
const controller = new AbortController()
@@ -84,7 +95,7 @@
load({
relays: [url],
signal: controller.signal,
filters: getReplyFilters([event], {kinds: [REACTION, REPORT, DELETE]}),
filters: getReplyFilters([event], {kinds: [REPORT, DELETE, ...REACTION_KINDS]}),
onEvent: batch(300, (events: TrustedEvent[]) => {
load({
relays: [url],
@@ -100,12 +111,12 @@
})
</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">
{#if url && $reports.length > 0}
<button
type="button"
data-tip="{`This content has been reported as "${displayList(reportReasons)}".`}}"
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
class="btn btn-error btn-xs tooltip-right flex items-center gap-1 rounded-full"
class:tooltip={!noTooltip && !isMobile}
onclick={stopPropagation(preventDefault(onReportClick))}>
@@ -113,6 +124,24 @@
<span>{$reports.length}</span>
</button>
{/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]}
{@const pubkeys = events.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
+43 -36
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {randomId} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import {uniqBy, nth} from "@welshman/lib"
import {displayRelayUrl, makeRoomMeta} from "@welshman/util"
import {deriveRelay, getThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -10,41 +10,41 @@
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {hasNip29} from "@app/state"
import {addRoomMembership, nip29, getThunkError} from "@app/commands"
import {hasNip29, loadChannel} from "@app/state"
import {makeSpacePath} from "@app/routes"
import {pushToast} from "@app/toast"
const {url} = $props()
const room = randomId()
const room = makeRoomMeta()
const relay = deriveRelay(url)
const back = () => history.back()
const tryCreate = async () => {
if (hasNip29($relay)) {
const createMessage = await getThunkError(nip29.createRoom(url, room))
room.tags = uniqBy(nth(0), [...room.tags, ["name", name]])
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
return pushToast({theme: "error", message: createMessage})
}
const createMessage = await getThunkError(createRoom(url, room))
const editMessage = await getThunkError(nip29.editMeta(url, room, {name}))
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})
}
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
return pushToast({theme: "error", message: createMessage})
}
addRoomMembership(url, room, name)
goto(makeSpacePath(url, room))
const editMessage = await getThunkError(editRoom(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 () => {
@@ -72,23 +72,30 @@
</div>
{/snippet}
</ModalHeader>
<Field>
{#snippet label()}
<p>Room Name</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="hashtag" />
<input bind:value={name} class="grow" type="text" />
</label>
{/snippet}
</Field>
{#if hasNip29($relay)}
<Field>
{#snippet label()}
<p>Room Name</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="hashtag" />
<input bind:value={name} class="grow" type="text" />
</label>
{/snippet}
</Field>
{:else}
<p class="bg-alt card2 row-2">
<Icon icon="danger" />
This relay does not support creating rooms.
</p>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</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>
<Icon icon="alt-arrow-right" />
</Button>
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@noble/hashes/utils"
import {hexToBytes} from "@welshman/lib"
import {makeSecret} from "@welshman/signer"
import {preventDefault, downloadText} from "@lib/html"
import Link from "@lib/components/Link.svelte"
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
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 Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
@@ -18,7 +18,7 @@
}
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 : []
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>
+6 -32
View File
@@ -1,49 +1,23 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {parse, renderAsHtml} from "@welshman/content"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {preventDefault} from "@lib/html"
import {ucFirst} from "@lib/util"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast"
import {clearModals} from "@app/modal"
import {attemptRelayAccess} from "@app/commands"
import SpaceAccessRequest from "@app/components/SpaceAccessRequest.svelte"
import {pushModal} from "@app/modal"
const {url, error} = $props()
const back = () => history.back()
const joinRelay = async () => {
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)
const requestAccess = () => pushModal(SpaceAccessRequest, {url})
</script>
<form class="column gap-4" onsubmit={preventDefault(join)}>
<form class="column gap-4" onsubmit={preventDefault(requestAccess)}>
<ModalHeader>
{#snippet title()}
<div>Access Error</div>
@@ -63,8 +37,8 @@
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Request Access</Spinner>
<Button type="submit" class="btn btn-primary">
Request Access
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
@@ -28,13 +28,19 @@
{/snippet}
</ModalHeader>
<p>
<Link class="text-primary" external href="https://relay.tools">relay.tools</Link> is a third-party
service that allows anyone to run their own relay for use with {PLATFORM_NAME}, or any other
<Link class="link" external href="https://relay.tools">relay.tools</Link> is a third-party service
that allows anyone to run their own relay for use with {PLATFORM_NAME}, or any other
nostr-compatible app.
</p>
<p>
Once you've created a relay of your own, come back here to link {PLATFORM_NAME} with your new relay.
</p>
<p>
Alternatively, you can
<Link external class="link" href="https://github.com/coracle-social/frith"
>run your own community relay</Link
>.
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
+42 -12
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {tryCatch} from "@welshman/lib"
import {tryCatch, first, removeNil} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {Pool, AuthStatus} from "@welshman/net"
import {preventDefault} from "@lib/html"
@@ -17,21 +17,36 @@
const back = () => history.back()
const joinRelay = async (invite: string) => {
const [raw, claim] = invite.split("|")
const url = normalizeRelayUrl(raw)
const error = await attemptRelayAccess(url, claim)
const joinRelay = async () => {
const promises: Promise<string | undefined>[] = []
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) {
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) {
pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
pushModal(SpaceJoinConfirm, {url: normalizedUrl}, {replaceState: true})
} else {
await confirmSpaceJoin(url)
await confirmSpaceJoin(normalizedUrl)
}
}
@@ -39,13 +54,14 @@
loading = true
try {
await joinRelay(url)
await joinRelay()
} finally {
loading = false
}
}
let url = $state("")
let claim = $state("")
let loading = $state(false)
const linkIsValid = $derived(
@@ -59,12 +75,12 @@
<div>Join a Space</div>
{/snippet}
{#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}
</ModalHeader>
<Field>
{#snippet label()}
<p>Invite code*</p>
<p>Relay URL*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
@@ -74,11 +90,25 @@
{/snippet}
{#snippet info()}
<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>
</p>
{/snippet}
</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>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
+2 -1
View File
@@ -7,7 +7,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {clearModals} from "@app/modal"
import {addSpaceMembership} from "@app/commands"
import {addSpaceMembership, broadcastUserData} from "@app/commands"
const {url} = $props()
@@ -16,6 +16,7 @@
const tryJoin = async () => {
await addSpaceMembership(url)
broadcastUserData([url])
clearModals()
}
+15 -2
View File
@@ -1,13 +1,26 @@
<script module lang="ts">
import {goto} from "$app/navigation"
import {ROOM_META} from "@welshman/util"
import {load} from "@welshman/net"
import {makeSpacePath} from "@app/routes"
import {addSpaceMembership} from "@app/commands"
import {addSpaceMembership, broadcastUserData} from "@app/commands"
import {pushToast} from "@app/toast"
export const confirmSpaceJoin = async (url: string) => {
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({
message: "Welcome to the space!",
+122
View File
@@ -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>
+4 -9
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {writable} from "svelte/store"
import {createEvent, THREAD} from "@welshman/util"
import {makeEvent, THREAD} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
@@ -10,7 +10,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {pushToast} from "@app/toast"
import {GENERAL, tagRoom, PROTECTED} from "@app/state"
import {PROTECTED} from "@app/state"
import {makeEditor} from "@app/editor"
const {url} = $props()
@@ -41,16 +41,11 @@
})
}
const tags = [
...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
["title", title],
PROTECTED,
]
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title], PROTECTED]
publishThunk({
relays: [url],
event: createEvent(THREAD, {content, tags}),
event: makeEvent(THREAD, {content, tags}),
})
history.back()
+10 -7
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {nth} from "@welshman/lib"
import {stopPropagation} from "svelte/legacy"
import {nth, noop} from "@welshman/lib"
import {PublishStatus} from "@welshman/net"
import {
MergedThunk,
@@ -60,9 +61,11 @@
{@const url = failedUrls[0]}
{@const status = $thunk.status[url]}
{@const message = $thunk.details[url]}
<div class="flex justify-end px-1 text-xs {restProps.class}">
<button
class="flex w-full justify-end px-1 text-xs {restProps.class}"
onclick={stopPropagation(noop)}>
<Tippy
class="flex items-center {restProps.class}"
class="flex items-center"
component={ThunkStatusDetail}
props={{url, message, status, retry}}
params={{interactive: true}}>
@@ -73,10 +76,10 @@
</span>
{/snippet}
</Tippy>
</div>
</button>
{:else if showPending}
<div class="flex justify-end px-1 text-xs {restProps.class}">
<span class="flex items-center gap-1 {restProps.class}">
<div class="flex w-full justify-end px-1 text-xs {restProps.class}">
<span class="flex items-center gap-1">
<span class="loading loading-spinner mx-1 h-3 w-3 translate-y-px"></span>
<span class="opacity-50">Sending...</span>
<button
@@ -84,7 +87,7 @@
class="underline transition-all"
class:link={canCancel}
class:opacity-25={!canCancel}
onclick={abort}>
onclick={stopPropagation(abort)}>
Cancel
</button>
</span>
+173
View File
@@ -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">
import {clamp} from "@welshman/lib"
import {pubkey, getFollows, deriveUserWotScore} from "@welshman/app"
interface Props {
score: any
max?: number
active?: boolean
pubkey: string
}
const {score, max = 100, active = false}: Props = $props()
const {pubkey: target}: Props = $props()
const max = 100
const radius = 6
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 style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`)
const stroke = $derived(active ? "var(--primary)" : "var(--base-content)")
+166
View File
@@ -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>
+37
View File
@@ -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>
+4 -13
View File
@@ -1,14 +1,8 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {
userFollows,
deriveUserWotScore,
deriveHandleForPubkey,
displayHandle,
deriveProfileDisplay,
} from "@welshman/app"
import WotScore from "@lib/components/WotScore.svelte"
import {displayPubkey} from "@welshman/util"
import {deriveHandleForPubkey, displayHandle, deriveProfileDisplay} from "@welshman/app"
import WotScore from "@app/components/WotScore.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
type Props = {
@@ -21,9 +15,6 @@
const pubkey = value
const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const following = $derived(getPubkeyTagValues(getListTags($userFollows)).includes(pubkey))
</script>
<div class="flex max-w-full gap-3">
@@ -35,7 +26,7 @@
<div class="text-bold overflow-hidden text-ellipsis text-base">
{$profileDisplay}
</div>
<WotScore score={$score} active={following} />
<WotScore {pubkey} />
</div>
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
+88 -68
View File
@@ -1,71 +1,33 @@
import {mount} from "svelte"
import type {Writable} from "svelte/store"
import {get} from "svelte/store"
import type {StampedEvent} from "@welshman/util"
import {makeEvent, getTagValues, getListTags, BLOSSOM_AUTH} from "@welshman/util"
import {simpleCache, normalizeUrl, removeNil, now} from "@welshman/lib"
import {sha256} from "@welshman/lib"
import {
getTagValues,
encryptFile,
uploadBlob,
makeBlossomAuthEvent,
getListTags,
} from "@welshman/util"
import {Router} from "@welshman/router"
import {Nip01Signer} from "@welshman/signer"
import {signer, profileSearch, userBlossomServers} from "@welshman/app"
import type {FileAttributes} from "@welshman/editor"
import {Editor, MentionSuggestion, WelshmanExtension} from "@welshman/editor"
import {makeMentionNodeView} from "./MentionNodeView"
import ProfileSuggestion from "./ProfileSuggestion.svelte"
import {pushToast} from "@app/toast"
export const hasBlossomSupport = simpleCache(async ([url]: [string]) => {
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) => {
export const getBlossomServer = () => {
const userUrls = getTagValues("server", getListTags(userBlossomServers.get()))
const allUrls = removeNil([spaceUrl, ...userUrls])
for (let url of allUrls) {
url = url.replace(/^ws/, "http")
if (await hasBlossomSupport(url)) {
return url
}
for (const url of userUrls) {
return url.replace(/^ws/, "http")
}
return "https://cdn.satellite.earth"
}
export const signWithAssert = async (template: StampedEvent) => {
const event = await signer.get().sign(template)
return event!
}
export const makeEditor = async ({
aggressive = false,
autofocus = false,
@@ -76,7 +38,6 @@ export const makeEditor = async ({
submit,
uploading,
wordCount,
disableFileUpload,
}: {
aggressive?: boolean
autofocus?: boolean
@@ -87,7 +48,6 @@ export const makeEditor = async ({
submit: () => void
uploading?: Writable<boolean>
wordCount?: Writable<number>
disableFileUpload?: boolean
}) => {
return new Editor({
content,
@@ -96,9 +56,6 @@ export const makeEditor = async ({
extensions: [
WelshmanExtension.configure({
submit,
sign: signWithAssert,
defaultUploadType: "blossom",
defaultUploadUrl: await getUploadUrl(url),
extensions: {
placeholder: {
config: {
@@ -110,18 +67,81 @@ export const makeEditor = async ({
aggressive,
},
},
fileUpload: disableFileUpload
? false
: {
config: {
onDrop() {
uploading?.set(true)
},
onComplete() {
uploading?.set(false)
},
},
fileUpload: {
config: {
upload: async (attrs: FileAttributes) => {
let file: Blob = attrs.file
if (!file.type.match("image/(webp|gif)")) {
const {default: Compressor} = await import("compressorjs")
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: {
extend: {
addNodeView: () => makeMentionNodeView(url),
+32 -14
View File
@@ -1,6 +1,6 @@
import {derived} from "svelte/store"
import {synced, throttled} from "@welshman/store"
import {pubkey} from "@welshman/app"
import {synced, localStorageProvider, throttled} from "@welshman/store"
import {pubkey, relaysByUrl} from "@welshman/app"
import {prop, spec, identity, now, groupBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {EVENT_TIME, MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util"
@@ -9,13 +9,18 @@ import {
makeChatPath,
makeThreadPath,
makeCalendarPath,
makeSpaceChatPath,
makeRoomPath,
} from "@app/routes"
import {chats, getUrlsForEvent, userRoomsByUrl, repositoryStore} from "@app/state"
import {chats, hasNip29, getUrlsForEvent, userRoomsByUrl, repositoryStore} from "@app/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))
@@ -26,9 +31,12 @@ export const setChecked = (key: string) => checked.update(state => ({...state, [
export const notifications = derived(
throttled(
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) => {
if (!latestEvent || latestEvent.pubkey === $pubkey) {
return false
@@ -75,8 +83,10 @@ export const notifications = derived(
const spacePath = makeSpacePath(url)
const threadPath = makeThreadPath(url)
const calendarPath = makeCalendarPath(url)
const messagesPath = makeSpaceChatPath(url)
const threadEvents = allThreadEvents.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])) {
paths.add(spacePath)
@@ -114,16 +124,24 @@ export const notifications = derived(
}
}
for (const room of rooms) {
const roomPath = makeRoomPath(url, room)
const latestEvent = allMessageEvents.find(
e =>
$getUrlsForEvent(e.id).includes(url) && e.tags.find(t => t[0] === "h" && t[1] === room),
)
if (hasNip29($relaysByUrl.get(url))) {
for (const room of rooms) {
const roomPath = makeRoomPath(url, room)
const latestEvent = allMessageEvents.find(
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(roomPath)
paths.add(messagesPath)
}
}
}
+131
View File
@@ -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
View File
@@ -13,13 +13,22 @@ import {
sortBy,
assoc,
now,
isNotNil,
filterVals,
fromPairs,
} from "@welshman/lib"
import {
MESSAGE,
DELETE,
THREAD,
EVENT_TIME,
AUTH_INVITE,
COMMENT,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS,
matchFilters,
getTagValues,
getTagValue,
@@ -48,8 +57,6 @@ import {
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
import {
ALERT,
ALERT_STATUS,
NOTIFIER_RELAY,
INDEXER_RELAYS,
getDefaultPubkeys,
@@ -343,7 +350,7 @@ export const makeCalendarFeed = ({
export const loadAlerts = (pubkey: string) =>
load({
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) =>
@@ -359,7 +366,7 @@ export const listenForNotifications = () => {
for (const [url, allRooms] of userRoomsByUrl.get()) {
// 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)
load({
@@ -367,6 +374,7 @@ export const listenForNotifications = () => {
relays: [url],
filters: [
{kinds: [THREAD], limit: 1},
{kinds: [MESSAGE], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
],
@@ -377,6 +385,7 @@ export const listenForNotifications = () => {
relays: [url],
filters: [
{kinds: [THREAD], since: now()},
{kinds: [MESSAGE], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
],
@@ -430,3 +439,18 @@ export const discoverRelays = (lists: List[]) =>
.filter(isShareableRelayUrl)
.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
View File
@@ -1,6 +1,21 @@
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 {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)[]) => {
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 makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
export const makeGoalPath = (url: string, eventId?: string) => makeSpacePath(url, "goals", eventId)
export const makeThreadPath = (url: string, eventId?: string) =>
makeSpacePath(url, "threads", eventId)
@@ -46,3 +65,68 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
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}))
}
+229 -135
View File
@@ -1,8 +1,12 @@
import twColors from "tailwindcss/colors"
import {Capacitor} from "@capacitor/core"
import {get, derived} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {
on,
call,
remove,
uniqBy,
sortBy,
sort,
uniq,
@@ -15,23 +19,43 @@ import {
memoize,
addToMapKey,
identity,
groupBy,
always,
} from "@welshman/lib"
import {load} from "@welshman/net"
import {collection} from "@welshman/store"
import type {Socket} from "@welshman/net"
import {Pool, load, AuthStateEvent, SocketEvent} from "@welshman/net"
import {
collection,
custom,
deriveEvents,
deriveEventsMapped,
withGetter,
synced,
localStorageProvider,
} from "@welshman/store"
import {
getIdFilters,
WRAP,
CLIENT_AUTH,
AUTH_JOIN,
REACTION,
ZAP_REQUEST,
ZAP_RESPONSE,
DIRECT_MESSAGE,
GROUP_META,
DIRECT_MESSAGE_FILE,
ROOM_META,
MESSAGE,
GROUPS,
ROOMS,
THREAD,
COMMENT,
ROOM_JOIN,
ROOM_ADD_USER,
ROOM_REMOVE_USER,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS,
getGroupTags,
getRelayTagValues,
getPubkeyTagValues,
@@ -41,6 +65,9 @@ import {
getListTags,
asDecryptedEvent,
normalizeRelayUrl,
getTag,
getTagValue,
getTagValues,
} from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59, decrypt} from "@welshman/signer"
@@ -65,24 +92,23 @@ import {
appContext,
} 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 ROOM = "h"
export const GENERAL = "_"
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_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 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_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
@@ -111,11 +137,9 @@ export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
export const IMGPROXY_URL = "https://imgproxy.coracle.social"
export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
export const NIP46_PERMS =
"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}`)
.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()) =>
entityLink(nip19.nprofileEncode({pubkey, relays}))
export const tagRoom = (room: string, url: string) => [ROOM, room]
export const getDefaultPubkeys = () => {
const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle(getPubkeyTagValues(getListTags(get(userFollows))))
@@ -284,7 +306,11 @@ routerContext.getIndexerRelays = always(INDEXER_RELAYS)
// Settings
export const canDecrypt = synced("canDecrypt", false)
export const canDecrypt = synced({
key: "canDecrypt",
defaultValue: false,
storage: localStorageProvider,
})
export const SETTINGS = 38489
@@ -296,6 +322,7 @@ export type Settings = {
report_usage: boolean
report_errors: boolean
send_delay: number
font_size: number
}
}
@@ -305,6 +332,7 @@ export const defaultSettings = {
report_usage: true,
report_errors: true,
send_delay: 3000,
font_size: 1,
}
export const settings = deriveEventsMapped<Settings>(repository, {
@@ -327,6 +355,43 @@ export const {
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
export type Alert = {
@@ -334,15 +399,17 @@ export type Alert = {
tags: string[][]
}
export const alerts = deriveEventsMapped<Alert>(repository, {
filters: [{kinds: [ALERT]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
export const alerts = withGetter(
deriveEventsMapped<Alert>(repository, {
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
return {event, tags}
},
})
return {event, tags}
},
}),
)
// Alert Statuses
@@ -351,15 +418,20 @@ export type AlertStatus = {
tags: string[][]
}
export const alertStatuses = deriveEventsMapped<AlertStatus>(repository, {
filters: [{kinds: [ALERT_STATUS]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
export const alertStatuses = withGetter(
deriveEventsMapped<AlertStatus>(repository, {
filters: [{kinds: [ALERT_STATUS]}],
itemToEvent: item => item.event,
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
@@ -388,25 +460,27 @@ export const getMembershipRoomsByUrl = (url: string, list?: List) =>
sort(getGroupTags(getListTags(list)).filter(nthEq(2, url)).map(nth(1)))
export const memberships = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [GROUPS]}],
filters: [{kinds: [ROOMS]}],
itemToEvent: item => item.event,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
export const {
indexStore: membershipByPubkey,
indexStore: membershipsByPubkey,
deriveItem: deriveMembership,
loadItem: loadMembership,
} = collection({
name: "memberships",
store: memberships,
getKey: list => list.event.pubkey,
load: makeOutboxLoader(GROUPS),
load: makeOutboxLoader(ROOMS),
})
// 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 = {
id: string
@@ -474,126 +548,93 @@ export const chatSearch = derived(chats, $chats =>
// Messages
export const messages = derived(
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")
export const messages = deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]})
// Channels
export type ChannelMeta = {
access: "public" | "private"
membership: "open" | "closed"
export type Channel = {
id: string
url: string
room: string
name: string
event: TrustedEvent
closed: boolean
private: boolean
picture?: string
about?: string
}
export type Channel = {
url: string
room: string
name: string
meta?: ChannelMeta
}
export const makeChannelId = (url: string, room: string) => `${url}'${room}`
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(
derived(
[groupMeta, memberships, messages, getUrlsForEvent],
([$groupMeta, $memberships, $messages, $getUrlsForEvent]) => {
const channelsById = new Map<string, Channel>()
export const channelEvents = deriveEvents(repository, {filters: [{kinds: [ROOM_META]}]})
// Add meta using group meta events
for (const event of $groupMeta) {
const meta = fromPairs(event.tags)
const room = meta.d
export const channels = derived(
[channelEvents, getUrlsForEvent],
([$channelEvents, $getUrlsForEvent]) => {
const $channels: Channel[] = []
if (room) {
for (const url of $getUrlsForEvent(event.id)) {
const id = makeChannelId(url, room)
for (const event of $channelEvents) {
const meta = fromPairs(event.tags)
const room = meta.d
channelsById.set(id, {
url,
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)) {
if (room) {
for (const url of $getUrlsForEvent(event.id)) {
const id = makeChannelId(url, room)
if (!channelsById.has(id)) {
channelsById.set(id, {url, room, name})
}
$channels.push({
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
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
},
),
return uniqBy(c => c.id, $channels)
},
)
export const deriveChannel = (url: string, room: string) =>
derived(channelsById, $channelsById => $channelsById.get(makeChannelId(url, room)))
export const channelsByUrl = derived(channels, $channels => groupBy(c => c.url, $channels))
export const channelsByUrl = derived(channelsById, $channelsById => {
const $channelsByUrl = new Map<string, Channel[]>()
export const {
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()) {
pushToMapKey($channelsByUrl, channel.url, channel)
}
return $channelsByUrl
await load({
relays: [url],
filters: [{kinds: [ROOM_META], "#d": [room]}],
})
},
})
export const displayChannel = (url: string, room: string) => {
if (room === GENERAL) {
return "general"
}
export const deriveChannel = (url: string, room: string) => _deriveChannel(makeChannelId(url, room))
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) =>
displayChannel(url, room).toLowerCase()
export const channelIsLocked = (channel?: Channel) =>
channel?.meta?.access === "private" && channel?.meta?.membership === "closed"
// User stuff
export const userSettings = withGetter(
@@ -613,26 +654,28 @@ export const userSettingValues = withGetter(
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingValues.get()[key] as T
export const userMembership = withGetter(
derived([pubkey, membershipByPubkey], ([$pubkey, $membershipByPubkey]) => {
derived([pubkey, membershipsByPubkey], ([$pubkey, $membershipsByPubkey]) => {
if (!$pubkey) return undefined
loadMembership($pubkey)
return $membershipByPubkey.get($pubkey)
return $membershipsByPubkey.get($pubkey)
}),
)
export const userRoomsByUrl = withGetter(
derived(userMembership, $userMembership => {
derived([userMembership, channelsById], ([$userMembership, $channelsById]) => {
const tags = getListTags($userMembership)
const $userRoomsByUrl = new Map<string, Set<string>>()
for (const [_, room, url] of getGroupTags(tags)) {
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
for (const url of getRelayTagValues(tags)) {
$userRoomsByUrl.set(normalizeRelayUrl(url), new Set())
}
for (const url of getRelayTagValues(tags)) {
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), GENERAL)
for (const [_, room, url] of getGroupTags(tags)) {
if ($channelsById.has(makeChannelId(url, room))) {
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
}
}
return $userRoomsByUrl
@@ -641,7 +684,7 @@ export const userRoomsByUrl = withGetter(
export const deriveUserRooms = (url: string) =>
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) =>
@@ -652,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
export const encodeRelay = (url: string) =>
@@ -668,3 +746,19 @@ export const displayReaction = (content: string) => {
if (content === "-") return "👎"
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
View File
@@ -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,
})

Some files were not shown because too many files have changed in this diff Show More