Compare commits

...

118 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
Build and Publish Docker Image / build-and-push (push) Failing after 9s
2025-06-09 15:15:58 -07:00
Jon Staab ae00eb0b9c Bump welshman and nostr-editor
Build and Publish Docker Image / build-and-push (push) Failing after 2m15s
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
Jon Staab 1ced5689c3 Bump version 2025-05-12 15:19:38 -07:00
Jon Staab 263a803875 Add custom emoji parsing and display 2025-05-12 15:10:24 -07:00
Jon Staab 58afb8fa0c Bump editor 2025-05-12 11:17:13 -07:00
Jon Staab 4aaa19ea1b Apply theme to body so popovers get themed too, make selected popover item more clear 2025-05-12 10:03:29 -07:00
Jon Staab 2f9010cd13 Ignore unnecessary error 2025-05-12 09:01:13 -07:00
Jon Staab 12fcdfcd4f Add light theme and secondary color 2025-05-12 08:48:54 -07:00
Jon Staab 317ab57ed2 Use env instead of env.local 2025-05-12 08:27:46 -07:00
Jon Staab 52ef67740a Move default env to env.template, fix notifier relay/pubkey 2025-05-12 08:27:07 -07:00
Jon Staab 68ebd32e15 Bump welshman 2025-05-09 12:41:02 -07:00
Jon Staab e94aa3c119 Bump version, fix new messages thing 2025-05-09 12:26:05 -07:00
Jon Staab 4d10fe7cc0 Handle broken supported_nips 2025-05-08 11:16:02 -07:00
Jon Staab 841928783b Re-introduce safe inset areas 2025-05-08 11:05:27 -07:00
Jon Staab 6e5e1a0846 Remove safe area inset stuff to re-apply later 2025-05-08 09:11:10 -07:00
Jon Staab d57f4747a6 Tweak errors so that actionable links are rendered 2025-05-07 15:04:35 -07:00
Jon Staab 94a0077b09 Use non-singleton broker 2025-05-07 13:53:58 -07:00
Jon Staab f2eb04adff Bump version 2025-05-07 09:12:17 -07:00
Jon Staab d4d5979a35 Fix missing room images and room overflow in nav 2025-05-07 09:11:00 -07:00
Jon Staab dde6e54657 Add build in production script 2025-05-06 18:26:48 -07:00
Jon Staab 698a7513b8 Tweak some gradle stuff 2025-05-06 18:07:30 -07:00
173 changed files with 5825 additions and 1634 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
android
ios
build
+6 -2
View File
@@ -5,10 +5,14 @@ VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/flotilla.png VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_RELAY= VITE_PLATFORM_RELAYS=
VITE_PLATFORM_ACCENT="#7161FF" VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities." VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/ VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/,wss://indexer.coracle.social/
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/ VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_GLITCHTIP_API_KEY= VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN= GLITCHTIP_AUTH_TOKEN=
+1 -1
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
+2 -1
View File
@@ -1,5 +1,5 @@
# Env # Env
.env.local .env
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
@@ -60,6 +60,7 @@ google-services.json
GoogleService-Info.plist GoogleService-Info.plist
# IDEs and editors # IDEs and editors
.roo
.idea/ .idea/
.vscode/ .vscode/
+6
View File
@@ -1,2 +1,8 @@
pnpm run lint pnpm run lint
pnpm run check pnpm run check
if [[ ! -z $(cat package.json | grep 'link:') ]]; then
echo "Some packages are linked to local files!"
exit 1
fi
+68
View File
@@ -1,5 +1,73 @@
# Changelog # Changelog
# 1.2.2
* Fix phantom chat notifications
* Fix zaps on mobile
# 1.2.1
* Add zaps to chat, threads, and events
* Add funding goals
* Add NWC support
* Add wallet settings page
* Handle invalid bunker url
* Fix sidebar overflow
* Fix profile npub display
# 1.2.0
* Fix sort order of thread comments
* Fix link display when no title is available
* Fix making profiles non-protected
* Replace bunker url with relay claims for notifier auth
* Add push notifications on all platforms
* Add "mark all as read" on desktop
* Re-design space dashboard
# 1.1.1
* Add chat quick link
# 1.1.0
* Add better theming support
* Improve forms for entering invite codes
* Rely more heavily on NIP 29 for rooms
* Support multiple platform relays
* Remove default general room
* Remove room tag from threads/calendars
* Show pubkey on profile detail
* Support pasting pubkey into chat start dialog
* Add minimal style for quoted messages
# 1.0.4
* Fix thunk status click handler
* Remove duplicate dependencies
* Improve navigation on white-labeled instances
* Add setting for font size
# 1.0.3
* Add light theme
* Use correct alerts server
* Ignore relay errors for claims
* Fix inline code blocks
* Add custom emoji parsing and display
# 1.0.2
* Fix add relay button
* Fix safe inset areas
* Better rendering for errors from relays
* Improve remote signer login
# 1.0.1
* Fix relay images in nav
* Fix relay nav overflow
# 1.0.0 # 1.0.0
* Add alerts via Anchor * Add alerts via Anchor
+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"]
+16 -79
View File
@@ -4,23 +4,15 @@ A discord-like nostr client based on the idea of "relays as groups".
If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797 If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797
# Deploy
To run your own Flotilla, it's as simple as:
- `pnpm install`
- `pnpm run build`
- `npx serve build`
## Environment ## Environment
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env` for examples): You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust. - `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust.
- `VITE_PLATFORM_URL` - The url where the app will be hosted. This is only used for build-time population of meta tags. - `VITE_PLATFORM_URL` - The url where the app will be hosted. This is only used for build-time population of meta tags.
- `VITE_PLATFORM_NAME` - The name of the app - `VITE_PLATFORM_NAME` - The name of the app
- `VITE_PLATFORM_LOGO` - A logo url for the app - `VITE_PLATFORM_LOGO` - A logo url for the app
- `VITE_PLATFORM_RELAY` - A relay url that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the platform relay the home page. - `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color - `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
- `VITE_PLATFORM_DESCRIPTION` - A description of the app - `VITE_PLATFORM_DESCRIPTION` - A description of the app
- `VITE_GLITCHTIP_API_KEY` - A Sentry DSN for use with glitchtip (error reporting) - `VITE_GLITCHTIP_API_KEY` - A Sentry DSN for use with glitchtip (error reporting)
@@ -28,84 +20,29 @@ You can also optionally create an `.env.local` file and populate it with the fol
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer. If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
## Nginx/TLS (optional) ## Development
If you'd like to set up flotilla on a server you control, you'll want to set up a reverse proxy and provision a TSL certificate for the domain you'll be using. You should also make sure to add swap to your server. Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, a pre-commit hook will run to lint and typecheck your work.
There will be some parts of the following templates, for example `<SERVER NAME>`, which you'll need to fill in before running the code. ## Deployment
First, create an `A` record with your DNS provider pointing to the IP of your server. This will allow certbot to create your certificate later. To run your own Flotilla, it's as simple as:
Next install `nginx`, `git`, and `certbot`. If you're on a debian- or ubuntu-based distro, run `sudo apt-get update && sudo apt-get install nginx git certbot python3-certbot-nginx`.
Now, create a new user where your code will be stored, clone the repository, fill in your `.env.local` file, and build the app.
```sh ```sh
# Replace with your password pnpm install
PASSWORD=<YOUR PASSWORD HERE> pnpm run build
npx serve build
# Add the user and set a password
adduser flotilla
echo flotilla:$PASSWORD | chpasswd
# Login as flotilla
sudo su flotilla
# Go to flotilla's home directory
cd ~
# Install nvm, yarn, clone repos
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# Update PATH
. ~/.bashrc
# Clone repository and install dependencies
git clone https://github.com/coracle-social/flotilla.git
cd ~/flotilla
nvm install
nvm use
pnpm i
# Optionally create and populate .env.local to suit your use case
# Build the app
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
# Exit back to root
exit
``` ```
Once you've exited back to root, you can set up nginx. Place the following in a file named after your domain in the `/etc/nginx/sites-available` directory, for example, `flotilla.example.com`. This should match the `A` record you registered above. Or, if you prefer to use a container:
```conf ```sh
server { podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
listen 80;
server_name <SERVER NAME>;
root /home/flotilla/flotilla/build;
index index.html;
location / {
try_files $uri /index.html;
}
}
``` ```
Now you can run `certbot`, which will provision a TLS certificate for your domain and update your nginx configuration. Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
```sh
mkdir ./mount
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
``` ```
certbot --nginx -d <SERVER NAME>
```
Now, enable the site and restart nginx. If you want to be careful, run `nginx -t` before restarting nginx.
```
ln -s /etc/nginx/sites-{available,enabled}/<SERVER NAME>
service nginx restart
```
Now, visit your domain. You should be all set up!
# Development
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, run `pnpm run format && pnpm run lint` and fix any errors that come up.
+4 -4
View File
@@ -5,10 +5,10 @@ android {
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdkVersion rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 14 versionCode 23
versionName "1.0.0" versionName "1.2.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+3
View File
@@ -9,8 +9,11 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-keyboard') implementation project(':capacitor-keyboard')
implementation project(':capacitor-push-notifications')
implementation project(':capawesome-capacitor-badge')
implementation project(':nostr-signer-capacitor-plugin') implementation project(':nostr-signer-capacitor-plugin')
} }
+1
View File
@@ -34,4 +34,5 @@
<!-- Permissions --> <!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest> </manifest>
+9
View File
@@ -2,11 +2,20 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/android/capacitor')
include ':capacitor-community-safe-area'
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area/android')
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
include ':capacitor-keyboard' include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android') project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications/android')
include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin' include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android') project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')
+4 -2
View File
@@ -3,7 +3,9 @@ ext {
compileSdkVersion = 35 compileSdkVersion = 35
targetSdkVersion = 35 targetSdkVersion = 35
androidxActivityVersion = '1.9.2' androidxActivityVersion = '1.9.2'
androidxAppCompatVersion = '1.7.0' //https://github.com/ionic-team/capacitor/issues/7866
// androidxAppCompatVersion = '1.7.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0' androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.15.0' androidxCoreVersion = '1.15.0'
androidxFragmentVersion = '1.8.4' androidxFragmentVersion = '1.8.4'
@@ -13,4 +15,4 @@ ext {
androidxJunitVersion = '1.2.1' androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.1' androidxEspressoCoreVersion = '3.6.1'
cordovaAndroidVersion = '10.1.1' cordovaAndroidVersion = '10.1.1'
} }
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -e
# Fetch tags and set to env vars
git fetch --prune --unshallow --tags || true
git describe --tags --abbrev=0 || true
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
# Install dependencies
CI=0 pnpm i
# Rebuild sharp
pnpm rebuild
# The build runs out of memory at times
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
+4 -4
View File
@@ -2,12 +2,12 @@
temp_env=$(declare -p -x) temp_env=$(declare -p -x)
if [ -f .env ]; then if [ -f .env.template ]; then
source .env source .env.template
fi fi
if [ -f .env.local ]; then if [ -f .env ]; then
source .env.local source .env
fi fi
# Avoid overwriting env vars provided directly # Avoid overwriting env vars provided directly
+5 -1
View File
@@ -15,10 +15,14 @@ const config: CapacitorConfig = {
style: "DARK", style: "DARK",
resizeOnFullScreen: true, resizeOnFullScreen: true,
}, },
Badge: {
persist: true,
autoClear: true
},
}, },
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload // Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// server: { // server: {
// url: "http://192.168.1.250:1847", // url: "http://192.168.1.115:1847",
// cleartext: true // cleartext: true
// }, // },
}; };
+8 -4
View File
@@ -18,6 +18,7 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Flotilla Chat.entitlements"; sourceTree = "<group>"; };
1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; }; 1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
@@ -57,6 +58,7 @@
504EC2FB1FED79650016851F = { 504EC2FB1FED79650016851F = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */,
504EC3061FED79650016851F /* App */, 504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */, 504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */, 7F8756D8B27F46E3366F6CEA /* Pods */,
@@ -349,16 +351,17 @@
baseConfigurationReference = 7B9FA71C362B734D9F965709 /* Pods-Flotilla Chat.debug.xcconfig */; baseConfigurationReference = 7B9FA71C362B734D9F965709 /* Pods-Flotilla Chat.debug.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8; CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.2.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -374,16 +377,17 @@
baseConfigurationReference = 1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */; baseConfigurationReference = 1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8; CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.2.2;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
+9
View File
@@ -46,4 +46,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
} }
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
// Enable push notifications https://capacitorjs.com/docs/apis/push-notifications
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
// Enable push notifications https://capacitorjs.com/docs/apis/push-notifications
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
} }
+4
View File
@@ -49,5 +49,9 @@
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict> </dict>
</plist> </plist>
+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 def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard' pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin' pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
end end
+25 -31
View File
@@ -1,6 +1,6 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.0.0", "version": "1.2.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -15,6 +15,7 @@
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.26.0",
"@sentry/cli": "^2.40.0", "@sentry/cli": "^2.40.0",
"@sveltejs/kit": "^2.5.27", "@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
@@ -37,32 +38,36 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@capacitor-community/safe-area": "7.0.0-alpha.1",
"@capacitor/android": "^7.0.0", "@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0", "@capacitor/app": "^7.0.0",
"@capacitor/cli": "^7.0.0", "@capacitor/cli": "^7.0.0",
"@capacitor/core": "^7.0.1", "@capacitor/core": "^7.0.1",
"@capacitor/ios": "^7.0.0", "@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0", "@capacitor/keyboard": "^7.0.0",
"@noble/curves": "^1.5.0", "@capacitor/push-notifications": "^7.0.1",
"@noble/hashes": "^1.4.0", "@capawesome/capacitor-badge": "^7.0.1",
"@getalby/sdk": "^5.1.0",
"@poppanator/sveltekit-svg": "^4.2.1", "@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.35.0", "@sentry/browser": "^8.35.0",
"@sveltejs/adapter-static": "^3.0.4", "@sveltejs/adapter-static": "^3.0.4",
"@tiptap/core": "^2.12.0",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6", "@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "^0.2.3", "@welshman/app": "^0.4.0",
"@welshman/content": "^0.2.0", "@welshman/content": "^0.4.0",
"@welshman/dvm": "^0.2.0", "@welshman/editor": "^0.4.0",
"@welshman/editor": "^0.2.0", "@welshman/feeds": "^0.4.0",
"@welshman/feeds": "^0.2.2", "@welshman/lib": "^0.4.0",
"@welshman/lib": "^0.2.1", "@welshman/net": "^0.4.0",
"@welshman/net": "^0.2.2", "@welshman/relay": "^0.4.0",
"@welshman/relay": "^0.2.0", "@welshman/router": "^0.4.0",
"@welshman/router": "^0.2.0", "@welshman/signer": "^0.4.0",
"@welshman/signer": "^0.2.1", "@welshman/store": "^0.4.0",
"@welshman/store": "^0.2.0", "@welshman/util": "^0.4.0",
"@welshman/util": "^0.2.2", "compressorjs": "^1.2.1",
"daisyui": "^4.12.10", "daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0", "date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -71,25 +76,14 @@
"husky": "^9.1.6", "husky": "^9.1.6",
"idb": "^8.0.0", "idb": "^8.0.0",
"nostr-signer-capacitor-plugin": "^0.0.4", "nostr-signer-capacitor-plugin": "^0.0.4",
"nostr-tools": "^2.7.2", "nostr-tools": "^2.14.2",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4" "qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2",
"tippy.js": "^6.3.7"
}, },
"pnpm": { "pnpm": {
"overrides": {
"@welshman/lib": "link:../welshman/packages/lib",
"@welshman/util": "link:../welshman/packages/util",
"@welshman/app": "link:../welshman/packages/app",
"@welshman/content": "link:../welshman/packages/content",
"@welshman/dvm": "link:../welshman/packages/dvm",
"@welshman/feeds": "link:../welshman/packages/feeds",
"@welshman/net": "link:../welshman/packages/net",
"@welshman/relay": "link:../welshman/packages/relay",
"@welshman/router": "link:../welshman/packages/router",
"@welshman/signer": "link:../welshman/packages/signer",
"@welshman/store": "link:../welshman/packages/store",
"@welshman/editor": "link:../welshman/packages/editor"
},
"ignoredBuiltDependencies": [ "ignoredBuiltDependencies": [
"@sentry/cli", "@sentry/cli",
"esbuild" "esbuild"
+958 -58
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,8 +1,8 @@
import dotenv from "dotenv" import dotenv from "dotenv"
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config" import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
dotenv.config({path: ".env.local"})
dotenv.config({path: ".env"}) dotenv.config({path: ".env"})
dotenv.config({path: ".env.template"})
export default defineConfig({ export default defineConfig({
preset, preset,
+95 -41
View File
@@ -46,6 +46,14 @@
:root { :root {
font-family: Lato; font-family: Lato;
--sait: env(safe-area-inset-top);
--saib: env(safe-area-inset-bottom);
--sail: env(safe-area-inset-left);
--sair: env(safe-area-inset-right);
}
[data-theme] {
@apply bg-base-300;
--base-100: oklch(var(--b1)); --base-100: oklch(var(--b1));
--base-200: oklch(var(--b2)); --base-200: oklch(var(--b2));
--base-300: oklch(var(--b3)); --base-300: oklch(var(--b3));
@@ -56,56 +64,80 @@
--secondary-content: oklch(var(--sc)); --secondary-content: oklch(var(--sc));
} }
:root, /* safe area insets */
body,
html {
@apply bg-base-300;
}
/* ios */ @layer components {
.pt-sai {
padding-top: var(--sait);
}
.sait { .pr-sai {
padding-top: env(safe-area-inset-top); padding-right: var(--sair);
} }
.sair { .pb-sai {
padding-right: env(safe-area-inset-right); padding-bottom: var(--saib);
} }
.saib { .pl-sai {
padding-bottom: env(safe-area-inset-bottom); padding-left: var(--sail);
} }
.sail { .px-sai {
padding-left: env(safe-area-inset-left); @apply pl-sai pr-sai;
} }
.saix { .py-sai {
@apply sail sair; @apply pt-sai pb-sai;
} }
.saiy { .p-sai {
@apply sait saib; @apply py-sai px-sai;
} }
.sai { .mt-sai {
@apply saiy saix; padding-top: var(--sait);
} }
.top-sai { .mr-sai {
top: env(safe-area-inset-top); padding-right: var(--sair);
} }
.right-sai { .mb-sai {
right: env(safe-area-inset-right); padding-bottom: var(--saib);
} }
.bottom-sai { .ml-sai {
bottom: env(safe-area-inset-bottom); padding-left: var(--sail);
} }
.left-sai { .mx-sai {
left: env(safe-area-inset-left); @apply ml-sai mr-sai;
}
.my-sai {
@apply mt-sai mb-sai;
}
.m-sai {
@apply my-sai mx-sai;
}
.top-sai {
top: var(--sait);
}
.right-sai {
right: var(--sair);
}
.bottom-sai {
bottom: var(--saib);
}
.left-sai {
left: var(--sail);
}
} }
/* utilities */ /* utilities */
@@ -259,6 +291,14 @@ html {
--tiptap-active-fg: var(--base-content); --tiptap-active-fg: var(--base-content);
} }
.tiptap-suggestions__item {
@apply border-l-2 border-solid border-base-100;
}
.tiptap-suggestions__selected {
@apply border-primary;
}
.tiptap { .tiptap {
@apply max-h-[350px] overflow-y-auto p-2 px-4; @apply max-h-[350px] overflow-y-auto p-2 px-4;
} }
@@ -294,6 +334,16 @@ html {
color: var(--base-content); color: var(--base-content);
} }
/* content rendered by welshman/content */
.welshman-content a {
@apply link;
}
.welshman-content-error a {
@apply underline;
}
/* date input */ /* date input */
.picker { .picker {
@@ -335,11 +385,15 @@ progress[value]::-webkit-progress-value {
/* content width for fixed elements */ /* content width for fixed elements */
.cw { .cw {
@apply w-full md:w-[calc(100%-18.5rem)]; @apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cw-full {
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
} }
.cb { .cb {
@apply saib bottom-14 md:bottom-0; @apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
} }
/* chat view */ /* chat view */
@@ -349,5 +403,5 @@ progress[value]::-webkit-progress-value {
} }
.chat__scroll-down { .chat__scroll-down {
@apply saib fixed bottom-28 right-4 md:bottom-16; @apply fixed bottom-28 right-4 md:bottom-16;
} }
+120 -103
View File
@@ -1,6 +1,7 @@
import {nwc} from "@getalby/sdk"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store" import {get} from "svelte/store"
import {randomId, ifLet, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib" import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import type {Feed} from "@welshman/feeds" import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import { import {
@@ -12,14 +13,14 @@ import {
FOLLOWS, FOLLOWS,
REACTION, REACTION,
AUTH_JOIN, AUTH_JOIN,
GROUP_JOIN, ROOMS,
GROUP_LEAVE,
GROUP_CREATE,
GROUP_EDIT_META,
GROUPS,
COMMENT, COMMENT,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
isSignedEvent, isSignedEvent,
createEvent, makeEvent,
displayProfile, displayProfile,
normalizeRelayUrl, normalizeRelayUrl,
makeList, makeList,
@@ -33,7 +34,7 @@ import {
getRelaysFromList, getRelaysFromList,
RelayMode, RelayMode,
} from "@welshman/util" } from "@welshman/util"
import {Pool, PublishStatus, AuthStatus, SocketStatus} from "@welshman/net" import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
import { import {
pubkey, pubkey,
@@ -52,15 +53,14 @@ import {
dropSession, dropSession,
tagEventForComment, tagEventForComment,
tagEventForQuote, tagEventForQuote,
thunkIsComplete, getThunkError,
} from "@welshman/app" } from "@welshman/app"
import type {Thunk} from "@welshman/app"
import { import {
tagRoom, wallet,
getWebLn,
PROTECTED, PROTECTED,
userMembership, userMembership,
INDEXER_RELAYS, INDEXER_RELAYS,
ALERT,
NOTIFIER_PUBKEY, NOTIFIER_PUBKEY,
NOTIFIER_RELAY, NOTIFIER_RELAY,
userRoomsByUrl, userRoomsByUrl,
@@ -83,21 +83,6 @@ export const getPubkeyPetname = (pubkey: string) => {
return display return display
} }
export const getThunkError = (thunk: Thunk) =>
new Promise<string>(resolve => {
thunk.subscribe($thunk => {
for (const [relay, status] of Object.entries($thunk.status)) {
if (status === PublishStatus.Failure) {
resolve($thunk.details[relay])
}
}
if (thunkIsComplete($thunk)) {
resolve("")
}
})
})
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => { export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) { if (parent) {
const nevent = nip19.neventEncode({ const nevent = nip19.neventEncode({
@@ -142,37 +127,10 @@ export const broadcastUserData = async (relays: string[]) => {
} }
} }
// NIP 29 stuff
export const nip29 = {
createRoom: (url: string, room: string) => {
const event = createEvent(GROUP_CREATE, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
editMeta: (url: string, room: string, meta: Record<string, string>) => {
const event = createEvent(GROUP_EDIT_META, {
tags: [tagRoom(room, url), ...Object.entries(meta)],
})
return publishThunk({event, relays: [url]})
},
joinRoom: (url: string, room: string) => {
const event = createEvent(GROUP_JOIN, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
leaveRoom: (url: string, room: string) => {
const event = createEvent(GROUP_LEAVE, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
}
// List updates // List updates
export const addSpaceMembership = async (url: string) => { export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS}) const list = get(userMembership) || makeList({kind: ROOMS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf) const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -180,7 +138,7 @@ export const addSpaceMembership = async (url: string) => {
} }
export const removeSpaceMembership = async (url: string) => { export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS}) const list = get(userMembership) || makeList({kind: ROOMS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -188,11 +146,11 @@ export const removeSpaceMembership = async (url: string) => {
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
export const addRoomMembership = async (url: string, room: string, name: string) => { export const addRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: GROUPS}) const list = get(userMembership) || makeList({kind: ROOMS})
const newTags = [ const newTags = [
["r", url], ["r", url],
["group", room, url, name], ["group", room, url],
] ]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf) const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -201,7 +159,7 @@ export const addRoomMembership = async (url: string, room: string, name: string)
} }
export const removeRoomMembership = async (url: string, room: string) => { export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: GROUPS}) const list = get(userMembership) || makeList({kind: ROOMS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3)) const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -222,7 +180,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
} }
return publishThunk({ return publishThunk({
event: createEvent(list.kind, {tags}), event: makeEvent(list.kind, {tags}),
relays: [ relays: [
url, url,
...INDEXER_RELAYS, ...INDEXER_RELAYS,
@@ -244,7 +202,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
} }
return publishThunk({ return publishThunk({
event: createEvent(list.kind, {tags}), event: makeEvent(list.kind, {tags}),
relays: [ relays: [
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...Router.get().FromUser().getUrls(), ...Router.get().FromUser().getUrls(),
@@ -256,28 +214,41 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
// Relay access // Relay access
export const attemptAuth = (url: string) =>
Pool.get()
.get(url)
.auth.attemptAuth(e => signer.get()?.sign(e))
export const checkRelayAccess = async (url: string, claim = "") => { export const checkRelayAccess = async (url: string, claim = "") => {
const socket = Pool.get().get(url) const socket = Pool.get().get(url)
await socket.auth.attemptAuth(signer.get().sign) await attemptAuth(url)
const thunk = publishThunk({ const thunk = publishThunk({
event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}), event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url], relays: [url],
}) })
ifLet(await getThunkError(thunk), error => { const error = await getThunkError(thunk)
if (error) {
const message = const message =
socket.auth.details?.replace(/^.*: /, "") || socket.auth.details?.replace(/^\w+: /, "") ||
error?.replace(/^.*: /, "") || error?.replace(/^\w+: /, "") ||
"join request rejected" "join request rejected"
// If it's a strict NIP 29 relay don't worry about requesting access // If it's a strict NIP 29 relay don't worry about requesting access
// TODO: remove this if relay29 ever gets less strict // TODO: remove this if relay29 ever gets less strict
if (message !== "missing group (`h`) tag") { if (message === "missing group (`h`) tag") return
return `Failed to join relay (${message})`
} // Ignore messages about the relay ignoring ours
}) if (error?.startsWith("mute: ")) return
// Ignore rejected empty claims
if (!claim && error?.includes("invite code")) return
return message
}
} }
export const checkRelayProfile = async (url: string) => { export const checkRelayProfile = async (url: string) => {
@@ -307,7 +278,7 @@ export const checkRelayAuth = async (url: string, timeout = 3000) => {
const socket = Pool.get().get(url) const socket = Pool.get().get(url)
const okStatuses = [AuthStatus.None, AuthStatus.Ok] const okStatuses = [AuthStatus.None, AuthStatus.Ok]
await socket.auth.attemptAuth(signer.get().sign) await attemptAuth(url)
// Only raise an error if it's not a timeout. // Only raise an error if it's not a timeout.
// If it is, odds are the problem is with our signer, not the relay // If it is, odds are the problem is with our signer, not the relay
@@ -334,20 +305,26 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
// Actions // Actions
export const makeDelete = ({event}: {event: TrustedEvent}) => { export const makeDelete = ({event, tags = []}: {event: TrustedEvent; tags?: string[][]}) => {
const tags = [["k", String(event.kind)], ...tagEvent(event)] const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags]
const groupTag = getTag("h", event.tags) const groupTag = getTag("h", event.tags)
if (groupTag) { if (groupTag) {
tags.push(PROTECTED) thisTags.push(PROTECTED, groupTag)
tags.push(groupTag)
} }
return createEvent(DELETE, {tags}) return makeEvent(DELETE, {tags: thisTags})
} }
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) => export const publishDelete = ({
publishThunk({event: makeDelete({event}), relays}) relays,
event,
tags = [],
}: {
relays: string[]
event: TrustedEvent
tags?: string[][]
}) => publishThunk({event: makeDelete({event, tags}), relays})
export type ReportParams = { export type ReportParams = {
event: TrustedEvent event: TrustedEvent
@@ -361,7 +338,7 @@ export const makeReport = ({event, reason, content}: ReportParams) => {
["e", event.id, reason], ["e", event.id, reason],
] ]
return createEvent(REPORT, {content, tags}) return makeEvent(REPORT, {content, tags})
} }
export const publishReport = ({ export const publishReport = ({
@@ -375,10 +352,12 @@ export const publishReport = ({
export type ReactionParams = { export type ReactionParams = {
event: TrustedEvent event: TrustedEvent
content: string content: string
tags?: string[][]
} }
export const makeReaction = ({event, content}: ReactionParams) => { export const makeReaction = ({content, event, tags: paramTags = []}: ReactionParams) => {
const tags = tagEventForReaction(event) const tags = [...paramTags, ...tagEventForReaction(event)]
const groupTag = getTag("h", event.tags) const groupTag = getTag("h", event.tags)
if (groupTag) { if (groupTag) {
@@ -386,7 +365,7 @@ export const makeReaction = ({event, content}: ReactionParams) => {
tags.push(groupTag) tags.push(groupTag)
} }
return createEvent(REACTION, {content, tags}) return makeEvent(REACTION, {content, tags})
} }
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) => export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
@@ -399,42 +378,64 @@ export type CommentParams = {
} }
export const makeComment = ({event, content, tags = []}: CommentParams) => export const makeComment = ({event, content, tags = []}: CommentParams) =>
createEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]}) makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) => export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays}) publishThunk({event: makeComment(params), relays})
export type AlertParams = { export type AlertParams = {
feed: Feed feed: Feed
cron: string
email: string
bunker: string
secret: string
description: string description: string
claims: Record<string, string>
email?: {
cron: string
email: string
handler: string[]
}
web?: {
endpoint: string
p256dh: string
auth: string
}
ios?: {
device_token: string
bundle_identifier: string
}
android?: {
device_token: string
}
} }
export const makeAlert = async ({cron, email, feed, bunker, secret, description}: AlertParams) => { export const makeAlert = async (params: AlertParams) => {
const tags = [ const tags = [
["feed", JSON.stringify(feed)], ["feed", JSON.stringify(params.feed)],
["cron", cron],
["email", email],
["locale", LOCALE], ["locale", LOCALE],
["timezone", TIMEZONE], ["timezone", TIMEZONE],
["description", description], ["description", params.description],
["channel", "email"],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
] ]
if (bunker) { for (const [relay, claim] of Object.entries(params.claims)) {
tags.push(["nip46", secret, bunker]) tags.push(["claim", relay, claim])
} }
return createEvent(ALERT, { let kind: number
if (params.email) {
kind = ALERT_EMAIL
tags.push(...Object.entries(params.email).map(flatten))
} else if (params.web) {
kind = ALERT_WEB
tags.push(...Object.entries(params.web).map(flatten))
} else if (params.ios) {
kind = ALERT_IOS
tags.push(...Object.entries(params.ios).map(flatten))
} else if (params.android) {
kind = ALERT_ANDROID
tags.push(...Object.entries(params.android).map(flatten))
} else {
throw new Error("Alert has invalid params")
}
return makeEvent(kind, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)), content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [ tags: [
["d", randomId()], ["d", randomId()],
@@ -445,3 +446,19 @@ export const makeAlert = async ({cron, email, feed, bunker, secret, description}
export const publishAlert = async (params: AlertParams) => export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]}) publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
export const payInvoice = async (invoice: string) => {
const $wallet = get(wallet)
if (!$wallet) {
throw new Error("No wallet is connected")
}
if ($wallet.type === "nwc") {
return new nwc.NWCClient($wallet.info).payInvoice({invoice})
} else if ($wallet.type === "webln") {
return getWebLn()
.enable()
.then(() => getWebLn().sendPayment(invoice))
}
}
+167 -107
View File
@@ -1,30 +1,56 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib" import {decrypt} from "@welshman/signer"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util" import {randomInt, parseJson, fromPairs, displayList, TIMEZONE, identity} from "@welshman/lib"
import {
displayRelayUrl,
getTagValue,
getAddress,
THREAD,
MESSAGE,
EVENT_TIME,
COMMENT,
} from "@welshman/util"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds" import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import {pubkey} from "@welshman/app" import {pubkey, signer, getThunkError} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import { import {
GENERAL,
alerts, alerts,
getMembershipUrls, getMembershipUrls,
getMembershipRoomsByUrl,
userMembership, userMembership,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
} from "@app/state" } from "@app/state"
import {loadAlertStatuses} from "@app/requests" import {loadAlertStatuses, requestRelayClaim} from "@app/requests"
import {publishAlert} from "@app/commands" import {publishAlert, attemptAuth} from "@app/commands"
import type {AlertParams} from "@app/commands"
import {platform, platformName, canSendPushNotifications, getPushInfo} from "@app/push"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
type Props = {
url?: string
channel?: string
notifyChat?: boolean
notifyThreads?: boolean
notifyCalendar?: boolean
hideSpaceField?: boolean
}
let {
url = "",
channel = "email",
notifyChat = true,
notifyThreads = true,
notifyCalendar = true,
hideSpaceField = false,
}: Props = $props()
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100 const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
const minute = randomInt(0, 59) const minute = randomInt(0, 59)
@@ -32,49 +58,22 @@
const WEEKLY = `0 ${minute} ${hour} * * 1` const WEEKLY = `0 ${minute} ${hour} * * 1`
const DAILY = `0 ${minute} ${hour} * * *` const DAILY = `0 ${minute} ${hour} * * *`
let loading = false let loading = $state(false)
let cron = WEEKLY let cron = $state(WEEKLY)
let email = $alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || "" let claim = $state("")
let relay = "" let email = $state($alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || "")
let bunker = ""
let secret = ""
let notifyThreads = true
let notifyCalendar = true
let notifyChat = false
let showBunker = false
const back = () => history.back() const back = () => history.back()
const controller = new BunkerConnectController({
onNostrConnect: (response: Nip46ResponseWithResult) => {
bunker = controller.broker.getBunkerUrl()
secret = controller.broker.params.clientSecret
showBunker = false
},
})
const connectBunker = () => {
showBunker = true
}
const hideBunker = () => {
showBunker = false
}
const clearBunker = () => {
bunker = ""
secret = ""
}
const submit = async () => { const submit = async () => {
if (!email.includes("@")) { if (channel === "email" && !email.includes("@")) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Please provide an email address", message: "Please provide an email address",
}) })
} }
if (!relay) { if (!url) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Please select a space", message: "Please select a space",
@@ -105,22 +104,69 @@
if (notifyChat) { if (notifyChat) {
display.push("chat") display.push("chat")
filters.push({ filters.push({kinds: [MESSAGE]})
kinds: [MESSAGE],
"#h": [GENERAL, ...getMembershipRoomsByUrl(relay, $userMembership)],
})
} }
loading = true loading = true
try { try {
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily" const claims = claim ? {[url]: claim} : {}
const description = `${cadence} alert for ${displayList(display)} on ${displayRelayUrl(relay)}, sent via email.` const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url))
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(relay)) const description = `for ${displayList(display)} on ${displayRelayUrl(url)}`
const thunk = await publishAlert({cron, email, feed, bunker, secret, description}) const params: AlertParams = {feed, claims, description}
await thunk.result if (channel === "email") {
await loadAlertStatuses($pubkey!) const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
params.description = `${cadence} alert ${description}, sent via email.`
params.email = {
cron,
email,
handler: [
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
}
} else {
try {
// @ts-ignore
params[platform] = await getPushInfo()
params.description = `${platformName} push notification ${description}.`
} catch (e: any) {
return pushToast({
theme: "error",
message: String(e),
})
}
}
// If we don't do this we'll get an event rejection
await attemptAuth(NOTIFIER_RELAY)
const thunk = await publishAlert(params)
const error = await getThunkError(thunk)
if (error) {
return pushToast({
theme: "error",
message: `Failed to send your alert to the notification server (${error}).`,
})
}
// Fetch our new status to make sure it's active
const address = getAddress(thunk.event)
const statusEvents = await loadAlertStatuses($pubkey!)
const statusEvent = statusEvents.find(event => getTagValue("d", event.tags) === address)
const statusTags = statusEvent
? parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, statusEvent.content))
: []
const {status = "error", message = "Your alert was not activated"}: Record<string, string> =
fromPairs(statusTags)
if (status === "error") {
return pushToast({theme: "error", message})
}
pushToast({message: "Your alert has been successfully created!"}) pushToast({message: "Your alert has been successfully created!"})
back() back()
@@ -128,6 +174,20 @@
loading = false loading = false
} }
} }
onMount(() => {
if (!canSendPushNotifications()) {
channel = "email"
}
if (url) {
requestRelayClaim(url).then(code => {
if (code) {
claim = code
}
})
}
})
</script> </script>
<form class="column gap-4" onsubmit={preventDefault(submit)}> <form class="column gap-4" onsubmit={preventDefault(submit)}>
@@ -136,13 +196,20 @@
Add an Alert Add an Alert
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
{#if showBunker} {#if canSendPushNotifications()}
<div class="card2 flex flex-col items-center gap-4 bg-base-300"> <FieldInline>
<p>Scan using a nostr signer, or click to copy.</p> {#snippet label()}
<BunkerConnect {controller} /> <p>Alert Type*</p>
<Button class="btn btn-neutral btn-sm" onclick={hideBunker}>Cancel</Button> {/snippet}
</div> {#snippet input()}
{:else} <select bind:value={channel} class="select select-bordered">
<option value="email">Email Digest</option>
<option value="push">Push Notification</option>
</select>
{/snippet}
</FieldInline>
{/if}
{#if channel === "email"}
<FieldInline> <FieldInline>
{#snippet label()} {#snippet label()}
<p>Email Address*</p> <p>Email Address*</p>
@@ -164,12 +231,14 @@
</select> </select>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
{/if}
{#if !hideSpaceField}
<FieldInline> <FieldInline>
{#snippet label()} {#snippet label()}
<p>Space*</p> <p>Space*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<select bind:value={relay} class="select select-bordered"> <select bind:value={url} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option> <option value="" disabled selected>Choose a space URL</option>
{#each getMembershipUrls($userMembership) as url (url)} {#each getMembershipUrls($userMembership) as url (url)}
<option value={url}>{displayRelayUrl(url)}</option> <option value={url}>{displayRelayUrl(url)}</option>
@@ -177,59 +246,50 @@
</select> </select>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
<FieldInline> {/if}
{#snippet label()} <FieldInline>
<p>Notifications*</p> {#snippet label()}
{/snippet} <p>Notifications*</p>
{#snippet input()} {/snippet}
<div class="flex items-center justify-end gap-4"> {#snippet input()}
<span class="flex gap-3"> <div class="flex items-center justify-end gap-4">
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} /> <span class="flex gap-3">
Threads <input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
</span> Threads
<span class="flex gap-3"> </span>
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} /> <span class="flex gap-3">
Calendar <input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
</span> Calendar
<span class="flex gap-3"> </span>
<input type="checkbox" class="checkbox" bind:checked={notifyChat} /> <span class="flex gap-3">
Chat <input type="checkbox" class="checkbox" bind:checked={notifyChat} />
</span> Chat
</div>
{/snippet}
</FieldInline>
<div class="card2 flex flex-col gap-3 bg-base-300">
<div class="flex items-center justify-between">
<strong>Connect a Bunker</strong>
<span class="flex items-center gap-2 text-sm" class:text-primary={bunker}>
{#if bunker}
<Icon icon="check-circle" size={5} />
Connected
{:else}
<Icon icon="close-circle" size={5} />
Not Connected
{/if}
</span> </span>
</div> </div>
<p class="text-sm"> {/snippet}
Required for receiving alerts about spaces with access controls. You can get one from your </FieldInline>
<Button class="text-primary" onclick={() => pushModal(InfoBunker)}>remote signer app</Button <FieldInline>
>. {#snippet label()}
<p>Invite Code</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={claim} />
</label>
{/snippet}
{#snippet info()}
<p>
To get notifications from private spaces, please provide an invite code which grants access
to the space.
</p> </p>
{#if bunker} {/snippet}
<Button class="btn btn-neutral btn-sm flex-grow" onclick={clearBunker}>Disconnect</Button> </FieldInline>
{:else}
<Button class="btn btn-primary btn-sm w-full flex-grow" onclick={connectBunker}
>Connect</Button>
{/if}
</div>
{/if}
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={loading || showBunker}> <Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Confirm</Spinner> <Spinner {loading}>Confirm</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Confirm from "@lib/components/Confirm.svelte" import Confirm from "@lib/components/Confirm.svelte"
import type {Alert} from "@app/state" import type {Alert} from "@app/state"
import {NOTIFIER_RELAY} from "@app/state" import {NOTIFIER_RELAY, NOTIFIER_PUBKEY} from "@app/state"
import {publishDelete} from "@app/commands" import {publishDelete} from "@app/commands"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@@ -12,7 +12,7 @@
const {alert}: Props = $props() const {alert}: Props = $props()
const confirm = () => { const confirm = () => {
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY]}) publishDelete({event: alert.event, relays: [NOTIFIER_RELAY], tags: [["p", NOTIFIER_PUBKEY]]})
pushToast({message: "Your alert has been deleted!"}) pushToast({message: "Your alert has been deleted!"})
history.back() history.back()
} }
+8 -9
View File
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import {parseJson, nthEq} from "@welshman/lib" import {parseJson} from "@welshman/lib"
import {displayFeeds} from "@welshman/feeds" import {displayFeeds} from "@welshman/feeds"
import {getAddress, getTagValue, getTagValues} from "@welshman/util" import {getAddress, getTagValue, getTagValues} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte" import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state" import type {Alert} from "@app/state"
import {alertStatuses} from "@app/state" import {deriveAlertStatus} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Props = { type Props = {
@@ -15,8 +15,7 @@
const {alert}: Props = $props() const {alert}: Props = $props()
const address = $derived(getAddress(alert.event)) const status = deriveAlertStatus(getAddress(alert.event))
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const cron = $derived(getTagValue("cron", alert.tags)) const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags)) const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags)) const feeds = $derived(getTagValues("feed", alert.tags))
@@ -39,24 +38,24 @@
</Button> </Button>
<div class="flex-inline gap-1">{description}</div> <div class="flex-inline gap-1">{description}</div>
</div> </div>
{#if status} {#if $status}
{@const statusText = getTagValue("status", status.tags) || "error"} {@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"} {#if statusText === "ok"}
<span <span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm" class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", status.tags)}> data-tip={getTagValue("message", $status.tags)}>
Active Active
</span> </span>
{:else if statusText === "pending"} {:else if statusText === "pending"}
<span <span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500" class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", status.tags)}> data-tip={getTagValue("message", $status.tags)}>
Pending Pending
</span> </span>
{:else} {:else}
<span <span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error" class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", status.tags)}> data-tip={getTagValue("message", $status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())} {statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span> </span>
{/if} {/if}
+15 -10
View File
@@ -1,20 +1,25 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {getTagValue} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte" import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte" import AlertItem from "@app/components/AlertItem.svelte"
import {loadAlertStatuses, loadAlerts} from "@app/requests"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {alerts} from "@app/state" import {alerts} from "@app/state"
const startAlert = () => pushModal(AlertAdd) type Props = {
url?: string
channel?: string
hideSpaceField?: boolean
}
onMount(() => { const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
loadAlertStatuses($pubkey!)
loadAlerts($pubkey!) const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
})
const filteredAlerts = $derived(
url ? $alerts.filter(a => getTagValue("feed", a.tags)?.includes(url)) : $alerts,
)
</script> </script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl"> <div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
@@ -29,10 +34,10 @@
</Button> </Button>
</div> </div>
<div class="col-4"> <div class="col-4">
{#each $alerts as alert (alert.event.id)} {#each filteredAlerts as alert (alert.event.id)}
<AlertItem {alert} /> <AlertItem {alert} />
{:else} {:else}
<p class="text-center opacity-75 py-12">No alerts found</p> <p class="text-center opacity-75 py-12">Nothing here yet!</p>
{/each} {/each}
</div> </div>
</div> </div>
+2 -1
View File
@@ -9,7 +9,7 @@
loading = $state(false) loading = $state(false)
clientSecret = makeSecret() clientSecret = makeSecret()
abortController = new AbortController() abortController = new AbortController()
broker = Nip46Broker.get({clientSecret: this.clientSecret, relays: SIGNER_RELAYS}) broker = new Nip46Broker({clientSecret: this.clientSecret, relays: SIGNER_RELAYS})
onNostrConnect: (response: Nip46ResponseWithResult) => void onNostrConnect: (response: Nip46ResponseWithResult) => void
constructor({onNostrConnect}: {onNostrConnect: (response: Nip46ResponseWithResult) => void}) { constructor({onNostrConnect}: {onNostrConnect: (response: Nip46ResponseWithResult) => void}) {
@@ -45,6 +45,7 @@
} }
stop() { stop() {
this.broker.cleanup()
this.abortController.abort() this.abortController.abort()
} }
} }
+5 -10
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -26,20 +26,15 @@
const editEvent = () => pushModal(CalendarEventEdit, {url, event}) const editEvent = () => pushModal(CalendarEventEdit, {url, event})
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
</script> </script>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2"> <div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} /> <ThunkStatusOrDeleted {event} />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
+3 -4
View File
@@ -2,7 +2,7 @@
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib" import {randomId, HOUR} from "@welshman/lib"
import {createEvent, EVENT_TIME} from "@welshman/util" import {makeEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk} from "@welshman/app" import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util" import {daysBetween} from "@lib/util"
@@ -13,7 +13,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte" import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED, GENERAL, tagRoom} from "@app/state" import {PROTECTED} from "@app/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@@ -63,7 +63,7 @@
} }
const ed = await editor const ed = await editor
const event = createEvent(EVENT_TIME, { const event = makeEvent(EVENT_TIME, {
content: ed.getText({blockSeparator: "\n"}).trim(), content: ed.getText({blockSeparator: "\n"}).trim(),
tags: [ tags: [
["d", initialValues?.d || randomId()], ["d", initialValues?.d || randomId()],
@@ -73,7 +73,6 @@
["end", end.toString()], ["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]), ...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(), ...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
PROTECTED, PROTECTED,
], ],
}) })
+19 -16
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib" import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay} from "@welshman/app" import {thunks, deriveProfile, deriveProfileDisplay} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import TapTarget from "@lib/components/TapTarget.svelte" import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
@@ -11,23 +11,23 @@
import ThunkStatus from "@app/components/ThunkStatus.svelte" import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ChannelMessageZapButton from "@app/components/ChannelMessageZapButton.svelte"
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte" import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte" import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte" import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
import {colors} from "@app/state" import {colors, ENABLE_ZAPS} from "@app/state"
import {publishDelete, publishReaction} from "@app/commands" import {publishDelete, publishReaction} from "@app/commands"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
interface Props { interface Props {
url: string url: string
room: string
event: TrustedEvent event: TrustedEvent
replyTo?: (event: TrustedEvent) => void replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean showPubkey?: boolean
inert?: boolean inert?: boolean
} }
const {url, room, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props() const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const thunk = $thunks[event.id] const thunk = $thunks[event.id]
const today = formatTimestampAsDate(now()) const today = formatTimestampAsDate(now())
@@ -41,15 +41,10 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
</script> </script>
<TapTarget <TapTarget
@@ -81,7 +76,7 @@
</div> </div>
{/if} {/if}
<div class="text-sm"> <div class="text-sm">
<Content {event} {url} /> <Content minimalQuote {event} {url} />
{#if thunk} {#if thunk}
<ThunkStatus {thunk} class="mt-2" /> <ThunkStatus {thunk} class="mt-2" />
{/if} {/if}
@@ -89,13 +84,21 @@
</div> </div>
</div> </div>
<div class="row-2 ml-10 mt-1"> <div class="row-2 ml-10 mt-1">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right" /> <ReactionSummary
{url}
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
</div> </div>
{#if !isMobile} {#if !isMobile}
<button <button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all" class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}> class:group-hover:opacity-100={!isMobile}>
<ChannelMessageEmojiButton {url} {room} {event} /> {#if ENABLE_ZAPS}
<ChannelMessageZapButton {url} {event} />
{/if}
<ChannelMessageEmojiButton {url} {event} />
{#if replyTo} {#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}> <Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon="reply" size={4} /> <Icon icon="reply" size={4} />
@@ -1,14 +1,10 @@
<script lang="ts"> <script lang="ts">
import {noop} from "@welshman/lib"
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {publishReaction} from "@app/commands" import {publishReaction} from "@app/commands"
const {url, room, event} = $props() const {url, event} = $props()
// Tell svelte-check to shut up
noop(room)
const onEmoji = (emoji: NativeEmoji) => const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, relays: [url], content: emoji.unicode}) publishReaction({event, relays: [url], content: emoji.unicode})
@@ -5,8 +5,10 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte" import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {ENABLE_ZAPS} from "@app/state"
import {publishReaction} from "@app/commands" import {publishReaction} from "@app/commands"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@@ -40,6 +42,12 @@
<Icon size={4} icon="smile-circle" /> <Icon size={4} icon="smile-circle" />
Send Reaction Send Reaction
</Button> </Button>
{#if ENABLE_ZAPS}
<ZapButton replaceState {url} {event} class="btn btn-secondary w-full">
<Icon size={4} icon="bolt" />
Send Zap
</ZapButton>
{/if}
<Button class="btn btn-neutral w-full" onclick={sendReply}> <Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon="reply" /> <Icon size={4} icon="reply" />
Send Reply Send Reply
@@ -0,0 +1,10 @@
<script lang="ts">
import Icon from "@lib/components/Icon.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
const {url, event} = $props()
</script>
<ZapButton {url} {event} class="btn join-item btn-xs">
<Icon icon="bolt" size={4} />
</ZapButton>
+2 -6
View File
@@ -1,11 +1,7 @@
<script lang="ts"> <script lang="ts">
import {GENERAL, channelsById, makeChannelId} from "@app/state" import {channelsById, makeChannelId} from "@app/state"
const {url, room} = $props() const {url, room} = $props()
</script> </script>
{#if room === GENERAL} {$channelsById.get(makeChannelId(url, room))?.name || room}
general
{:else}
{$channelsById.get(makeChannelId(url, room))?.name || room}
{/if}
+71 -11
View File
@@ -1,9 +1,28 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {onMount} from "svelte" import {onMount} from "svelte"
import {int, nthNe, MINUTE, sortBy, remove, formatTimestampAsDate} from "@welshman/lib" import {
import type {TrustedEvent, EventContent} from "@welshman/util" int,
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util" ms,
partition,
spec,
nthEq,
nthNe,
MINUTE,
sortBy,
remove,
formatTimestampAsDate,
} from "@welshman/lib"
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
import {parse, isLink} from "@welshman/content"
import {
makeEvent,
tagsFromIMeta,
getTags,
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
INBOX_RELAYS,
} from "@welshman/util"
import { import {
pubkey, pubkey,
tagPubkey, tagPubkey,
@@ -61,14 +80,53 @@
} }
const onSubmit = async (params: EventContent) => { const onSubmit = async (params: EventContent) => {
// Remove p tags since they result in forking the conversation const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)]
await sendWrapped({ // Remove p tags since they result in forking the conversation
pubkeys, params.tags = params.tags.filter(nthNe(0, "p"))
template: createEvent(DIRECT_MESSAGE, prependParent(parent, {...params, tags})),
delay: $userSettingValues.send_delay, // Add our reply quote to content
}) params = prependParent(parent, params)
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
const templates: EventTemplate[] = []
const buffer = []
const addTemplate = (kind: number, content: string, tags: string[][]) => {
content = content.trim()
if (content) {
templates.push(makeEvent(kind, {content, tags: [...tags, ...ptags]}))
}
}
for (const p of parse(params)) {
const imeta = isLink(p)
? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()])))
: undefined
if (isLink(p) && imeta) {
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
addTemplate(
DIRECT_MESSAGE_FILE,
p.value.url.toString(),
imeta.slice(1).filter(nthNe(0, "url")),
)
} else {
buffer.push(p.raw)
}
}
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
// Sleep 1 second between each one to make sure timestamps are distinct
for (let i = 0; i < templates.length; i++) {
const template = templates[i]
await sendWrapped({pubkeys, template, delay: $userSettingValues.send_delay + ms(i)})
}
clearParent() clearParent()
} }
@@ -126,9 +184,11 @@
}) })
observer.observe(chatCompose!) observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
return () => { return () => {
observer.unobserve(chatCompose!) observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
} }
}) })
@@ -189,7 +249,7 @@
{/snippet} {/snippet}
</PageBar> </PageBar>
<PageContent class="flex flex-col-reverse pt-4"> <PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div> <div bind:this={dynamicPadding}></div>
{#if missingInboxes.includes($pubkey!)} {#if missingInboxes.includes($pubkey!)}
<div class="py-12"> <div class="py-12">
+13 -1
View File
@@ -20,6 +20,8 @@
export const focus = () => editor.then(ed => ed.chain().focus().run()) export const focus = () => editor.then(ed => ed.chain().focus().run())
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => { const submit = async () => {
if ($uploading) return if ($uploading) return
@@ -40,11 +42,21 @@
submit, submit,
uploading, uploading,
aggressive: true, aggressive: true,
disableFileUpload: true,
}) })
</script> </script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}> <form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<Button
data-tip="Add an image"
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
disabled={$uploading}
onclick={uploadFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
{/if}
</Button>
<div class="chat-editor flex-grow overflow-hidden"> <div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} /> <EditorContent {editor} />
</div> </div>
+1 -1
View File
@@ -22,7 +22,7 @@
const {...props}: Props = $props() const {...props}: Props = $props()
const others = remove($pubkey!, props.pubkeys) const others = remove($pubkey!, props.pubkeys)
const active = $page.params.chat === props.id const active = $derived($page.params.chat === props.id)
const path = makeChatPath(props.pubkeys) const path = makeChatPath(props.pubkeys)
onMount(() => { onMount(() => {
+6 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {type Instance} from "tippy.js" import {type Instance} from "tippy.js"
import {hash, formatTimestampAsTime} from "@welshman/lib" import {hash, formatTimestampAsTime} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app" import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -36,12 +36,11 @@
const reply = () => replyTo(event) const reply = () => replyTo(event)
const onReactionClick = async (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) =>
const reaction = events.find(e => e.pubkey === $pubkey) sendWrapped({template: makeDelete({event}), pubkeys})
const template = reaction ? makeDelete({event: reaction}) : makeReaction({event, content})
await sendWrapped({template, pubkeys}) const createReaction = (template: EventContent) =>
} sendWrapped({template: makeReaction({event, ...template}), pubkeys})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
@@ -120,7 +119,7 @@
</div> </div>
</TapTarget> </TapTarget>
<div class="row-2 z-feature -mt-4 ml-4"> <div class="row-2 z-feature -mt-4 ml-4">
<ReactionSummary {event} {onReactionClick} noTooltip /> <ReactionSummary {event} {deleteReaction} {createReaction} noTooltip />
</div> </div>
</div> </div>
</div> </div>
+35 -1
View File
@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {tryCatch, uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util"
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
@@ -14,7 +19,36 @@
const onSubmit = () => goto(makeChatPath([...pubkeys, $pubkey!])) const onSubmit = () => goto(makeChatPath([...pubkeys, $pubkey!]))
const addPubkey = (pubkey: string) => {
pubkeys = uniq([...pubkeys, pubkey])
term.set("")
}
const term = writable("")
let pubkeys: string[] = $state([]) let pubkeys: string[] = $state([])
onMount(() => {
return term.subscribe(t => {
if (t.match(/^[0-9a-f]{64}$/)) {
addPubkey(t)
}
if (t.match(/^(nostr:)?(npub1|nprofile1)/)) {
tryCatch(() => {
const {type, data} = nip19.decode(fromNostrURI(t))
if (type === "npub") {
addPubkey(data)
}
if (type === "nprofile") {
addPubkey(data.pubkey)
}
})
}
})
})
</script> </script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}> <form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
@@ -28,7 +62,7 @@
</ModalHeader> </ModalHeader>
<Field> <Field>
{#snippet input()} {#snippet input()}
<ProfileMultiSelect autofocus bind:value={pubkeys} /> <ProfileMultiSelect autofocus bind:value={pubkeys} {term} />
{/snippet} {/snippet}
</Field> </Field>
<ModalFooter> <ModalFooter>
+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>
+13 -1
View File
@@ -6,6 +6,7 @@
truncate, truncate,
renderAsHtml, renderAsHtml,
isText, isText,
isEmoji,
isTopic, isTopic,
isCode, isCode,
isCashu, isCashu,
@@ -22,6 +23,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte" import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentCode from "@app/components/ContentCode.svelte" import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte" import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte" import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
@@ -38,6 +40,7 @@
showEntire?: boolean showEntire?: boolean
hideMediaAtDepth?: number hideMediaAtDepth?: number
expandMode?: string expandMode?: string
minimalQuote?: boolean
depth?: number depth?: number
url?: string url?: string
} }
@@ -49,6 +52,7 @@
showEntire = $bindable(false), showEntire = $bindable(false),
hideMediaAtDepth = 1, hideMediaAtDepth = 1,
expandMode = "block", expandMode = "block",
minimalQuote = false,
depth = 0, depth = 0,
url, url,
}: Props = $props() }: Props = $props()
@@ -133,6 +137,8 @@
<ContentNewline value={parsed.value} /> <ContentNewline value={parsed.value} />
{:else if isTopic(parsed)} {:else if isTopic(parsed)}
<ContentTopic value={parsed.value} /> <ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else if isCode(parsed)} {:else if isCode(parsed)}
<ContentCode <ContentCode
value={parsed.value} value={parsed.value}
@@ -149,7 +155,13 @@
<ContentMention value={parsed.value} {url} /> <ContentMention value={parsed.value} {url} />
{:else if isEvent(parsed) || isAddress(parsed)} {:else if isEvent(parsed) || isAddress(parsed)}
{#if isBlock(i)} {#if isBlock(i)}
<ContentQuote {depth} {url} {hideMediaAtDepth} value={parsed.value} {event} /> <ContentQuote
{depth}
{url}
{hideMediaAtDepth}
value={parsed.value}
{event}
minimal={minimalQuote} />
{:else} {:else}
<Link <Link
external external
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type {ParsedEmojiValue} from "@welshman/content"
import {imgproxy} from "@app/state"
export let value: ParsedEmojiValue
const alt = `:${value.name}:`
</script>
{#if value.url}
<img
{alt}
src={imgproxy(value.url, {w: 24, h: 24})}
class="-mt-0.5 inline h-[1em] min-w-[1em] align-middle" />
{:else}
{alt}
{/if}
+8 -10
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {ellipsize, postJson} from "@welshman/lib" import {ellipsize, displayUrl, postJson} from "@welshman/lib"
import {dufflepud, imgproxy} from "@app/state" import {dufflepud, imgproxy} from "@app/state"
import {preventDefault, stopPropagation} from "@lib/html" import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
@@ -31,7 +31,7 @@
</script> </script>
<Link external href={url} class="my-2 block"> <Link external href={url} class="my-2 block">
<div class="overflow-hidden rounded-box leading-[0]"> <div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/)} {#if url.match(/\.(mov|webm|mp4)$/)}
<video controls src={url} class="max-h-96 object-contain object-center"> <video controls src={url} class="max-h-96 object-contain object-center">
<track kind="captions" /> <track kind="captions" />
@@ -52,15 +52,13 @@
alt="Link preview" alt="Link preview"
onerror={onError} onerror={onError}
src={imgproxy(preview.image)} src={imgproxy(preview.image)}
class="bg-alt max-h-72 object-contain object-center" /> class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if}
{#if preview.title}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
{/if} {/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div> </div>
{:catch} {:catch}
<p class="bg-alt p-12 text-center leading-normal"> <p class="bg-alt p-12 text-center leading-normal">
+34 -31
View File
@@ -1,49 +1,52 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte" import {onMount, onDestroy} from "svelte"
import {now} from "@welshman/lib" import {displayUrl} from "@welshman/lib"
import {BLOSSOM_AUTH, makeEvent, getTags, getTagValue, tagsFromIMeta} from "@welshman/util" import {getTags, decryptFile, getTagValue, tagsFromIMeta} from "@welshman/util"
import {signer} from "@welshman/app" import Icon from "@lib/components/Icon.svelte"
import {imgproxy} from "@app/state" import {imgproxy} from "@app/state"
const {value, event, ...props} = $props() const {value, event, ...props} = $props()
const url = value.url.toString() const url = value.url.toString()
const meta =
// If we fail to fetch the image, try authenticating if we have a blossom hash getTags("imeta", event.tags)
const onerror = async () => {
const meta = getTags("imeta", event.tags)
.map(tagsFromIMeta) .map(tagsFromIMeta)
.find(meta => getTagValue("url", meta) === url) .find(meta => getTagValue("url", meta) === url) || event.tags
const hash = meta ? getTagValue("x", meta) : undefined
if (hash && $signer) { const key = getTagValue("decryption-key", meta)
const event = await signer.get().sign( const nonce = getTagValue("decryption-nonce", meta)
makeEvent(BLOSSOM_AUTH, { const algorithm = getTagValue("encryption-algorithm", meta)
tags: [
["t", "get"],
["x", hash],
["expiration", String(now() + 30)],
],
}),
)
const res = await fetch(url, { const onError = () => {
headers: { hasError = true
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
},
})
if (res.status === 200) {
src = URL.createObjectURL(await res.blob())
}
}
} }
let hasError = $state(false)
let src = $state(imgproxy(url)) let src = $state(imgproxy(url))
onMount(async () => {
if (algorithm === "aes-gcm" && key && nonce) {
const response = await fetch(url)
if (response.ok) {
const ciphertext = new Uint8Array(await response.arrayBuffer())
const decryptedData = await decryptFile({ciphertext, key, nonce, algorithm})
src = URL.createObjectURL(new Blob([decryptedData]))
}
}
})
onDestroy(() => { onDestroy(() => {
URL.revokeObjectURL(src) URL.revokeObjectURL(src)
}) })
</script> </script>
<img alt="" {src} {onerror} {...props} /> {#if hasError}
<a href={url} class="link-content whitespace-nowrap">
<Icon icon="link-round" size={3} class="inline-block" />
{displayUrl(url)}
</a>
{:else}
<img alt="" {src} onerror={onError} {...props} />
{/if}
+3 -4
View File
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import {removeNil} from "@welshman/lib" import {removeNil} from "@welshman/lib"
import type {ProfilePointer} from "@welshman/content" import type {ProfilePointer} from "@welshman/content"
import {displayProfile} from "@welshman/util" import {deriveProfileDisplay} from "@welshman/app"
import {deriveProfile} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@@ -14,11 +13,11 @@
const {value, url}: Props = $props() const {value, url}: Props = $props()
const profile = deriveProfile(value.pubkey, removeNil([url])) const display = deriveProfileDisplay(value.pubkey, removeNil([url]))
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url}) const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
</script> </script>
<Button onclick={openProfile} class="link-content"> <Button onclick={openProfile} class="link-content">
@{displayProfile($profile)} @{$display}
</Button> </Button>
+19 -61
View File
@@ -1,18 +1,14 @@
<script lang="ts"> <script lang="ts">
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {nthEq} from "@welshman/lib"
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
import {tracker, repository} from "@welshman/app"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD, EVENT_TIME} from "@welshman/util" import {Address, MESSAGE} from "@welshman/util"
import {scrollToEvent} from "@lib/html"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
import {deriveEvent, entityLink, ROOM} from "@app/state" import {deriveEvent, entityLink} from "@app/state"
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes" import {goToEvent} from "@app/routes"
type Props = { type Props = {
value: any value: any
@@ -20,9 +16,10 @@
event: TrustedEvent event: TrustedEvent
depth: number depth: number
url?: string url?: string
minimal?: boolean
} }
const {value, event, depth, hideMediaAtDepth, url}: Props = $props() const {value, event, depth, hideMediaAtDepth, url, minimal}: Props = $props()
const {id, identifier, kind, pubkey, relays = []} = value const {id, identifier, kind, pubkey, relays = []} = value
const idOrAddress = id || new Address(kind, pubkey, identifier).toString() const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
@@ -37,67 +34,28 @@
? nip19.neventEncode({id, relays: mergedRelays}) ? nip19.neventEncode({id, relays: mergedRelays})
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr() : new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
const openMessage = (url: string, room: string, id: string) => {
const event = repository.getEvent(id)
if (event) {
goto(makeRoomPath(url, room))
scrollToEvent(id)
}
return Boolean(event)
}
const onclick = () => { const onclick = () => {
if ($quote) { if ($quote) {
if ($quote.kind === DIRECT_MESSAGE) { goToEvent($quote)
return scrollToEvent($quote.id) } else {
} window.open(entityLink(entity))
const [url] = tracker.getRelays($quote.id)
const room = $quote.tags.find(nthEq(0, ROOM))?.[1]
if (url && room) {
if ($quote.kind === THREAD) {
return goto(makeThreadPath(url, $quote.id))
}
if ($quote.kind === EVENT_TIME) {
return goto(makeCalendarPath(url, $quote.id))
}
if ($quote.kind === MESSAGE) {
return scrollToEvent($quote.id) || openMessage(url, room, $quote.id)
}
const kind = $quote.tags.find(nthEq(0, "K"))?.[1]
const id = $quote.tags.find(nthEq(0, "E"))?.[1]
if (id && kind) {
if (parseInt(kind) === THREAD) {
return goto(makeThreadPath(url, id))
}
if (parseInt(kind) === EVENT_TIME) {
return goto(makeCalendarPath(url, id))
}
if (parseInt(kind) === MESSAGE) {
return scrollToEvent(id) || openMessage(url, room, id)
}
}
}
} }
window.open(entityLink(entity))
} }
</script> </script>
<Button class="my-2 block max-w-full text-left" {onclick}> <Button class="my-2 block max-w-full text-left" {onclick}>
{#if $quote} {#if $quote}
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4"> {#if minimal && $quote.kind === MESSAGE}
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} /> <div
</NoteCard> class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
</div>
{:else}
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
</NoteCard>
{/if}
{:else} {:else}
<div class="rounded-box p-4"> <div class="rounded-box p-4">
<Spinner loading>Loading event...</Spinner> <Spinner loading>Loading event...</Spinner>
@@ -0,0 +1,74 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {goToEvent} from "@app/routes"
import {displayChannel} from "@app/state"
type Props = {
url: string
room?: string
events: TrustedEvent[]
latest: TrustedEvent
earliest: TrustedEvent
participants: string[]
}
const {url, room, events, latest, earliest, participants}: Props = $props()
</script>
<Button class="card2 bg-alt" onclick={() => goToEvent(earliest)}>
<div class="flex flex-col gap-3">
<div class="flex items-start gap-3">
<ProfileCircle pubkey={earliest.pubkey} size={10} />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm opacity-70">
{#if room}
<span class="font-medium text-blue-400">
#{displayChannel(url, room)}
</span>
<span class="opacity-50"></span>
{/if}
<span>{formatTimestamp(earliest.created_at)}</span>
</div>
<Content minimalQuote minLength={100} maxLength={400} event={earliest} />
</div>
</div>
<div class="ml-13 flex items-center justify-between">
<div class="flex gap-1">
<Icon icon="alt-arrow-left" />
<span class="text-sm opacity-70">
{events.length}
{events.length === 1 ? "message" : "messages"}
</span>
</div>
<div class="flex gap-2">
<ProfileCircles pubkeys={participants} size={6} />
<span class="text-sm opacity-70">
{participants.length}
{participants.length === 1 ? "participant" : "participants"}
</span>
</div>
</div>
{#if latest !== earliest}
<Button class="card2 bg-alt" onclick={() => goToEvent(latest)}>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm opacity-70">
<ProfileCircle pubkey={latest.pubkey} size={5} />
<span class="font-medium">Latest reply:</span>
</div>
<span class="text-xs opacity-50">
{formatTimestamp(latest.created_at)}
</span>
</div>
<Content minimalQuote minLength={100} maxLength={400} event={latest} />
</div>
</Button>
{/if}
</div>
</Button>
+9 -1
View File
@@ -6,18 +6,21 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import EventMenu from "@app/components/EventMenu.svelte" import EventMenu from "@app/components/EventMenu.svelte"
import {ENABLE_ZAPS} from "@app/state"
import {publishReaction} from "@app/commands" import {publishReaction} from "@app/commands"
type Props = { type Props = {
url: string url: string
noun: string noun: string
event: TrustedEvent event: TrustedEvent
hideZap?: boolean
customActions?: Snippet customActions?: Snippet
} }
const {url, noun, event, customActions}: Props = $props() const {url, noun, event, hideZap, customActions}: Props = $props()
const showPopover = () => popover?.show() const showPopover = () => popover?.show()
@@ -30,6 +33,11 @@
</script> </script>
<Button class="join rounded-full"> <Button class="join rounded-full">
{#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
<Icon icon="bolt" size={4} />
</ZapButton>
{/if}
<EmojiButton {onEmoji} class="btn join-item btn-neutral btn-xs"> <EmojiButton {onEmoji} class="btn join-item btn-neutral btn-xs">
<Icon icon="smile-circle" size={4} /> <Icon icon="smile-circle" size={4} />
</EmojiButton> </EmojiButton>
+2 -2
View File
@@ -8,7 +8,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {publishComment} from "@app/commands" import {publishComment} from "@app/commands"
import {tagRoom, GENERAL, PROTECTED} from "@app/state" import {PROTECTED} from "@app/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@@ -23,7 +23,7 @@
const ed = await editor const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim() const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [...ed.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED] const tags = [...ed.storage.nostr.getEditorTags(), PROTECTED]
if (!content) { if (!content) {
return pushToast({ return pushToast({
+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>
+22 -12
View File
@@ -33,25 +33,28 @@
const onSubmit = async () => { const onSubmit = async () => {
if (controller.loading) return if (controller.loading) return
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(controller.bunker)
if (!signerPubkey || relays.length === 0) {
return pushToast({
theme: "error",
message: "Sorry, it looks like that's an invalid bunker link.",
})
}
controller.loading = true
try { try {
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(controller.bunker)
console.log({signerPubkey, connectSecret, relays})
if (!signerPubkey || relays.length === 0) {
return pushToast({
theme: "error",
message: "Sorry, it looks like that's an invalid bunker link.",
})
}
controller.loading = true
const {clientSecret} = controller const {clientSecret} = controller
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey}) const broker = new Nip46Broker({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS) const result = await broker.connect(connectSecret, NIP46_PERMS)
const pubkey = await broker.getPublicKey() const pubkey = await broker.getPublicKey()
// TODO: remove ack result // TODO: remove ack result
if (pubkey && ["ack", connectSecret].includes(result)) { if (pubkey && ["ack", connectSecret].includes(result)) {
broker.cleanup()
controller.stop() controller.stop()
await loadUserData(pubkey) await loadUserData(pubkey)
@@ -63,6 +66,13 @@
message: "Something went wrong, please try again!", message: "Something went wrong, please try again!",
}) })
} }
} catch (e) {
console.error(e)
return pushToast({
theme: "error",
message: "Something went wrong, please try again!",
})
} finally { } finally {
controller.loading = false controller.loading = false
} }
+2 -2
View File
@@ -34,7 +34,7 @@
? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))] ? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))]
: [normalizeRelayUrl(BURROW_URL)] : [normalizeRelayUrl(BURROW_URL)]
const broker = Nip46Broker.get({clientSecret, relays}) const broker = new Nip46Broker({clientSecret, relays})
const back = () => history.back() const back = () => history.back()
@@ -89,7 +89,7 @@
await loadUserData(pubkey) await loadUserData(pubkey)
addSession({...session, email}) addSession({...session, email})
broker.cleanup()
setChecked("*") setChecked("*")
clearModals() clearModals()
} }
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
type Props = {
urls: string[]
}
const {urls}: Props = $props()
</script>
<div class="column menu gap-2">
{#each urls as url (url)}
<MenuSpacesItem {url} />
{/each}
</div>
+71 -27
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl, getTagValue} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -12,14 +13,19 @@
import SpaceExit from "@app/components/SpaceExit.svelte" import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte"
import ProfileList from "@app/components/ProfileList.svelte" import ProfileList from "@app/components/ProfileList.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import Alerts from "@app/components/Alerts.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte" import RoomCreate from "@app/components/RoomCreate.svelte"
import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte" import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
import { import {
ENABLE_ZAPS,
userRoomsByUrl, userRoomsByUrl,
hasMembershipUrl, hasMembershipUrl,
memberships, memberships,
deriveUserRooms, deriveUserRooms,
deriveOtherRooms, deriveOtherRooms,
hasNip29,
alerts,
} from "@app/state" } from "@app/state"
import {notifications} from "@app/notifications" import {notifications} from "@app/notifications"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@@ -27,10 +33,14 @@
const {url} = $props() const {url} = $props()
const relay = deriveRelay(url)
const chatPath = makeSpacePath(url, "chat")
const goalsPath = makeSpacePath(url, "goals")
const threadsPath = makeSpacePath(url, "threads") const threadsPath = makeSpacePath(url, "threads")
const calendarPath = makeSpacePath(url, "calendar") const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(url) const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url) const otherRooms = deriveOtherRooms(url)
const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url)))
const openMenu = () => { const openMenu = () => {
showMenu = true showMenu = true
@@ -55,6 +65,13 @@
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState}) const addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
const manageAlerts = () => {
const component = hasAlerts ? Alerts : AlertAdd
const params = {url, channel: "push", hideSpaceField: true}
pushModal(component, params, {replaceState})
}
let showMenu = $state(false) let showMenu = $state(false)
let replaceState = $state(false) let replaceState = $state(false)
let element: Element | undefined = $state() let element: Element | undefined = $state()
@@ -68,18 +85,20 @@
}) })
</script> </script>
<div bind:this={element}> <div bind:this={element} class="flex h-full flex-col justify-between">
<SecondaryNavSection class="max-h-screen"> <SecondaryNavSection>
<div> <div>
<SecondaryNavItem class="w-full !justify-between" onclick={openMenu}> <SecondaryNavItem class="w-full !justify-between" onclick={openMenu}>
<strong class="ellipsize">{displayRelayUrl(url)}</strong> <strong class="ellipsize flex items-center gap-3">
{displayRelayUrl(url)}
</strong>
<Icon icon="alt-arrow-down" /> <Icon icon="alt-arrow-down" />
</SecondaryNavItem> </SecondaryNavItem>
{#if showMenu} {#if showMenu}
<Popover hideOnClick onClose={toggleMenu}> <Popover hideOnClick onClose={toggleMenu}>
<ul <ul
transition:fly transition:fly
class="menu absolute z-popover mt-2 w-full rounded-box bg-base-100 p-2 shadow-xl"> class="menu absolute z-popover mt-2 w-full gap-1 rounded-box bg-base-100 p-2 shadow-xl">
<li> <li>
<Button onclick={showMembers}> <Button onclick={showMembers}>
<Icon icon="user-rounded" /> <Icon icon="user-rounded" />
@@ -109,10 +128,18 @@
</Popover> </Popover>
{/if} {/if}
</div> </div>
<div class="flex min-h-0 flex-col gap-1 overflow-auto"> <div class="flex max-h-[calc(100vh-150px)] min-h-0 flex-col gap-1 overflow-auto">
<SecondaryNavItem {replaceState} href={makeSpacePath(url)}> <SecondaryNavItem {replaceState} href={makeSpacePath(url)}>
<Icon icon="home-smile" /> Home <Icon icon="home-smile" /> Home
</SecondaryNavItem> </SecondaryNavItem>
{#if ENABLE_ZAPS}
<SecondaryNavItem
{replaceState}
href={goalsPath}
notification={$notifications.has(goalsPath)}>
<Icon icon="star-fall-minimalistic-2" /> Goals
</SecondaryNavItem>
{/if}
<SecondaryNavItem <SecondaryNavItem
{replaceState} {replaceState}
href={threadsPath} href={threadsPath}
@@ -125,28 +152,45 @@
notification={$notifications.has(calendarPath)}> notification={$notifications.has(calendarPath)}>
<Icon icon="calendar-minimalistic" /> Calendar <Icon icon="calendar-minimalistic" /> Calendar
</SecondaryNavItem> </SecondaryNavItem>
<div class="h-2"></div> {#if hasNip29($relay)}
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader> {#if $userRooms.length > 0}
{#each $userRooms as room, i (room)} <div class="h-2"></div>
<MenuSpaceRoomItem {replaceState} notify {url} {room} /> <SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/each} {/if}
{#if $otherRooms.length > 0} {#each $userRooms as room, i (room)}
<div class="h-2"></div> <MenuSpaceRoomItem {replaceState} notify {url} {room} />
<SecondaryNavHeader> {/each}
{#if $userRooms.length > 0} {#if $otherRooms.length > 0}
Other Rooms <div class="h-2"></div>
{:else} <SecondaryNavHeader>
Rooms {#if $userRooms.length > 0}
{/if} Other Rooms
</SecondaryNavHeader> {:else}
Rooms
{/if}
</SecondaryNavHeader>
{/if}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} {url} {room} />
{/each}
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
{:else}
<SecondaryNavItem
{replaceState}
href={chatPath}
notification={$notifications.has(chatPath)}>
<Icon icon="chat-round" /> Chat
</SecondaryNavItem>
{/if} {/if}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} {url} {room} />
{/each}
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
</div> </div>
</SecondaryNavSection> </SecondaryNavSection>
<div class="p-4">
<button class="btn btn-neutral btn-sm w-full" onclick={manageAlerts}>
<Icon icon="bell" />
Manage Alerts
</button>
</div>
</div> </div>
+2 -2
View File
@@ -3,7 +3,7 @@
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ChannelName from "@app/components/ChannelName.svelte" import ChannelName from "@app/components/ChannelName.svelte"
import {makeRoomPath} from "@app/routes" import {makeRoomPath} from "@app/routes"
import {deriveChannel, channelIsLocked} from "@app/state" import {deriveChannel} from "@app/state"
import {notifications} from "@app/notifications" import {notifications} from "@app/notifications"
interface Props { interface Props {
@@ -23,7 +23,7 @@
href={path} href={path}
{replaceState} {replaceState}
notification={notify ? $notifications.has(path) : false}> notification={notify ? $notifications.has(path) : false}>
{#if channelIsLocked($channel)} {#if $channel?.closed || $channel?.private}
<Icon icon="lock" size={4} /> <Icon icon="lock" size={4} />
{:else} {:else}
<Icon icon="hashtag" /> <Icon icon="hashtag" />
+11 -12
View File
@@ -5,23 +5,22 @@
import CardButton from "@lib/components/CardButton.svelte" import CardButton from "@lib/components/CardButton.svelte"
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte" import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte" import SpaceAdd from "@app/components/SpaceAdd.svelte"
import {userRoomsByUrl, PLATFORM_RELAY} from "@app/state" import {userRoomsByUrl, PLATFORM_RELAYS} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const addSpace = () => pushModal(SpaceAdd) const addSpace = () => pushModal(SpaceAdd)
</script> </script>
<div class="column menu gap-2"> <div class="column menu gap-2">
{#if PLATFORM_RELAY} {#each PLATFORM_RELAYS as url (url)}
<MenuSpacesItem url={PLATFORM_RELAY} /> <MenuSpacesItem {url} />
<Divider /> {:else}
{:else if $userRoomsByUrl.size > 0} {#if $userRoomsByUrl.size > 0}
{#each $userRoomsByUrl.keys() as url (url)} {#each $userRoomsByUrl.keys() as url (url)}
<MenuSpacesItem {url} /> <MenuSpacesItem {url} />
{/each} {/each}
<Divider /> <Divider />
{/if} {/if}
{#if !PLATFORM_RELAY}
<Button onclick={addSpace}> <Button onclick={addSpace}>
<CardButton> <CardButton>
{#snippet icon()} {#snippet icon()}
@@ -35,5 +34,5 @@
{/snippet} {/snippet}
</CardButton> </CardButton>
</Button> </Button>
{/if} {/each}
</div> </div>
+5 -11
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
@@ -11,15 +10,10 @@
const {url, event} = $props() const {url, event} = $props()
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
const onEmoji = (emoji: NativeEmoji) => const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, content: emoji.unicode, relays: [url]}) publishReaction({event, content: emoji.unicode, relays: [url]})
@@ -28,7 +22,7 @@
<NoteCard {event} {url} class="card2 bg-alt"> <NoteCard {event} {url} class="card2 bg-alt">
<NoteContent {event} expandMode="inline" /> <NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2"> <div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right"> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
<EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box"> <EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box">
<Icon icon="smile-circle" size={4} /> <Icon icon="smile-circle" size={4} />
</EmojiButton> </EmojiButton>
+15 -36
View File
@@ -1,16 +1,11 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {formatTimestampRelative} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {load} from "@welshman/net"
import {Router} from "@welshman/router"
import {repository, loadRelaySelections} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte" import ProfileInfo from "@app/components/ProfileInfo.svelte"
import {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 = { type Props = {
pubkey: string pubkey: string
@@ -19,37 +14,21 @@
const {pubkey, url}: Props = $props() const {pubkey, url}: Props = $props()
const filters: Filter[] = [{authors: [pubkey], limit: 1}] const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
const events = deriveEvents(repository, {filters})
onMount(async () => {
// Make sure we have their relay selections before we load their posts
await loadRelaySelections(pubkey)
// Load at least one note, regardless of time frame
load({
filters: [{authors: [pubkey], limit: 1}],
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
})
})
</script> </script>
<div class="card2 bg-alt col-2 shadow-xl"> <div class="card2 bg-alt flex flex-col gap-4 shadow-xl">
<div class="flex justify-between"> <div class="flex justify-between">
<Profile {pubkey} {url} /> <Profile {pubkey} {url} />
<Link class="btn btn-primary hidden sm:flex" href={makeChatPath([pubkey])}> <Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
<Icon icon="letter" /> <Icon icon="user-circle" />
Start a Chat View Profile
</Link> </Button>
</div> </div>
<ProfileInfo {pubkey} {url} /> <ProfileInfo {pubkey} {url} />
{#if $events.length > 0} <ProfileBadges {pubkey} {url} />
<div class="bg-alt badge badge-neutral border-none"> <Button onclick={openProfile} class="btn btn-primary sm:hidden">
Last active {formatTimestampRelative($events[0].created_at)} <Icon icon="user-circle" />
</div> View Profile
{/if} </Button>
<Link class="btn btn-primary sm:hidden" href={makeChatPath([pubkey])}>
<Icon icon="letter" />
Start a Chat
</Link>
</div> </div>
+50 -17
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {splitAt} from "@welshman/lib"
import {userProfile} from "@welshman/app" import {userProfile} from "@welshman/app"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
@@ -8,50 +10,76 @@
import SpaceAdd from "@app/components/SpaceAdd.svelte" import SpaceAdd from "@app/components/SpaceAdd.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte" import ChatEnable from "@app/components/ChatEnable.svelte"
import MenuSpaces from "@app/components/MenuSpaces.svelte" import MenuSpaces from "@app/components/MenuSpaces.svelte"
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte" import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte" import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state" import {userRoomsByUrl, canDecrypt, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications" import {notifications} from "@app/notifications"
interface Props {
children?: import("svelte").Snippet type Props = {
children?: Snippet
} }
const {children}: Props = $props() const {children}: Props = $props()
const addSpace = () => pushModal(SpaceAdd) const addSpace = () => pushModal(SpaceAdd)
const showSpacesMenu = () => (spacePaths.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd)) const showSpacesMenu = () => (spaceUrls.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd))
const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls})
const showSettingsMenu = () => pushModal(MenuSettings) const showSettingsMenu = () => pushModal(MenuSettings)
const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"})) const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
const hasNotification = (url: string) => {
const path = makeSpacePath(url)
return !$page.url.pathname.startsWith(path) && $notifications.has(path)
}
let windowHeight = $state(0)
const itemHeight = 56
const navPadding = 6 * itemHeight
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
const spaceUrls = $derived(Array.from($userRoomsByUrl.keys())) const spaceUrls = $derived(Array.from($userRoomsByUrl.keys()))
const spacePaths = $derived(spaceUrls.map(url => makeSpacePath(url))) const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, spaceUrls))
const anySpaceNotifications = $derived( const anySpaceNotifications = $derived(spaceUrls.some(hasNotification))
spacePaths.some(path => !$page.url.pathname.startsWith(path) && $notifications.has(path)), const otherSpaceNotifications = $derived(secondarySpaceUrls.some(hasNotification))
)
</script> </script>
<div class="sail sait saib relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block"> <svelte:window bind:innerHeight={windowHeight} />
<div
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
<div class="flex h-full flex-col justify-between"> <div class="flex h-full flex-col justify-between">
<div> <div>
{#if PLATFORM_RELAY} {#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace url={PLATFORM_RELAY} /> <PrimaryNavItemSpace {url} />
{:else} {:else}
<PrimaryNavItem title="Home" href="/home" class="tooltip-right"> <PrimaryNavItem title="Home" href="/home" class="tooltip-right">
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" /> <Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
<Divider /> <Divider />
{#each spaceUrls as url (url)} {#each primarySpaceUrls as url (url)}
<PrimaryNavItemSpace {url} /> <PrimaryNavItemSpace {url} />
{/each} {/each}
{#if secondarySpaceUrls.length > 0}
<PrimaryNavItem
title="Other Spaces"
class="tooltip-right"
onclick={showOtherSpacesMenu}
notification={otherSpaceNotifications}>
<Avatar icon="widget" class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
<PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right"> <PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right">
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" /> <Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
{/if} {/each}
</div> </div>
<div> <div>
<PrimaryNavItem <PrimaryNavItem
@@ -78,7 +106,7 @@
{@render children?.()} {@render children?.()}
<!-- a little extra something for ios --> <!-- a little extra something for ios -->
<div class="fixed bottom-0 left-0 right-0 z-nav h-14 bg-base-100 md:hidden"></div> <div class="fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden"></div>
<div <div
class="border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden"> class="border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
<div class="content-padding-x content-sizing flex justify-between px-2"> <div class="content-padding-x content-sizing flex justify-between px-2">
@@ -92,9 +120,14 @@
notification={$notifications.has("/chat")}> notification={$notifications.has("/chat")}>
<Avatar icon="letter" class="!h-10 !w-10" /> <Avatar icon="letter" class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem title="Spaces" onclick={showSpacesMenu} notification={anySpaceNotifications}> {#if PLATFORM_RELAYS.length !== 1}
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" /> <PrimaryNavItem
</PrimaryNavItem> title="Spaces"
onclick={showSpacesMenu}
notification={anySpaceNotifications}>
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
</div> </div>
<PrimaryNavItem title="Settings" onclick={showSettingsMenu}> <PrimaryNavItem title="Settings" onclick={showSettingsMenu}>
<Avatar icon="settings" src={$userProfile?.picture} class="!h-10 !w-10" /> <Avatar icon="settings" src={$userProfile?.picture} class="!h-10 !w-10" />
+25 -16
View File
@@ -1,54 +1,63 @@
<script lang="ts"> <script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {removeNil} from "@welshman/lib" import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util" import {displayPubkey} from "@welshman/util"
import { import {
session,
userFollows,
deriveUserWotScore,
deriveHandleForPubkey, deriveHandleForPubkey,
displayHandle, displayHandle,
deriveProfile, deriveProfile,
deriveProfileDisplay, deriveProfileDisplay,
} from "@welshman/app" } from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import WotScore from "@lib/components/WotScore.svelte" import WotScore from "@app/components/WotScore.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {clip} from "@app/toast"
type Props = { type Props = {
pubkey: string pubkey: string
url?: string url?: string
showPubkey?: boolean
avatarSize?: number
} }
const {pubkey, url}: Props = $props() const {pubkey, url, showPubkey, avatarSize = 10}: Props = $props()
const relays = removeNil([url]) const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays) const profile = deriveProfile(pubkey, relays)
const profileDisplay = deriveProfileDisplay(pubkey, relays) const profileDisplay = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey) const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey, url}) const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
const following = $derived( const copyPubkey = () => clip(nip19.npubEncode(pubkey))
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
)
</script> </script>
<div class="flex max-w-full gap-3"> <div class="flex max-w-full items-start gap-3">
<Button onclick={openProfile} class="py-1"> <Button onclick={openProfile} class="py-1">
<Avatar src={$profile?.picture} size={10} /> <Avatar src={$profile?.picture} size={avatarSize} />
</Button> </Button>
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis"> <Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay} {$profileDisplay}
</Button> </Button>
<WotScore score={$score} active={following} /> <WotScore {pubkey} />
</div>
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
</div> </div>
{#if $handle}
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{displayHandle($handle)}
</div>
{/if}
{#if showPubkey}
<div class="flex items-center gap-1 overflow-hidden text-ellipsis text-xs opacity-60">
{displayPubkey(pubkey)}
<Button onclick={copyPubkey} class="pt-1">
<Icon size={3} icon="copy" />
</Button>
</div>
{/if}
</div> </div>
</div> </div>
+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> </script>
<div class="flex pr-3"> <div class="flex pr-3">
{#each props.pubkeys.slice(0, 15) as pubkey (pubkey)} {#each props.pubkeys.toSorted().slice(0, 15) as pubkey (pubkey)}
<div class="z-feature -mr-3 inline-block"> <div class="z-feature -mr-3 inline-block">
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {...props} /> <ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {...props} />
</div> </div>
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {chunk, sleep, uniq} from "@welshman/lib" import {chunk, sleep, uniq} from "@welshman/lib"
import { import {
createEvent, makeEvent,
createProfile, createProfile,
PROFILE, PROFILE,
DELETE, DELETE,
@@ -36,8 +36,8 @@
} }
const chunks = chunk(500, repository.query([{authors: [$pubkey!]}])) const chunks = chunk(500, repository.query([{authors: [$pubkey!]}]))
const profileEvent = createEvent(PROFILE, createProfile({name: "[deleted]"})) const profileEvent = makeEvent(PROFILE, createProfile({name: "[deleted]"}))
const vanishEvent = createEvent(62, {tags: [["relay", "ALL_RELAYS"]]}) const vanishEvent = makeEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
const denominator = chunks.length + 2 const denominator = chunks.length + 2
const relays = uniq([ const relays = uniq([
...INDEXER_RELAYS, ...INDEXER_RELAYS,
@@ -75,7 +75,7 @@
} }
} }
await publishThunk({relays, event: createEvent(DELETE, {tags})}) await publishThunk({relays, event: makeEvent(DELETE, {tags})})
await incrementProgress() await incrementProgress()
} }
+9 -43
View File
@@ -1,23 +1,13 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {
session,
userFollows,
deriveUserWotScore,
deriveHandleForPubkey,
displayHandle,
deriveProfile,
deriveProfileDisplay,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import WotScore from "@lib/components/WotScore.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte" import ProfileInfo from "@app/components/ProfileInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte" import ChatEnable from "@app/components/ChatEnable.svelte"
import {canDecrypt, pubkeyLink} from "@app/state" import {canDecrypt, pubkeyLink} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@@ -30,50 +20,26 @@
const {pubkey, url}: Props = $props() const {pubkey, url}: Props = $props()
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const display = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const back = () => history.back() const back = () => history.back()
const chatPath = makeChatPath([pubkey]) const chatPath = makeChatPath([pubkey])
const openChat = () => ($canDecrypt ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath})) const openChat = () => ($canDecrypt ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath}))
const following = $derived(
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
)
</script> </script>
<div class="column gap-4"> <div class="flex flex-col gap-4">
<div class="flex max-w-full gap-3"> <Profile showPubkey avatarSize={14} {pubkey} {url} />
<span class="py-1">
<Avatar src={$profile?.picture} size={10} />
</span>
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
<span class="text-bold overflow-hidden text-ellipsis">
{$display}
</span>
<WotScore score={$score} active={following} />
</div>
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
</div>
</div>
</div>
<ProfileInfo {pubkey} {url} /> <ProfileInfo {pubkey} {url} />
<ProfileBadges {pubkey} {url} />
<ModalFooter> <ModalFooter>
<Button onclick={back} class="btn btn-link"> <Button onclick={back} class="hidden md:btn md:btn-link">
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<div class="flex gap-2"> <div class="flex gap-2">
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral"> <Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
<Icon icon="user-circle" /> <Avatar src="/coracle.png" />
See Complete Profile Open in Coracle
</Link> </Link>
<Button onclick={openChat} class="btn btn-primary"> <Button onclick={openChat} class="btn btn-primary">
<Icon icon="letter" /> <Icon icon="letter" />
+8 -4
View File
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import {nthNe} from "@welshman/lib"
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import { import {
getTag, getTag,
createEvent, makeEvent,
makeProfile, makeProfile,
editProfile, editProfile,
createProfile, createProfile,
@@ -24,16 +25,19 @@
const back = () => history.back() const back = () => history.back()
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => { const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const router = Router.get()
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const relays = [...getMembershipUrls($userMembership)] const scenarios = [router.FromRelays(getMembershipUrls($userMembership))]
if (shouldBroadcast) { if (shouldBroadcast) {
relays.push(...Router.get().FromUser().getUrls()) scenarios.push(router.FromUser(), router.Index())
template.tags = template.tags.filter(nthNe(0, "-"))
} else { } else {
template.tags = uniqTags([...template.tags, PROTECTED]) template.tags = uniqTags([...template.tags, PROTECTED])
} }
const event = createEvent(template.kind, template) const event = makeEvent(template.kind, template)
const relays = router.merge(scenarios).getUrls()
publishThunk({event, relays}) publishThunk({event, relays})
pushToast({message: "Your profile has been updated!"}) pushToast({message: "Your profile has been updated!"})
+2 -1
View File
@@ -45,7 +45,8 @@
e => e.id, e => e.id,
sortBy(e => -e.created_at, buffer), sortBy(e => -e.created_at, buffer),
) )
events = [...events, ...buffer.splice(0, 5)]
events = uniqBy(e => e.id, [...events, ...buffer.splice(0, 5)])
if (buffer.length < 50) { if (buffer.length < 50) {
ctrl.load(50) ctrl.load(50)
+1 -1
View File
@@ -14,5 +14,5 @@
</script> </script>
{#if $profile} {#if $profile}
<Content event={{content: $profile.about, tags: []}} hideMediaAtDepth={0} /> <Content event={{content: $profile.about || "", tags: []}} hideMediaAtDepth={0} />
{/if} {/if}
+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"> <script lang="ts">
import cx from "classnames"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
@@ -8,13 +9,15 @@
type Props = { type Props = {
pubkey: string pubkey: string
url?: string url?: string
class?: string
unstyled?: boolean
} }
const {pubkey, url}: Props = $props() const {pubkey, url, unstyled, ...props}: Props = $props()
const openProfile = () => pushModal(ProfileDetail, {pubkey, url}) const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script> </script>
<Button onclick={preventDefault(openProfile)} class="link-content"> <Button onclick={preventDefault(openProfile)} class={cx(props.class, {"link-content": !unstyled})}>
@<ProfileName {pubkey} {url} /> @<ProfileName {pubkey} {url} />
</Button> </Button>
+6 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import type {Writable} from "svelte/store"
import {type Instance} from "tippy.js" import {type Instance} from "tippy.js"
import {append, remove, uniq} from "@welshman/lib" import {append, remove, uniq} from "@welshman/lib"
import {profileSearch} from "@welshman/app" import {profileSearch} from "@welshman/app"
@@ -15,11 +16,10 @@
interface Props { interface Props {
value: string[] value: string[]
autofocus?: boolean autofocus?: boolean
term?: Writable<string>
} }
let {value = $bindable(), autofocus = false}: Props = $props() let {value = $bindable(), term = writable(""), autofocus = false}: Props = $props()
const term = writable("")
const search = (term: string) => $profileSearch.searchValues(term) const search = (term: string) => $profileSearch.searchValues(term)
@@ -44,6 +44,9 @@
let instance: any = $state() let instance: any = $state()
$effect(() => { $effect(() => {
// @ts-ignore
oninput?.($term)
if ($term) { if ($term) {
popover?.show() popover?.show()
} else { } else {
+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>
+2 -2
View File
@@ -4,7 +4,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {clip} from "@app/toast" import {clip} from "@app/toast"
const {code} = $props() const {code, ...props} = $props()
let canvas: Element | undefined = $state() let canvas: Element | undefined = $state()
let wrapper: Element | undefined = $state() let wrapper: Element | undefined = $state()
@@ -26,7 +26,7 @@
}) })
</script> </script>
<Button class="max-w-full" onclick={copy}> <Button class="max-w-full {props.class}" onclick={copy}>
<div bind:this={wrapper} style={`height: ${height}px`}> <div bind:this={wrapper} style={`height: ${height}px`}>
<canvas <canvas
class="rounded-box" class="rounded-box"
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import {parse, isEmoji, renderAsHtml} from "@welshman/content"
import Icon from "@lib/components/Icon.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
export let event
</script>
{#if event.content === "+" || event.content === ""}
<Icon icon="heart" />
{:else if event.content === "-"}
<Icon icon="thumbs-down" />
{:else}
{#each parse(event) as parsed}
{#if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else}
{@html renderAsHtml(parsed)}
{/if}
{/each}
{/if}
+74 -18
View File
@@ -1,21 +1,33 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {groupBy, uniq, uniqBy, batch, displayList} from "@welshman/lib" import {groupBy, sum, uniq, uniqBy, batch, displayList} from "@welshman/lib"
import {REACTION, getReplyFilters, getTag, REPORT, DELETE} from "@welshman/util" import {
import type {TrustedEvent} from "@welshman/util" REACTION,
import {deriveEvents} from "@welshman/store" ZAP_RESPONSE,
getReplyFilters,
getEmojiTags,
getEmojiTag,
fromMsats,
getTag,
REPORT,
DELETE,
} from "@welshman/util"
import type {TrustedEvent, EventContent, Zap} from "@welshman/util"
import {deriveEvents, deriveEventsMapped} from "@welshman/store"
import {load} from "@welshman/net" import {load} from "@welshman/net"
import {pubkey, repository, displayProfileByPubkey} from "@welshman/app" import {pubkey, repository, getValidZap, displayProfileByPubkey} from "@welshman/app"
import {isMobile, preventDefault, stopPropagation} from "@lib/html" import {isMobile, preventDefault, stopPropagation} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Reaction from "@app/components/Reaction.svelte"
import EventReportDetails from "@app/components/EventReportDetails.svelte" import EventReportDetails from "@app/components/EventReportDetails.svelte"
import {displayReaction} from "@app/state" import {REACTION_KINDS} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
interface Props { interface Props {
event: any event: TrustedEvent
onReactionClick: any deleteReaction: (event: TrustedEvent) => void
createReaction: (event: EventContent) => void
url?: string url?: string
reactionClass?: string reactionClass?: string
noTooltip?: boolean noTooltip?: boolean
@@ -24,7 +36,8 @@
const { const {
event, event,
onReactionClick, deleteReaction,
createReaction,
url = "", url = "",
reactionClass = "", reactionClass = "",
noTooltip = false, noTooltip = false,
@@ -39,17 +52,42 @@
filters: [{kinds: [REACTION], "#e": [event.id]}], filters: [{kinds: [REACTION], "#e": [event.id]}],
}) })
const zaps = deriveEventsMapped<Zap>(repository, {
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
itemToEvent: item => item.response,
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
})
const onReactionClick = (events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) {
deleteReaction(reaction)
} else {
const [event] = events
createReaction({
content: event.content,
tags: getEmojiTags(event.content.replace(/:/g, ""), event.tags),
})
}
}
const onReportClick = () => pushModal(EventReportDetails, {url, event}) const onReportClick = () => pushModal(EventReportDetails, {url, event})
const reportReasons = $derived(uniq($reports.map(e => getTag("e", e.tags)?.[2]))) const reportReasons = $derived(uniq($reports.map(e => getTag("e", e.tags)?.[2])))
const getReactionKey = (e: TrustedEvent) => getEmojiTag(e.content, e.tags)?.join("") || e.content
const groupedReactions = $derived( const groupedReactions = $derived(
groupBy( groupBy(
e => e.content, getReactionKey,
uniqBy(e => e.pubkey + e.content, $reactions), uniqBy(e => `${e.pubkey}${getReactionKey(e)}`, $reactions),
), ),
) )
const groupedZaps = $derived(groupBy(e => getReactionKey(e.request), $zaps))
onMount(() => { onMount(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -57,7 +95,7 @@
load({ load({
relays: [url], relays: [url],
signal: controller.signal, signal: controller.signal,
filters: getReplyFilters([event], {kinds: [REACTION, REPORT, DELETE]}), filters: getReplyFilters([event], {kinds: [REPORT, DELETE, ...REACTION_KINDS]}),
onEvent: batch(300, (events: TrustedEvent[]) => { onEvent: batch(300, (events: TrustedEvent[]) => {
load({ load({
relays: [url], relays: [url],
@@ -73,12 +111,12 @@
}) })
</script> </script>
{#if $reactions.length > 0 || $reports.length > 0} {#if $reactions.length > 0 || $zaps.length || $reports.length > 0}
<div class="flex min-w-0 flex-wrap gap-2"> <div class="flex min-w-0 flex-wrap gap-2">
{#if url && $reports.length > 0} {#if url && $reports.length > 0}
<button <button
type="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="btn btn-error btn-xs tooltip-right flex items-center gap-1 rounded-full"
class:tooltip={!noTooltip && !isMobile} class:tooltip={!noTooltip && !isMobile}
onclick={stopPropagation(preventDefault(onReportClick))}> onclick={stopPropagation(preventDefault(onReportClick))}>
@@ -86,12 +124,30 @@
<span>{$reports.length}</span> <span>{$reports.length}</span>
</button> </button>
{/if} {/if}
{#each groupedReactions.entries() as [content, events]} {#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 pubkeys = events.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)} {@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))} {@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} reacted ${displayReaction(content)}`} {@const tooltip = `${info} reacted`}
{@const onClick = () => onReactionClick(content, events)} {@const onClick = () => onReactionClick(events)}
<button <button
type="button" type="button"
data-tip={tooltip} data-tip={tooltip}
@@ -101,7 +157,7 @@
class:border-solid={isOwn} class:border-solid={isOwn}
class:border-primary={isOwn} class:border-primary={isOwn}
onclick={stopPropagation(preventDefault(onClick))}> onclick={stopPropagation(preventDefault(onClick))}>
<span>{displayReaction(content)}</span> <Reaction event={events[0]} />
{#if events.length > 1} {#if events.length > 1}
<span>{events.length}</span> <span>{events.length}</span>
{/if} {/if}
+1 -1
View File
@@ -29,7 +29,7 @@
>{displayUrl($relay.profile.contact)}</Link> >{displayUrl($relay.profile.contact)}</Link>
&bull; &bull;
{/if} {/if}
{#if $relay?.profile?.supported_nips} {#if Array.isArray($relay?.profile?.supported_nips)}
<span <span
class="tooltip cursor-pointer underline" class="tooltip cursor-pointer underline"
data-tip="NIPs supported: {$relay.profile.supported_nips.join(', ')}"> data-tip="NIPs supported: {$relay.profile.supported_nips.join(', ')}">
+43 -36
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {randomId} from "@welshman/lib" import {uniqBy, nth} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl, makeRoomMeta} from "@welshman/util"
import {deriveRelay} from "@welshman/app" import {deriveRelay, getThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
@@ -10,41 +10,41 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {hasNip29} from "@app/state" import {hasNip29, loadChannel} from "@app/state"
import {addRoomMembership, nip29, getThunkError} from "@app/commands"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
const {url} = $props() const {url} = $props()
const room = randomId() const room = makeRoomMeta()
const relay = deriveRelay(url) const relay = deriveRelay(url)
const back = () => history.back() const back = () => history.back()
const tryCreate = async () => { const tryCreate = async () => {
if (hasNip29($relay)) { room.tags = uniqBy(nth(0), [...room.tags, ["name", name]])
const createMessage = await getThunkError(nip29.createRoom(url, room))
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) { const createMessage = await getThunkError(createRoom(url, room))
return pushToast({theme: "error", message: createMessage})
}
const editMessage = await getThunkError(nip29.editMeta(url, room, {name})) if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
return pushToast({theme: "error", message: createMessage})
if (editMessage) {
return pushToast({theme: "error", message: editMessage})
}
const joinMessage = await getThunkError(nip29.joinRoom(url, room))
if (joinMessage && !joinMessage.includes("already")) {
return pushToast({theme: "error", message: joinMessage})
}
} }
addRoomMembership(url, room, name) const editMessage = await getThunkError(editRoom(url, room))
goto(makeSpacePath(url, room))
if (editMessage) {
return pushToast({theme: "error", message: editMessage})
}
const joinMessage = await getThunkError(joinRoom(url, room))
if (joinMessage && !joinMessage.includes("already")) {
return pushToast({theme: "error", message: joinMessage})
}
await loadChannel(url, room.id)
goto(makeSpacePath(url, room.id))
} }
const create = async () => { const create = async () => {
@@ -72,23 +72,30 @@
</div> </div>
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
<Field> {#if hasNip29($relay)}
{#snippet label()} <Field>
<p>Room Name</p> {#snippet label()}
{/snippet} <p>Room Name</p>
{#snippet input()} {/snippet}
<label class="input input-bordered flex w-full items-center gap-2"> {#snippet input()}
<Icon icon="hashtag" /> <label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={name} class="grow" type="text" /> <Icon icon="hashtag" />
</label> <input bind:value={name} class="grow" type="text" />
{/snippet} </label>
</Field> {/snippet}
</Field>
{:else}
<p class="bg-alt card2 row-2">
<Icon icon="danger" />
This relay does not support creating rooms.
</p>
{/if}
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={!name || loading}> <Button type="submit" class="btn btn-primary" disabled={!name || loading || !hasNip29($relay)}>
<Spinner {loading}>Create Room</Spinner> <Spinner {loading}>Create Room</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {encrypt} from "nostr-tools/nip49" import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@noble/hashes/utils" import {hexToBytes} from "@welshman/lib"
import {makeSecret} from "@welshman/signer" import {makeSecret} from "@welshman/signer"
import {preventDefault, downloadText} from "@lib/html" import {preventDefault, downloadText} from "@lib/html"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import {PROFILE, createProfile, makeProfile, createEvent} from "@welshman/util" import {PROFILE, createProfile, makeProfile, makeEvent} from "@welshman/util"
import {loginWithNip01, publishThunk} from "@welshman/app" import {loginWithNip01, publishThunk} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte" import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
@@ -18,7 +18,7 @@
} }
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => { const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const event = createEvent(PROFILE, createProfile(profile)) const event = makeEvent(PROFILE, createProfile(profile))
const relays = shouldBroadcast ? INDEXER_RELAYS : [] const relays = shouldBroadcast ? INDEXER_RELAYS : []
loginWithNip01(secret) loginWithNip01(secret)
@@ -0,0 +1,84 @@
<script lang="ts">
import {displayUrl} from "@welshman/lib"
import {Pool, AuthStatus} from "@welshman/net"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SpaceJoinConfirm, {confirmSpaceJoin} from "@app/components/SpaceJoinConfirm.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import {attemptRelayAccess} from "@app/commands"
type Props = {
url: string
}
const {url}: Props = $props()
const back = () => history.back()
const joinRelay = async () => {
const error = await attemptRelayAccess(url, claim)
if (error) {
return pushToast({theme: "error", message: error, timeout: 30_000})
}
const socket = Pool.get().get(url)
if (socket.auth.status === AuthStatus.None) {
pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
} else {
await confirmSpaceJoin(url)
}
}
const join = async () => {
loading = true
try {
await joinRelay()
} finally {
loading = false
}
}
let claim = $state("")
let loading = $state(false)
</script>
<form class="column gap-4" onsubmit={preventDefault(join)}>
<ModalHeader>
{#snippet title()}
<div>Request Access</div>
{/snippet}
{#snippet info()}
<div>Enter an invite code below to request access to {displayUrl(url)}.</div>
{/snippet}
</ModalHeader>
<Field>
{#snippet label()}
<p>Invite code*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="link-round" />
<input bind:value={claim} class="grow" type="text" />
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Join Space</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+11 -52
View File
@@ -1,49 +1,23 @@
<script lang="ts"> <script lang="ts">
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import Spinner from "@lib/components/Spinner.svelte" import {parse, renderAsHtml} from "@welshman/content"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {ucFirst} from "@lib/util"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast" import SpaceAccessRequest from "@app/components/SpaceAccessRequest.svelte"
import {clearModals} from "@app/modal" import {pushModal} from "@app/modal"
import {attemptRelayAccess} from "@app/commands"
const {url, error} = $props() const {url, error} = $props()
const back = () => history.back() const back = () => history.back()
const joinRelay = async (claim: string) => { const requestAccess = () => pushModal(SpaceAccessRequest, {url})
const error = await attemptRelayAccess(url, claim)
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(claim)
} finally {
loading = false
}
}
let claim = $state("")
let loading = $state(false)
</script> </script>
<form class="column gap-4" onsubmit={preventDefault(join)}> <form class="column gap-4" onsubmit={preventDefault(requestAccess)}>
<ModalHeader> <ModalHeader>
{#snippet title()} {#snippet title()}
<div>Access Error</div> <div>Access Error</div>
@@ -53,33 +27,18 @@
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
<p> <p>
We received an error from the relay indicating you don't have access to {displayRelayUrl(url)}. We received an error from the relay indicating you don't have access to {displayRelayUrl(url)}:
</p> </p>
<p class="border-l border-solid border-error pl-4 text-error"> <p class="bg-alt card2 welshman-content">
{error} {@html renderAsHtml(parse({content: ucFirst(error)}))}
</p> </p>
<p>If you have one, you can try entering an invite code below to request access.</p>
<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}
{#snippet info()}
<p>Enter an invite code provided to you by the admin of the relay.</p>
{/snippet}
</Field>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={!claim || loading}> <Button type="submit" class="btn btn-primary">
<Spinner {loading}>Request Access</Spinner> Request Access
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
</ModalFooter> </ModalFooter>
@@ -28,13 +28,19 @@
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
<p> <p>
<Link class="text-primary" external href="https://relay.tools">relay.tools</Link> is a third-party <Link class="link" external href="https://relay.tools">relay.tools</Link> is a third-party service
service that allows anyone to run their own relay for use with {PLATFORM_NAME}, or any other that allows anyone to run their own relay for use with {PLATFORM_NAME}, or any other
nostr-compatible app. nostr-compatible app.
</p> </p>
<p> <p>
Once you've created a relay of your own, come back here to link {PLATFORM_NAME} with your new relay. Once you've created a relay of your own, come back here to link {PLATFORM_NAME} with your new relay.
</p> </p>
<p>
Alternatively, you can
<Link external class="link" href="https://github.com/coracle-social/frith"
>run your own community relay</Link
>.
</p>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
+44 -14
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {tryCatch} from "@welshman/lib" import {tryCatch, first, removeNil} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util" import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {Pool, AuthStatus} from "@welshman/net" import {Pool, AuthStatus} from "@welshman/net"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
@@ -17,21 +17,36 @@
const back = () => history.back() const back = () => history.back()
const joinRelay = async (invite: string) => { const joinRelay = async () => {
const [raw, claim] = invite.split("|") const promises: Promise<string | undefined>[] = []
const url = normalizeRelayUrl(raw)
const error = await attemptRelayAccess(url, claim)
if (error) { const [rawUrl, rawClaim] = url.split("|")
return pushToast({theme: "error", message: error}) const normalizedUrl = normalizeRelayUrl(rawUrl)
if (claim) {
promises.push(attemptRelayAccess(normalizedUrl, claim))
} }
const socket = Pool.get().get(url) 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(normalizedUrl)
if (socket.auth.status === AuthStatus.None) { if (socket.auth.status === AuthStatus.None) {
pushModal(SpaceJoinConfirm, {url}, {replaceState: true}) pushModal(SpaceJoinConfirm, {url: normalizedUrl}, {replaceState: true})
} else { } else {
await confirmSpaceJoin(url) await confirmSpaceJoin(normalizedUrl)
} }
} }
@@ -39,13 +54,14 @@
loading = true loading = true
try { try {
await joinRelay(url) await joinRelay()
} finally { } finally {
loading = false loading = false
} }
} }
let url = $state("") let url = $state("")
let claim = $state("")
let loading = $state(false) let loading = $state(false)
const linkIsValid = $derived( const linkIsValid = $derived(
@@ -59,12 +75,12 @@
<div>Join a Space</div> <div>Join a Space</div>
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
<div>Enter an invite code below to join an existing space.</div> <div>Enter a relay URL below to join an existing space.</div>
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
<Field> <Field>
{#snippet label()} {#snippet label()}
<p>Invite code*</p> <p>Relay URL*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
@@ -74,11 +90,25 @@
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
<p> <p>
You can also directly join any relay by entering its URL here. Enter the URL of the relay that hosts the space you'd like to join.
<Button class="link" onclick={() => pushModal(InfoRelay)}>What is a relay?</Button> <Button class="link" onclick={() => pushModal(InfoRelay)}>What is a relay?</Button>
</p> </p>
{/snippet} {/snippet}
</Field> </Field>
<Field>
{#snippet label()}
<p>Invite Code (optional)</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="ticket" />
<input bind:value={claim} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
<p>If you have an invite code, enter it here to get access.</p>
{/snippet}
</Field>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
+2 -1
View File
@@ -7,7 +7,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {clearModals} from "@app/modal" import {clearModals} from "@app/modal"
import {addSpaceMembership} from "@app/commands" import {addSpaceMembership, broadcastUserData} from "@app/commands"
const {url} = $props() const {url} = $props()
@@ -16,6 +16,7 @@
const tryJoin = async () => { const tryJoin = async () => {
await addSpaceMembership(url) await addSpaceMembership(url)
broadcastUserData([url])
clearModals() clearModals()
} }
+15 -2
View File
@@ -1,13 +1,26 @@
<script module lang="ts"> <script module lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {ROOM_META} from "@welshman/util"
import {load} from "@welshman/net"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {addSpaceMembership} from "@app/commands" import {addSpaceMembership, broadcastUserData} from "@app/commands"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
export const confirmSpaceJoin = async (url: string) => { export const confirmSpaceJoin = async (url: string) => {
await addSpaceMembership(url) await addSpaceMembership(url)
goto(makeSpacePath(url), {replaceState: true}) const path = makeSpacePath(url)
if (window.location.pathname === path) {
load({
relays: [url],
filters: [{kinds: [ROOM_META]}],
})
}
broadcastUserData([url])
goto(path, {replaceState: true})
pushToast({ pushToast({
message: "Welcome to the space!", message: "Welcome to the space!",
+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>
+5 -11
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
@@ -18,20 +17,15 @@
const path = makeThreadPath(url, event.id) const path = makeThreadPath(url, event.id)
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
</script> </script>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2"> <div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} /> <ThunkStatusOrDeleted {event} />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
+4 -9
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {createEvent, THREAD} from "@welshman/util" import {makeEvent, THREAD} from "@welshman/util"
import {publishThunk} from "@welshman/app" import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -10,7 +10,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {GENERAL, tagRoom, PROTECTED} from "@app/state" import {PROTECTED} from "@app/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
const {url} = $props() const {url} = $props()
@@ -41,16 +41,11 @@
}) })
} }
const tags = [ const tags = [...ed.storage.nostr.getEditorTags(), ["title", title], PROTECTED]
...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
["title", title],
PROTECTED,
]
publishThunk({ publishThunk({
relays: [url], relays: [url],
event: createEvent(THREAD, {content, tags}), event: makeEvent(THREAD, {content, tags}),
}) })
history.back() history.back()

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