Compare commits

..

86 Commits

Author SHA1 Message Date
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
134 changed files with 4215 additions and 1504 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_NAME=Flotilla
VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_RELAY=
VITE_PLATFORM_RELAYS=
VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/,wss://indexer.coracle.social/
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
VITE_VAPID_PUBLIC_KEY=
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+1 -1
View File
@@ -1 +1 @@
package-lock.json -diff
pnpm-lock.yaml -diff
+59
View File
@@ -0,0 +1,59 @@
name: Docker
on:
push:
branches: ['master']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
+2 -1
View File
@@ -1,5 +1,5 @@
# Env
.env.local
.env
# Vite
vite.config.js.timestamp-*
@@ -60,6 +60,7 @@ google-services.json
GoogleService-Info.plist
# IDEs and editors
.roo
.idea/
.vscode/
+6
View File
@@ -1,2 +1,8 @@
pnpm run lint
pnpm run check
if [[ ! -z $(cat package.json | grep 'link:') ]]; then
echo "Some packages are linked to local files!"
exit 1
fi
+41
View File
@@ -1,5 +1,46 @@
# Changelog
# 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
+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
# Deploy
To run your own Flotilla, it's as simple as:
- `pnpm install`
- `pnpm run build`
- `npx serve build`
## Environment
You can also optionally create an `.env.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_PLATFORM_URL` - The url where the app will be hosted. This is only used for build-time population of meta tags.
- `VITE_PLATFORM_NAME` - The name of the app
- `VITE_PLATFORM_LOGO` - A logo url for the app
- `VITE_PLATFORM_RELAY` - A relay url that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the platform relay the home page.
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
- `VITE_GLITCHTIP_API_KEY` - A Sentry DSN for use with glitchtip (error reporting)
@@ -28,84 +20,29 @@ You can also optionally create an `.env.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.
## Nginx/TLS (optional)
## Development
If you'd like to set up flotilla on a server you control, you'll want to set up a reverse proxy and provision a TSL certificate for the domain you'll be using. You should also make sure to add swap to your server.
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, a pre-commit hook will run to lint and typecheck your work.
There will be some parts of the following templates, for example `<SERVER NAME>`, which you'll need to fill in before running the code.
## Deployment
First, create an `A` record with your DNS provider pointing to the IP of your server. This will allow certbot to create your certificate later.
Next install `nginx`, `git`, and `certbot`. If you're on a debian- or ubuntu-based distro, run `sudo apt-get update && sudo apt-get install nginx git certbot python3-certbot-nginx`.
Now, create a new user where your code will be stored, clone the repository, fill in your `.env.local` file, and build the app.
To run your own Flotilla, it's as simple as:
```sh
# Replace with your password
PASSWORD=<YOUR PASSWORD HERE>
# Add the user and set a password
adduser flotilla
echo flotilla:$PASSWORD | chpasswd
# Login as flotilla
sudo su flotilla
# Go to flotilla's home directory
cd ~
# Install nvm, yarn, clone repos
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# Update PATH
. ~/.bashrc
# Clone repository and install dependencies
git clone https://github.com/coracle-social/flotilla.git
cd ~/flotilla
nvm install
nvm use
pnpm i
# Optionally create and populate .env.local to suit your use case
# Build the app
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
# Exit back to root
exit
pnpm install
pnpm run build
npx serve build
```
Once you've exited back to root, you can set up nginx. Place the following in a file named after your domain in the `/etc/nginx/sites-available` directory, for example, `flotilla.example.com`. This should match the `A` record you registered above.
Or, if you prefer to use a container:
```conf
server {
listen 80;
server_name <SERVER NAME>;
root /home/flotilla/flotilla/build;
index index.html;
location / {
try_files $uri /index.html;
}
}
```sh
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
```
Now you can run `certbot`, which will provision a TLS certificate for your domain and update your nginx configuration.
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
```sh
mkdir ./mount
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
```
certbot --nginx -d <SERVER NAME>
```
Now, enable the site and restart nginx. If you want to be careful, run `nginx -t` before restarting nginx.
```
ln -s /etc/nginx/sites-{available,enabled}/<SERVER NAME>
service nginx restart
```
Now, visit your domain. You should be all set up!
# Development
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, run `pnpm run format && pnpm run lint` and fix any errors that come up.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 16
versionName "1.0.2"
versionCode 21
versionName "1.2.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+3
View File
@@ -9,8 +9,11 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-push-notifications')
implementation project(':capawesome-capacitor-badge')
implementation project(':nostr-signer-capacitor-plugin')
}
+1
View File
@@ -34,4 +34,5 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>
+9
View File
@@ -2,11 +2,20 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/android/capacitor')
include ':capacitor-community-safe-area'
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications/android')
include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')
+2 -5
View File
@@ -6,11 +6,8 @@ git describe --tags --abbrev=0
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
# Remove link overrides
node remove-pnpm-overrides.js package.json
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
pnpm i --no-frozen-lockfile
# Install dependencies
CI=0 pnpm i
# Rebuild sharp
pnpm rebuild
+4 -4
View File
@@ -2,12 +2,12 @@
temp_env=$(declare -p -x)
if [ -f .env ]; then
source .env
if [ -f .env.template ]; then
source .env.template
fi
if [ -f .env.local ]; then
source .env.local
if [ -f .env ]; then
source .env
fi
# Avoid overwriting env vars provided directly
+4
View File
@@ -15,6 +15,10 @@ const config: CapacitorConfig = {
style: "DARK",
resizeOnFullScreen: true,
},
Badge: {
persist: true,
autoClear: true
},
},
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// server: {
+8 -4
View File
@@ -18,6 +18,7 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Flotilla Chat.entitlements"; sourceTree = "<group>"; };
1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
@@ -57,6 +58,7 @@
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
@@ -349,16 +351,17 @@
baseConfigurationReference = 7B9FA71C362B734D9F965709 /* Pods-Flotilla Chat.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.2;
MARKETING_VERSION = 1.2.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -374,16 +377,17 @@
baseConfigurationReference = 1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.2;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+9
View File
@@ -46,4 +46,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
// Enable push notifications https://capacitorjs.com/docs/apis/push-notifications
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
// Enable push notifications https://capacitorjs.com/docs/apis/push-notifications
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
}
+4
View File
@@ -49,5 +49,9 @@
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
+3
View File
@@ -11,8 +11,11 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@7.2.0_@capacitor+core@7.2.0/node_modules/@capacitor/ios'
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
end
+21 -31
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "1.0.2",
"version": "1.2.0",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -15,6 +15,7 @@
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.26.0",
"@sentry/cli": "^2.40.0",
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
@@ -37,32 +38,34 @@
},
"type": "module",
"dependencies": {
"@capacitor-community/safe-area": "7.0.0-alpha.1",
"@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0",
"@capacitor/cli": "^7.0.0",
"@capacitor/core": "^7.0.1",
"@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@noble/curves": "^1.5.0",
"@noble/hashes": "^1.4.0",
"@capacitor/push-notifications": "^7.0.1",
"@capawesome/capacitor-badge": "^7.0.1",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.35.0",
"@sveltejs/adapter-static": "^3.0.4",
"@tiptap/core": "^2.12.0",
"@types/qrcode": "^1.5.5",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "^0.2.4",
"@welshman/content": "^0.2.1",
"@welshman/dvm": "^0.2.0",
"@welshman/editor": "^0.2.1",
"@welshman/feeds": "^0.2.2",
"@welshman/lib": "^0.2.2",
"@welshman/net": "^0.2.3",
"@welshman/relay": "^0.2.0",
"@welshman/router": "^0.2.0",
"@welshman/signer": "^0.2.3",
"@welshman/store": "^0.2.0",
"@welshman/util": "^0.2.2",
"@welshman/app": "^0.3.8",
"@welshman/content": "^0.3.8",
"@welshman/editor": "^0.3.8",
"@welshman/feeds": "^0.3.8",
"@welshman/lib": "^0.3.8",
"@welshman/net": "^0.3.8",
"@welshman/relay": "^0.3.8",
"@welshman/router": "^0.3.8",
"@welshman/signer": "^0.3.8",
"@welshman/store": "^0.3.8",
"@welshman/util": "^0.3.8",
"compressorjs": "^1.2.1",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
@@ -71,25 +74,12 @@
"husky": "^9.1.6",
"idb": "^8.0.0",
"nostr-signer-capacitor-plugin": "^0.0.4",
"nostr-tools": "^2.7.2",
"nostr-tools": "^2.14.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4"
"qrcode": "^1.5.4",
"tippy.js": "^6.3.7"
},
"pnpm": {
"overrides": {
"@welshman/lib": "link:../welshman/packages/lib",
"@welshman/util": "link:../welshman/packages/util",
"@welshman/app": "link:../welshman/packages/app",
"@welshman/content": "link:../welshman/packages/content",
"@welshman/dvm": "link:../welshman/packages/dvm",
"@welshman/feeds": "link:../welshman/packages/feeds",
"@welshman/net": "link:../welshman/packages/net",
"@welshman/relay": "link:../welshman/packages/relay",
"@welshman/router": "link:../welshman/packages/router",
"@welshman/signer": "link:../welshman/packages/signer",
"@welshman/store": "link:../welshman/packages/store",
"@welshman/editor": "link:../welshman/packages/editor"
},
"ignoredBuiltDependencies": [
"@sentry/cli",
"esbuild"
+901 -62
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 {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
dotenv.config({path: ".env.local"})
dotenv.config({path: ".env"})
dotenv.config({path: ".env.template"})
export default defineConfig({
preset,
-20
View File
@@ -1,20 +0,0 @@
// This script is necessary for installing stuff on a host, since our links don't exist there.
import fs from "fs"
const pkgName = process.argv[2]
if (!pkgName?.endsWith("package.json")) {
console.log("File passed was not a package.json file")
process.exit(1)
}
const pkg = JSON.parse(fs.readFileSync(pkgName, "utf8"))
if (pkg.pnpm && pkg.pnpm.overrides) {
delete pkg.pnpm.overrides
fs.writeFileSync(pkgName, JSON.stringify(pkg, null, 2) + "\n")
console.log("Removed pnpm.overrides from package.json")
} else {
console.log("No pnpm.overrides found in package.json")
}
+20 -10
View File
@@ -46,6 +46,14 @@
:root {
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-200: oklch(var(--b2));
--base-300: oklch(var(--b3));
@@ -54,16 +62,6 @@
--primary-content: oklch(var(--pc));
--secondary: oklch(var(--s));
--secondary-content: oklch(var(--sc));
--sait: env(safe-area-inset-top);
--saib: env(safe-area-inset-bottom);
--sail: env(safe-area-inset-left);
--sair: env(safe-area-inset-right);
}
:root,
body,
html {
@apply bg-base-300;
}
/* safe area insets */
@@ -293,6 +291,14 @@ html {
--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 {
@apply max-h-[350px] overflow-y-auto p-2 px-4;
}
@@ -382,6 +388,10 @@ progress[value]::-webkit-progress-value {
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cw-full {
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
}
.cb {
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
}
+96 -98
View File
@@ -1,6 +1,6 @@
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {randomId, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
@@ -12,14 +12,14 @@ import {
FOLLOWS,
REACTION,
AUTH_JOIN,
GROUP_JOIN,
GROUP_LEAVE,
GROUP_CREATE,
GROUP_EDIT_META,
GROUPS,
ROOMS,
COMMENT,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
isSignedEvent,
createEvent,
makeEvent,
displayProfile,
normalizeRelayUrl,
makeList,
@@ -33,7 +33,7 @@ import {
getRelaysFromList,
RelayMode,
} from "@welshman/util"
import {Pool, PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
import {
pubkey,
@@ -52,15 +52,12 @@ import {
dropSession,
tagEventForComment,
tagEventForQuote,
thunkIsComplete,
getThunkError,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {
tagRoom,
PROTECTED,
userMembership,
INDEXER_RELAYS,
ALERT,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
userRoomsByUrl,
@@ -83,21 +80,6 @@ export const getPubkeyPetname = (pubkey: string) => {
return display
}
export const getThunkError = (thunk: Thunk) =>
new Promise<string>(resolve => {
thunk.subscribe($thunk => {
for (const [relay, status] of Object.entries($thunk.status)) {
if (status === PublishStatus.Failure) {
resolve($thunk.details[relay])
}
}
if (thunkIsComplete($thunk)) {
resolve("")
}
})
})
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) {
const nevent = nip19.neventEncode({
@@ -142,37 +124,10 @@ export const broadcastUserData = async (relays: string[]) => {
}
}
// NIP 29 stuff
export const nip29 = {
createRoom: (url: string, room: string) => {
const event = createEvent(GROUP_CREATE, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
editMeta: (url: string, room: string, meta: Record<string, string>) => {
const event = createEvent(GROUP_EDIT_META, {
tags: [tagRoom(room, url), ...Object.entries(meta)],
})
return publishThunk({event, relays: [url]})
},
joinRoom: (url: string, room: string) => {
const event = createEvent(GROUP_JOIN, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
leaveRoom: (url: string, room: string) => {
const event = createEvent(GROUP_LEAVE, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
}
// List updates
export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const list = get(userMembership) || makeList({kind: ROOMS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -180,7 +135,7 @@ export const addSpaceMembership = async (url: string) => {
}
export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const list = get(userMembership) || makeList({kind: ROOMS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -188,11 +143,11 @@ export const removeSpaceMembership = async (url: string) => {
return publishThunk({event, relays})
}
export const addRoomMembership = async (url: string, room: string, name: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
export const addRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: ROOMS})
const newTags = [
["r", url],
["group", room, url, name],
["group", room, url],
]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -201,7 +156,7 @@ export const addRoomMembership = async (url: string, room: string, name: string)
}
export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const list = get(userMembership) || makeList({kind: ROOMS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -222,7 +177,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
}
return publishThunk({
event: createEvent(list.kind, {tags}),
event: makeEvent(list.kind, {tags}),
relays: [
url,
...INDEXER_RELAYS,
@@ -244,7 +199,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
}
return publishThunk({
event: createEvent(list.kind, {tags}),
event: makeEvent(list.kind, {tags}),
relays: [
...INDEXER_RELAYS,
...Router.get().FromUser().getUrls(),
@@ -256,13 +211,18 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
// Relay access
export const attemptAuth = (url: string) =>
Pool.get()
.get(url)
.auth.attemptAuth(e => signer.get()?.sign(e))
export const checkRelayAccess = async (url: string, claim = "") => {
const socket = Pool.get().get(url)
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
await attemptAuth(url)
const thunk = publishThunk({
event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url],
})
@@ -276,9 +236,17 @@ export const checkRelayAccess = async (url: string, claim = "") => {
// If it's a strict NIP 29 relay don't worry about requesting access
// TODO: remove this if relay29 ever gets less strict
if (message !== "missing group (`h`) tag") {
return message
if (message === "missing group (`h`) tag") return
// Ignore messages about the relay ignoring ours
if (error?.startsWith("mute: ")) return
// Ignore rejected empty claims
if (!claim && error?.includes("invite code")) {
return `failed to request access to relay`
}
return message
}
}
@@ -309,7 +277,7 @@ export const checkRelayAuth = async (url: string, timeout = 3000) => {
const socket = Pool.get().get(url)
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
await attemptAuth(url)
// Only raise an error if it's not a timeout.
// If it is, odds are the problem is with our signer, not the relay
@@ -336,20 +304,26 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
// Actions
export const makeDelete = ({event}: {event: TrustedEvent}) => {
const tags = [["k", String(event.kind)], ...tagEvent(event)]
export const makeDelete = ({event, tags = []}: {event: TrustedEvent; tags?: string[][]}) => {
const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags]
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(PROTECTED)
tags.push(groupTag)
thisTags.push(PROTECTED, groupTag)
}
return createEvent(DELETE, {tags})
return makeEvent(DELETE, {tags: thisTags})
}
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
publishThunk({event: makeDelete({event}), relays})
export const publishDelete = ({
relays,
event,
tags = [],
}: {
relays: string[]
event: TrustedEvent
tags?: string[][]
}) => publishThunk({event: makeDelete({event, tags}), relays})
export type ReportParams = {
event: TrustedEvent
@@ -363,7 +337,7 @@ export const makeReport = ({event, reason, content}: ReportParams) => {
["e", event.id, reason],
]
return createEvent(REPORT, {content, tags})
return makeEvent(REPORT, {content, tags})
}
export const publishReport = ({
@@ -377,10 +351,12 @@ export const publishReport = ({
export type ReactionParams = {
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeReaction = ({event, content}: ReactionParams) => {
const tags = tagEventForReaction(event)
export const makeReaction = ({content, event, tags: paramTags = []}: ReactionParams) => {
const tags = [...paramTags, ...tagEventForReaction(event)]
const groupTag = getTag("h", event.tags)
if (groupTag) {
@@ -388,7 +364,7 @@ export const makeReaction = ({event, content}: ReactionParams) => {
tags.push(groupTag)
}
return createEvent(REACTION, {content, tags})
return makeEvent(REACTION, {content, tags})
}
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
@@ -401,42 +377,64 @@ export type CommentParams = {
}
export const makeComment = ({event, content, tags = []}: CommentParams) =>
createEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays})
export type AlertParams = {
feed: Feed
cron: string
email: string
bunker: string
secret: string
description: string
claims: Record<string, string>
email?: {
cron: string
email: string
handler: string[]
}
web?: {
endpoint: string
p256dh: string
auth: string
}
ios?: {
device_token: string
bundle_identifier: string
}
android?: {
device_token: string
}
}
export const makeAlert = async ({cron, email, feed, bunker, secret, description}: AlertParams) => {
export const makeAlert = async (params: AlertParams) => {
const tags = [
["feed", JSON.stringify(feed)],
["cron", cron],
["email", email],
["feed", JSON.stringify(params.feed)],
["locale", LOCALE],
["timezone", TIMEZONE],
["description", description],
["channel", "email"],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
["description", params.description],
]
if (bunker) {
tags.push(["nip46", secret, bunker])
for (const [relay, claim] of Object.entries(params.claims)) {
tags.push(["claim", relay, claim])
}
return createEvent(ALERT, {
let kind: number
if (params.email) {
kind = ALERT_EMAIL
tags.push(...Object.entries(params.email).map(flatten))
} else if (params.web) {
kind = ALERT_WEB
tags.push(...Object.entries(params.web).map(flatten))
} else if (params.ios) {
kind = ALERT_IOS
tags.push(...Object.entries(params.ios).map(flatten))
} else if (params.android) {
kind = ALERT_ANDROID
tags.push(...Object.entries(params.android).map(flatten))
} else {
throw new Error("Alert has invalid params")
}
return makeEvent(kind, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
+167 -107
View File
@@ -1,30 +1,56 @@
<script lang="ts">
import {onMount} from "svelte"
import {preventDefault} from "@lib/html"
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import {decrypt} from "@welshman/signer"
import {randomInt, parseJson, fromPairs, displayList, TIMEZONE, identity} from "@welshman/lib"
import {
displayRelayUrl,
getTagValue,
getAddress,
THREAD,
MESSAGE,
EVENT_TIME,
COMMENT,
} from "@welshman/util"
import type {Filter} from "@welshman/util"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import {pubkey} from "@welshman/app"
import {pubkey, signer, getThunkError} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import {
GENERAL,
alerts,
getMembershipUrls,
getMembershipRoomsByUrl,
userMembership,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
} from "@app/state"
import {loadAlertStatuses} from "@app/requests"
import {publishAlert} from "@app/commands"
import {loadAlertStatuses, requestRelayClaim} from "@app/requests"
import {publishAlert, attemptAuth} from "@app/commands"
import type {AlertParams} from "@app/commands"
import {platform, platformName, canSendPushNotifications, getPushInfo} from "@app/push"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
type Props = {
url?: string
channel?: string
notifyChat?: boolean
notifyThreads?: boolean
notifyCalendar?: boolean
hideSpaceField?: boolean
}
let {
url = "",
channel = "email",
notifyChat = true,
notifyThreads = true,
notifyCalendar = true,
hideSpaceField = false,
}: Props = $props()
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
const minute = randomInt(0, 59)
@@ -32,49 +58,22 @@
const WEEKLY = `0 ${minute} ${hour} * * 1`
const DAILY = `0 ${minute} ${hour} * * *`
let loading = false
let cron = WEEKLY
let email = $alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || ""
let relay = ""
let bunker = ""
let secret = ""
let notifyThreads = true
let notifyCalendar = true
let notifyChat = false
let showBunker = false
let loading = $state(false)
let cron = $state(WEEKLY)
let claim = $state("")
let email = $state($alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || "")
const back = () => history.back()
const controller = new BunkerConnectController({
onNostrConnect: (response: Nip46ResponseWithResult) => {
bunker = controller.broker.getBunkerUrl()
secret = controller.broker.params.clientSecret
showBunker = false
},
})
const connectBunker = () => {
showBunker = true
}
const hideBunker = () => {
showBunker = false
}
const clearBunker = () => {
bunker = ""
secret = ""
}
const submit = async () => {
if (!email.includes("@")) {
if (channel === "email" && !email.includes("@")) {
return pushToast({
theme: "error",
message: "Please provide an email address",
})
}
if (!relay) {
if (!url) {
return pushToast({
theme: "error",
message: "Please select a space",
@@ -105,22 +104,69 @@
if (notifyChat) {
display.push("chat")
filters.push({
kinds: [MESSAGE],
"#h": [GENERAL, ...getMembershipRoomsByUrl(relay, $userMembership)],
})
filters.push({kinds: [MESSAGE]})
}
loading = true
try {
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
const description = `${cadence} alert for ${displayList(display)} on ${displayRelayUrl(relay)}, sent via email.`
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(relay))
const thunk = await publishAlert({cron, email, feed, bunker, secret, description})
const claims = claim ? {[url]: claim} : {}
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url))
const description = `for ${displayList(display)} on ${displayRelayUrl(url)}`
const params: AlertParams = {feed, claims, description}
await thunk.result
await loadAlertStatuses($pubkey!)
if (channel === "email") {
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
params.description = `${cadence} alert ${description}, sent via email.`
params.email = {
cron,
email,
handler: [
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
}
} else {
try {
// @ts-ignore
params[platform] = await getPushInfo()
params.description = `${platformName} push notification ${description}.`
} catch (e: any) {
return pushToast({
theme: "error",
message: String(e),
})
}
}
// If we don't do this we'll get an event rejection
await attemptAuth(NOTIFIER_RELAY)
const thunk = await publishAlert(params)
const error = await getThunkError(thunk)
if (error) {
return pushToast({
theme: "error",
message: `Failed to send your alert to the notification server (${error}).`,
})
}
// Fetch our new status to make sure it's active
const address = getAddress(thunk.event)
const statusEvents = await loadAlertStatuses($pubkey!)
const statusEvent = statusEvents.find(event => getTagValue("d", event.tags) === address)
const statusTags = statusEvent
? parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, statusEvent.content))
: []
const {status = "error", message = "Your alert was not activated"}: Record<string, string> =
fromPairs(statusTags)
if (status === "error") {
return pushToast({theme: "error", message})
}
pushToast({message: "Your alert has been successfully created!"})
back()
@@ -128,6 +174,20 @@
loading = false
}
}
onMount(() => {
if (!canSendPushNotifications()) {
channel = "email"
}
if (url) {
requestRelayClaim(url).then(code => {
if (code) {
claim = code
}
})
}
})
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
@@ -136,13 +196,20 @@
Add an Alert
{/snippet}
</ModalHeader>
{#if showBunker}
<div class="card2 flex flex-col items-center gap-4 bg-base-300">
<p>Scan using a nostr signer, or click to copy.</p>
<BunkerConnect {controller} />
<Button class="btn btn-neutral btn-sm" onclick={hideBunker}>Cancel</Button>
</div>
{:else}
{#if canSendPushNotifications()}
<FieldInline>
{#snippet label()}
<p>Alert Type*</p>
{/snippet}
{#snippet input()}
<select bind:value={channel} class="select select-bordered">
<option value="email">Email Digest</option>
<option value="push">Push Notification</option>
</select>
{/snippet}
</FieldInline>
{/if}
{#if channel === "email"}
<FieldInline>
{#snippet label()}
<p>Email Address*</p>
@@ -164,12 +231,14 @@
</select>
{/snippet}
</FieldInline>
{/if}
{#if !hideSpaceField}
<FieldInline>
{#snippet label()}
<p>Space*</p>
{/snippet}
{#snippet input()}
<select bind:value={relay} class="select select-bordered">
<select bind:value={url} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option>
{#each getMembershipUrls($userMembership) as url (url)}
<option value={url}>{displayRelayUrl(url)}</option>
@@ -177,59 +246,50 @@
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Notifications*</p>
{/snippet}
{#snippet input()}
<div class="flex items-center justify-end gap-4">
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
Threads
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
Calendar
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
Chat
</span>
</div>
{/snippet}
</FieldInline>
<div class="card2 flex flex-col gap-3 bg-base-300">
<div class="flex items-center justify-between">
<strong>Connect a Bunker</strong>
<span class="flex items-center gap-2 text-sm" class:text-primary={bunker}>
{#if bunker}
<Icon icon="check-circle" size={5} />
Connected
{:else}
<Icon icon="close-circle" size={5} />
Not Connected
{/if}
{/if}
<FieldInline>
{#snippet label()}
<p>Notifications*</p>
{/snippet}
{#snippet input()}
<div class="flex items-center justify-end gap-4">
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyThreads} />
Threads
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyCalendar} />
Calendar
</span>
<span class="flex gap-3">
<input type="checkbox" class="checkbox" bind:checked={notifyChat} />
Chat
</span>
</div>
<p class="text-sm">
Required for receiving alerts about spaces with access controls. You can get one from your
<Button class="text-primary" onclick={() => pushModal(InfoBunker)}>remote signer app</Button
>.
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Invite Code</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={claim} />
</label>
{/snippet}
{#snippet info()}
<p>
To get notifications from private spaces, please provide an invite code which grants access
to the space.
</p>
{#if bunker}
<Button class="btn btn-neutral btn-sm flex-grow" onclick={clearBunker}>Disconnect</Button>
{:else}
<Button class="btn btn-primary btn-sm w-full flex-grow" onclick={connectBunker}
>Connect</Button>
{/if}
</div>
{/if}
{/snippet}
</FieldInline>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || showBunker}>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Confirm</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
import type {Alert} from "@app/state"
import {NOTIFIER_RELAY} from "@app/state"
import {NOTIFIER_RELAY, NOTIFIER_PUBKEY} from "@app/state"
import {publishDelete} from "@app/commands"
import {pushToast} from "@app/toast"
@@ -12,7 +12,7 @@
const {alert}: Props = $props()
const confirm = () => {
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY]})
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY], tags: [["p", NOTIFIER_PUBKEY]]})
pushToast({message: "Your alert has been deleted!"})
history.back()
}
+8 -9
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import {parseJson, nthEq} from "@welshman/lib"
import {parseJson} from "@welshman/lib"
import {displayFeeds} from "@welshman/feeds"
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state"
import {alertStatuses} from "@app/state"
import {deriveAlertStatus} from "@app/state"
import {pushModal} from "@app/modal"
type Props = {
@@ -15,8 +15,7 @@
const {alert}: Props = $props()
const address = $derived(getAddress(alert.event))
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const status = deriveAlertStatus(getAddress(alert.event))
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags))
@@ -39,24 +38,24 @@
</Button>
<div class="flex-inline gap-1">{description}</div>
</div>
{#if status}
{@const statusText = getTagValue("status", status.tags) || "error"}
{#if $status}
{@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", status.tags)}>
data-tip={getTagValue("message", $status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", status.tags)}>
data-tip={getTagValue("message", $status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", status.tags)}>
data-tip={getTagValue("message", $status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
+15 -10
View File
@@ -1,20 +1,25 @@
<script lang="ts">
import {onMount} from "svelte"
import {pubkey} from "@welshman/app"
import {getTagValue} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import AlertItem from "@app/components/AlertItem.svelte"
import {loadAlertStatuses, loadAlerts} from "@app/requests"
import {pushModal} from "@app/modal"
import {alerts} from "@app/state"
const startAlert = () => pushModal(AlertAdd)
type Props = {
url?: string
channel?: string
hideSpaceField?: boolean
}
onMount(() => {
loadAlertStatuses($pubkey!)
loadAlerts($pubkey!)
})
const {url = "", channel = "push", hideSpaceField = false}: Props = $props()
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
const filteredAlerts = $derived(
url ? $alerts.filter(a => getTagValue("feed", a.tags)?.includes(url)) : $alerts,
)
</script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
@@ -29,10 +34,10 @@
</Button>
</div>
<div class="col-4">
{#each $alerts as alert (alert.event.id)}
{#each filteredAlerts as alert (alert.event.id)}
<AlertItem {alert} />
{:else}
<p class="text-center opacity-75 py-12">No alerts found</p>
<p class="text-center opacity-75 py-12">Nothing here yet!</p>
{/each}
</div>
</div>
+5 -10
View File
@@ -1,5 +1,5 @@
<script lang="ts">
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 Button from "@lib/components/Button.svelte"
@@ -26,20 +26,15 @@
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
if (reaction) {
publishDelete({relays: [url], event: reaction})
} else {
publishReaction({event, content, relays: [url]})
}
}
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} {onReactionClick} reactionClass="tooltip-left" />
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
+3 -4
View File
@@ -2,7 +2,7 @@
import type {Snippet} from "svelte"
import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib"
import {createEvent, EVENT_TIME} from "@welshman/util"
import {makeEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
@@ -13,7 +13,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED, GENERAL, tagRoom} from "@app/state"
import {PROTECTED} from "@app/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
@@ -63,7 +63,7 @@
}
const ed = await editor
const event = createEvent(EVENT_TIME, {
const event = makeEvent(EVENT_TIME, {
content: ed.getText({blockSeparator: "\n"}).trim(),
tags: [
["d", initialValues?.d || randomId()],
@@ -73,7 +73,6 @@
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
PROTECTED,
],
})
+14 -15
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay} from "@welshman/app"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, deriveProfile, deriveProfileDisplay} from "@welshman/app"
import {isMobile} from "@lib/html"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
@@ -20,14 +20,13 @@
interface Props {
url: string
room: string
event: TrustedEvent
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
inert?: boolean
}
const {url, room, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const thunk = $thunks[event.id]
const today = formatTimestampAsDate(now())
@@ -41,15 +40,10 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
if (reaction) {
publishDelete({relays: [url], event: reaction})
} else {
publishReaction({event, content, relays: [url]})
}
}
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
</script>
<TapTarget
@@ -81,7 +75,7 @@
</div>
{/if}
<div class="text-sm">
<Content {event} {url} />
<Content minimalQuote {event} {url} />
{#if thunk}
<ThunkStatus {thunk} class="mt-2" />
{/if}
@@ -89,13 +83,18 @@
</div>
</div>
<div class="row-2 ml-10 mt-1">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right" />
<ReactionSummary
{url}
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
</div>
{#if !isMobile}
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
<ChannelMessageEmojiButton {url} {room} {event} />
<ChannelMessageEmojiButton {url} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon="reply" size={4} />
@@ -1,14 +1,10 @@
<script lang="ts">
import {noop} from "@welshman/lib"
import type {NativeEmoji} from "emoji-picker-element/shared"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import Icon from "@lib/components/Icon.svelte"
import {publishReaction} from "@app/commands"
const {url, room, event} = $props()
// Tell svelte-check to shut up
noop(room)
const {url, event} = $props()
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, relays: [url], content: emoji.unicode})
+2 -6
View File
@@ -1,11 +1,7 @@
<script lang="ts">
import {GENERAL, channelsById, makeChannelId} from "@app/state"
import {channelsById, makeChannelId} from "@app/state"
const {url, room} = $props()
</script>
{#if room === GENERAL}
general
{:else}
{$channelsById.get(makeChannelId(url, room))?.name || room}
{/if}
{$channelsById.get(makeChannelId(url, room))?.name || room}
+69 -11
View File
@@ -1,9 +1,28 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {onMount} from "svelte"
import {int, nthNe, MINUTE, sortBy, remove, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
import {
int,
ms,
partition,
spec,
nthEq,
nthNe,
MINUTE,
sortBy,
remove,
formatTimestampAsDate,
} from "@welshman/lib"
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
import {parse, isLink} from "@welshman/content"
import {
makeEvent,
tagsFromIMeta,
getTags,
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
INBOX_RELAYS,
} from "@welshman/util"
import {
pubkey,
tagPubkey,
@@ -61,14 +80,53 @@
}
const onSubmit = async (params: EventContent) => {
// Remove p tags since they result in forking the conversation
const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)]
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
await sendWrapped({
pubkeys,
template: createEvent(DIRECT_MESSAGE, prependParent(parent, {...params, tags})),
delay: $userSettingValues.send_delay,
})
// Remove p tags since they result in forking the conversation
params.tags = params.tags.filter(nthNe(0, "p"))
// Add our reply quote to content
params = prependParent(parent, params)
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
const templates: EventTemplate[] = []
const buffer = []
const addTemplate = (kind: number, content: string, tags: string[][]) => {
content = content.trim()
if (content) {
templates.push(makeEvent(kind, {content, tags: [...tags, ...ptags]}))
}
}
for (const p of parse(params)) {
const imeta = isLink(p)
? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()])))
: undefined
if (isLink(p) && imeta) {
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
addTemplate(
DIRECT_MESSAGE_FILE,
p.value.url.toString(),
imeta.slice(1).filter(nthNe(0, "url")),
)
} else {
buffer.push(p.raw)
}
}
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
// Sleep 1 second between each one to make sure timestamps are distinct
for (let i = 0; i < templates.length; i++) {
const template = templates[i]
await sendWrapped({pubkeys, template, delay: $userSettingValues.send_delay + ms(i)})
}
clearParent()
}
@@ -191,7 +249,7 @@
{/snippet}
</PageBar>
<PageContent class="flex flex-col-reverse pt-4">
<PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div>
{#if missingInboxes.includes($pubkey!)}
<div class="py-12">
+13 -1
View File
@@ -20,6 +20,8 @@
export const focus = () => editor.then(ed => ed.chain().focus().run())
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading) return
@@ -40,11 +42,21 @@
submit,
uploading,
aggressive: true,
disableFileUpload: true,
})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<Button
data-tip="Add an image"
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
disabled={$uploading}
onclick={uploadFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
{/if}
</Button>
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
+1 -1
View File
@@ -22,7 +22,7 @@
const {...props}: Props = $props()
const others = remove($pubkey!, props.pubkeys)
const active = $page.params.chat === props.id
const active = $derived($page.params.chat === props.id)
const path = makeChatPath(props.pubkeys)
onMount(() => {
+6 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {type Instance} from "tippy.js"
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 {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
@@ -36,12 +36,11 @@
const reply = () => replyTo(event)
const onReactionClick = async (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
const template = reaction ? makeDelete({event: reaction}) : makeReaction({event, content})
const deleteReaction = (event: TrustedEvent) =>
sendWrapped({template: makeDelete({event}), pubkeys})
await sendWrapped({template, pubkeys})
}
const createReaction = (template: EventContent) =>
sendWrapped({template: makeReaction({event, ...template}), pubkeys})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
@@ -120,7 +119,7 @@
</div>
</TapTarget>
<div class="row-2 z-feature -mt-4 ml-4">
<ReactionSummary {event} {onReactionClick} noTooltip />
<ReactionSummary {event} {deleteReaction} {createReaction} noTooltip />
</div>
</div>
</div>
+35 -1
View File
@@ -1,5 +1,10 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {goto} from "$app/navigation"
import {tryCatch, uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util"
import {pubkey} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte"
@@ -14,7 +19,36 @@
const onSubmit = () => goto(makeChatPath([...pubkeys, $pubkey!]))
const addPubkey = (pubkey: string) => {
pubkeys = uniq([...pubkeys, pubkey])
term.set("")
}
const term = writable("")
let pubkeys: string[] = $state([])
onMount(() => {
return term.subscribe(t => {
if (t.match(/^[0-9a-f]{64}$/)) {
addPubkey(t)
}
if (t.match(/^(nostr:)?(npub1|nprofile1)/)) {
tryCatch(() => {
const {type, data} = nip19.decode(fromNostrURI(t))
if (type === "npub") {
addPubkey(data)
}
if (type === "nprofile") {
addPubkey(data.pubkey)
}
})
}
})
})
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
@@ -28,7 +62,7 @@
</ModalHeader>
<Field>
{#snippet input()}
<ProfileMultiSelect autofocus bind:value={pubkeys} />
<ProfileMultiSelect autofocus bind:value={pubkeys} {term} />
{/snippet}
</Field>
<ModalFooter>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {makeThreadPath} from "@app/routes"
interface Props {
url: any
event: any
showActivity?: boolean
}
const {url, event, showActivity = false}: Props = $props()
const path = makeThreadPath(url, event.id)
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Comment" />
</div>
</div>
+13 -1
View File
@@ -6,6 +6,7 @@
truncate,
renderAsHtml,
isText,
isEmoji,
isTopic,
isCode,
isCashu,
@@ -22,6 +23,7 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
@@ -38,6 +40,7 @@
showEntire?: boolean
hideMediaAtDepth?: number
expandMode?: string
minimalQuote?: boolean
depth?: number
url?: string
}
@@ -49,6 +52,7 @@
showEntire = $bindable(false),
hideMediaAtDepth = 1,
expandMode = "block",
minimalQuote = false,
depth = 0,
url,
}: Props = $props()
@@ -133,6 +137,8 @@
<ContentNewline value={parsed.value} />
{:else if isTopic(parsed)}
<ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else if isCode(parsed)}
<ContentCode
value={parsed.value}
@@ -149,7 +155,13 @@
<ContentMention value={parsed.value} {url} />
{:else if isEvent(parsed) || isAddress(parsed)}
{#if isBlock(i)}
<ContentQuote {depth} {url} {hideMediaAtDepth} value={parsed.value} {event} />
<ContentQuote
{depth}
{url}
{hideMediaAtDepth}
value={parsed.value}
{event}
minimal={minimalQuote} />
{:else}
<Link
external
+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}
+7 -9
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {ellipsize, postJson} from "@welshman/lib"
import {ellipsize, displayUrl, postJson} from "@welshman/lib"
import {dufflepud, imgproxy} from "@app/state"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
@@ -31,7 +31,7 @@
</script>
<Link external href={url} class="my-2 block">
<div class="overflow-hidden rounded-box leading-[0]">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/)}
<video controls src={url} class="max-h-96 object-contain object-center">
<track kind="captions" />
@@ -54,13 +54,11 @@
src={imgproxy(preview.image)}
class="bg-alt max-h-72 object-contain object-center" />
{/if}
{#if preview.title}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
+34 -31
View File
@@ -1,49 +1,52 @@
<script lang="ts">
import {onDestroy} from "svelte"
import {now} from "@welshman/lib"
import {BLOSSOM_AUTH, makeEvent, getTags, getTagValue, tagsFromIMeta} from "@welshman/util"
import {signer} from "@welshman/app"
import {onMount, onDestroy} from "svelte"
import {displayUrl} from "@welshman/lib"
import {getTags, decryptFile, getTagValue, tagsFromIMeta} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import {imgproxy} from "@app/state"
const {value, event, ...props} = $props()
const url = value.url.toString()
// If we fail to fetch the image, try authenticating if we have a blossom hash
const onerror = async () => {
const meta = getTags("imeta", event.tags)
const meta =
getTags("imeta", event.tags)
.map(tagsFromIMeta)
.find(meta => getTagValue("url", meta) === url)
const hash = meta ? getTagValue("x", meta) : undefined
.find(meta => getTagValue("url", meta) === url) || event.tags
if (hash && $signer) {
const event = await signer.get().sign(
makeEvent(BLOSSOM_AUTH, {
tags: [
["t", "get"],
["x", hash],
["expiration", String(now() + 30)],
],
}),
)
const key = getTagValue("decryption-key", meta)
const nonce = getTagValue("decryption-nonce", meta)
const algorithm = getTagValue("encryption-algorithm", meta)
const res = await fetch(url, {
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
},
})
if (res.status === 200) {
src = URL.createObjectURL(await res.blob())
}
}
const onError = () => {
hasError = true
}
let hasError = $state(false)
let src = $state(imgproxy(url))
onMount(async () => {
if (algorithm === "aes-gcm" && key && nonce) {
const response = await fetch(url)
if (response.ok) {
const ciphertext = new Uint8Array(await response.arrayBuffer())
const decryptedData = await decryptFile({ciphertext, key, nonce, algorithm})
src = URL.createObjectURL(new Blob([decryptedData]))
}
}
})
onDestroy(() => {
URL.revokeObjectURL(src)
})
</script>
<img alt="" {src} {onerror} {...props} />
{#if hasError}
<a href={url} class="link-content whitespace-nowrap">
<Icon icon="link-round" size={3} class="inline-block" />
{displayUrl(url)}
</a>
{:else}
<img alt="" {src} onerror={onError} {...props} />
{/if}
+3 -4
View File
@@ -1,8 +1,7 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import type {ProfilePointer} from "@welshman/content"
import {displayProfile} from "@welshman/util"
import {deriveProfile} from "@welshman/app"
import {deriveProfileDisplay} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
@@ -14,11 +13,11 @@
const {value, url}: Props = $props()
const profile = deriveProfile(value.pubkey, removeNil([url]))
const display = deriveProfileDisplay(value.pubkey, removeNil([url]))
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
</script>
<Button onclick={openProfile} class="link-content">
@{displayProfile($profile)}
@{$display}
</Button>
+19 -61
View File
@@ -1,18 +1,14 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {nthEq} from "@welshman/lib"
import {Router} from "@welshman/router"
import {tracker, repository} from "@welshman/app"
import type {TrustedEvent} from "@welshman/util"
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD, EVENT_TIME} from "@welshman/util"
import {scrollToEvent} from "@lib/html"
import {Address, MESSAGE} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import {deriveEvent, entityLink, ROOM} from "@app/state"
import {makeThreadPath, makeCalendarPath, makeRoomPath} from "@app/routes"
import {deriveEvent, entityLink} from "@app/state"
import {goToEvent} from "@app/routes"
type Props = {
value: any
@@ -20,9 +16,10 @@
event: TrustedEvent
depth: number
url?: string
minimal?: boolean
}
const {value, event, depth, hideMediaAtDepth, url}: Props = $props()
const {value, event, depth, hideMediaAtDepth, url, minimal}: Props = $props()
const {id, identifier, kind, pubkey, relays = []} = value
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
@@ -37,67 +34,28 @@
? nip19.neventEncode({id, relays: mergedRelays})
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
const openMessage = (url: string, room: string, id: string) => {
const event = repository.getEvent(id)
if (event) {
goto(makeRoomPath(url, room))
scrollToEvent(id)
}
return Boolean(event)
}
const onclick = () => {
if ($quote) {
if ($quote.kind === DIRECT_MESSAGE) {
return scrollToEvent($quote.id)
}
const [url] = tracker.getRelays($quote.id)
const room = $quote.tags.find(nthEq(0, ROOM))?.[1]
if (url && room) {
if ($quote.kind === THREAD) {
return goto(makeThreadPath(url, $quote.id))
}
if ($quote.kind === EVENT_TIME) {
return goto(makeCalendarPath(url, $quote.id))
}
if ($quote.kind === MESSAGE) {
return scrollToEvent($quote.id) || openMessage(url, room, $quote.id)
}
const kind = $quote.tags.find(nthEq(0, "K"))?.[1]
const id = $quote.tags.find(nthEq(0, "E"))?.[1]
if (id && kind) {
if (parseInt(kind) === THREAD) {
return goto(makeThreadPath(url, id))
}
if (parseInt(kind) === EVENT_TIME) {
return goto(makeCalendarPath(url, id))
}
if (parseInt(kind) === MESSAGE) {
return scrollToEvent(id) || openMessage(url, room, id)
}
}
}
goToEvent($quote)
} else {
window.open(entityLink(entity))
}
window.open(entityLink(entity))
}
</script>
<Button class="my-2 block max-w-full text-left" {onclick}>
{#if $quote}
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
</NoteCard>
{#if minimal && $quote.kind === MESSAGE}
<div
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
</div>
{:else}
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
</NoteCard>
{/if}
{:else}
<div class="rounded-box p-4">
<Spinner loading>Loading event...</Spinner>
@@ -0,0 +1,74 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {goToEvent} from "@app/routes"
import {displayChannel} from "@app/state"
type Props = {
url: string
room?: string
events: TrustedEvent[]
latest: TrustedEvent
earliest: TrustedEvent
participants: string[]
}
const {url, room, events, latest, earliest, participants}: Props = $props()
</script>
<Button class="card2 bg-alt" onclick={() => goToEvent(earliest)}>
<div class="flex flex-col gap-3">
<div class="flex items-start gap-3">
<ProfileCircle pubkey={earliest.pubkey} size={10} />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm opacity-70">
{#if room}
<span class="font-medium text-blue-400">
#{displayChannel(url, room)}
</span>
<span class="opacity-50"></span>
{/if}
<span>{formatTimestamp(earliest.created_at)}</span>
</div>
<Content minimalQuote minLength={100} maxLength={400} event={earliest} />
</div>
</div>
<div class="ml-13 flex items-center justify-between">
<div class="flex gap-1">
<Icon icon="alt-arrow-left" />
<span class="text-sm opacity-70">
{events.length}
{events.length === 1 ? "message" : "messages"}
</span>
</div>
<div class="flex gap-2">
<ProfileCircles pubkeys={participants} size={6} />
<span class="text-sm opacity-70">
{participants.length}
{participants.length === 1 ? "participant" : "participants"}
</span>
</div>
</div>
{#if latest !== earliest}
<Button class="card2 bg-alt" onclick={() => goToEvent(latest)}>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm opacity-70">
<ProfileCircle pubkey={latest.pubkey} size={5} />
<span class="font-medium">Latest reply:</span>
</div>
<span class="text-xs opacity-50">
{formatTimestamp(latest.created_at)}
</span>
</div>
<Content minimalQuote minLength={100} maxLength={400} event={latest} />
</div>
</Button>
{/if}
</div>
</Button>
+2 -2
View File
@@ -8,7 +8,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {publishComment} from "@app/commands"
import {tagRoom, GENERAL, PROTECTED} from "@app/state"
import {PROTECTED} from "@app/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
@@ -23,7 +23,7 @@
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [...ed.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
const tags = [...ed.storage.nostr.getEditorTags(), PROTECTED]
if (!content) {
return pushToast({
@@ -0,0 +1,31 @@
<script lang="ts">
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import {PLATFORM_NAME} from "@app/state"
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>Where did my rooms go?</div>
{/snippet}
</ModalHeader>
<p>
You might have noticed that old rooms have disappeared from navigation. {PLATFORM_NAME} is still
under heavy development, which means that we occasionally have to make breaking changes. In this
case, we've changed how rooms work in {PLATFORM_NAME} to be more fully compatible with other NIP
29 clients, like <Link external class="link" href="https://chachi.chat">Chachi</Link> and
<Link external class="link" href="https://0xchat.com">0xChat</Link>.
</p>
<p>
If you run a relay, please upgrade to a version that supports NIP 29. {PLATFORM_NAME} works best
with the latest version of <Link
external
class="link"
href="https://github.com/coracle-social/frith">Frith</Link
>, which will automatically migrate your rooms. In the meantime, your messages are all still
available under the "Chat" tab (all conversations have been temporarily merged together).
</p>
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
</div>
+67 -26
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {displayRelayUrl} from "@welshman/util"
import {displayRelayUrl, getTagValue} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -12,14 +13,19 @@
import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import ProfileList from "@app/components/ProfileList.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import Alerts from "@app/components/Alerts.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
import InfoMissingRooms from "@app/components/InfoMissingRooms.svelte"
import {
userRoomsByUrl,
hasMembershipUrl,
memberships,
deriveUserRooms,
deriveOtherRooms,
hasNip29,
alerts,
} from "@app/state"
import {notifications} from "@app/notifications"
import {pushModal} from "@app/modal"
@@ -27,10 +33,13 @@
const {url} = $props()
const relay = deriveRelay(url)
const chatPath = makeSpacePath(url, "chat")
const threadsPath = makeSpacePath(url, "threads")
const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url)))
const openMenu = () => {
showMenu = true
@@ -40,6 +49,8 @@
showMenu = !showMenu
}
const showMissingRooms = () => pushModal(InfoMissingRooms)
const showMembers = () =>
pushModal(
ProfileList,
@@ -55,6 +66,13 @@
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
const manageAlerts = () => {
const component = hasAlerts ? Alerts : AlertAdd
const params = {url, channel: "push", hideSpaceField: true}
pushModal(component, params, {replaceState})
}
let showMenu = $state(false)
let replaceState = $state(false)
let element: Element | undefined = $state()
@@ -68,18 +86,20 @@
})
</script>
<div bind:this={element}>
<SecondaryNavSection class="max-h-screen">
<div bind:this={element} class="flex h-full flex-col justify-between">
<SecondaryNavSection>
<div>
<SecondaryNavItem class="w-full !justify-between" onclick={openMenu}>
<strong class="ellipsize">{displayRelayUrl(url)}</strong>
<strong class="ellipsize flex items-center gap-3">
{displayRelayUrl(url)}
</strong>
<Icon icon="alt-arrow-down" />
</SecondaryNavItem>
{#if showMenu}
<Popover hideOnClick onClose={toggleMenu}>
<ul
transition:fly
class="menu absolute z-popover mt-2 w-full rounded-box bg-base-100 p-2 shadow-xl">
class="menu absolute z-popover mt-2 w-full gap-1 rounded-box bg-base-100 p-2 shadow-xl">
<li>
<Button onclick={showMembers}>
<Icon icon="user-rounded" />
@@ -125,28 +145,49 @@
notification={$notifications.has(calendarPath)}>
<Icon icon="calendar-minimalistic" /> Calendar
</SecondaryNavItem>
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{#each $userRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
{:else}
Rooms
{/if}
</SecondaryNavHeader>
{#if hasNip29($relay)}
{#if $userRooms.length > 0}
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} notify {url} {room} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
{:else}
Rooms
{/if}
</SecondaryNavHeader>
{/if}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} {url} {room} />
{/each}
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
{:else}
<SecondaryNavItem
{replaceState}
href={chatPath}
notification={$notifications.has(chatPath)}>
<Icon icon="chat-round" /> Chat
</SecondaryNavItem>
<Button class="link flex items-center gap-2 py-2 pl-4 text-sm" onclick={showMissingRooms}>
<Icon icon="info-circle" size={4} />
Where did my rooms go?
</Button>
{/if}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} {url} {room} />
{/each}
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
</div>
</SecondaryNavSection>
<div class="p-4">
<button class="btn btn-neutral btn-sm w-full" onclick={manageAlerts}>
<Icon icon="bell" />
Manage Alerts
</button>
</div>
</div>
+2 -2
View File
@@ -3,7 +3,7 @@
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {makeRoomPath} from "@app/routes"
import {deriveChannel, channelIsLocked} from "@app/state"
import {deriveChannel} from "@app/state"
import {notifications} from "@app/notifications"
interface Props {
@@ -23,7 +23,7 @@
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
{#if channelIsLocked($channel)}
{#if $channel?.closed || $channel?.private}
<Icon icon="lock" size={4} />
{:else}
<Icon icon="hashtag" />
+11 -12
View File
@@ -5,23 +5,22 @@
import CardButton from "@lib/components/CardButton.svelte"
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte"
import {userRoomsByUrl, PLATFORM_RELAY} from "@app/state"
import {userRoomsByUrl, PLATFORM_RELAYS} from "@app/state"
import {pushModal} from "@app/modal"
const addSpace = () => pushModal(SpaceAdd)
</script>
<div class="column menu gap-2">
{#if PLATFORM_RELAY}
<MenuSpacesItem url={PLATFORM_RELAY} />
<Divider />
{:else if $userRoomsByUrl.size > 0}
{#each $userRoomsByUrl.keys() as url (url)}
<MenuSpacesItem {url} />
{/each}
<Divider />
{/if}
{#if !PLATFORM_RELAY}
{#each PLATFORM_RELAYS as url (url)}
<MenuSpacesItem {url} />
{:else}
{#if $userRoomsByUrl.size > 0}
{#each $userRoomsByUrl.keys() as url (url)}
<MenuSpacesItem {url} />
{/each}
<Divider />
{/if}
<Button onclick={addSpace}>
<CardButton>
{#snippet icon()}
@@ -35,5 +34,5 @@
{/snippet}
</CardButton>
</Button>
{/if}
{/each}
</div>
+5 -11
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import type {TrustedEvent, EventContent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
@@ -11,15 +10,10 @@
const {url, event} = $props()
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
if (reaction) {
publishDelete({relays: [url], event: reaction})
} else {
publishReaction({event, content, relays: [url]})
}
}
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, content: emoji.unicode, relays: [url]})
@@ -28,7 +22,7 @@
<NoteCard {event} {url} class="card2 bg-alt">
<NoteContent {event} expandMode="inline" />
<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">
<Icon icon="smile-circle" size={4} />
</EmojiButton>
+15 -36
View File
@@ -1,16 +1,11 @@
<script lang="ts">
import {onMount} from "svelte"
import {formatTimestampRelative} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {load} from "@welshman/net"
import {Router} from "@welshman/router"
import {repository, loadRelaySelections} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import {makeChatPath} from "@app/routes"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
type Props = {
pubkey: string
@@ -19,37 +14,21 @@
const {pubkey, url}: Props = $props()
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
const events = deriveEvents(repository, {filters})
onMount(async () => {
// Make sure we have their relay selections before we load their posts
await loadRelaySelections(pubkey)
// Load at least one note, regardless of time frame
load({
filters: [{authors: [pubkey], limit: 1}],
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
})
})
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script>
<div class="card2 bg-alt col-2 shadow-xl">
<div class="card2 bg-alt flex flex-col gap-4 shadow-xl">
<div class="flex justify-between">
<Profile {pubkey} {url} />
<Link class="btn btn-primary hidden sm:flex" href={makeChatPath([pubkey])}>
<Icon icon="letter" />
Start a Chat
</Link>
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
<Icon icon="user-circle" />
View Profile
</Button>
</div>
<ProfileInfo {pubkey} {url} />
{#if $events.length > 0}
<div class="bg-alt badge badge-neutral border-none">
Last active {formatTimestampRelative($events[0].created_at)}
</div>
{/if}
<Link class="btn btn-primary sm:hidden" href={makeChatPath([pubkey])}>
<Icon icon="letter" />
Start a Chat
</Link>
<ProfileBadges {pubkey} {url} />
<Button onclick={openProfile} class="btn btn-primary sm:hidden">
<Icon icon="user-circle" />
View Profile
</Button>
</div>
+16 -9
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {splitAt} from "@welshman/lib"
@@ -12,12 +13,13 @@
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state"
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/state"
import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications"
interface Props {
children?: import("svelte").Snippet
type Props = {
children?: Snippet
}
const {children}: Props = $props()
@@ -55,8 +57,8 @@
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
<div class="flex h-full flex-col justify-between">
<div>
{#if PLATFORM_RELAY}
<PrimaryNavItemSpace url={PLATFORM_RELAY} />
{#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} />
{:else}
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
@@ -77,7 +79,7 @@
<PrimaryNavItem title="Add Space" onclick={addSpace} class="tooltip-right">
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
{/each}
</div>
<div>
<PrimaryNavItem
@@ -118,9 +120,14 @@
notification={$notifications.has("/chat")}>
<Avatar icon="letter" class="!h-10 !w-10" />
</PrimaryNavItem>
<PrimaryNavItem title="Spaces" onclick={showSpacesMenu} notification={anySpaceNotifications}>
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem
title="Spaces"
onclick={showSpacesMenu}
notification={anySpaceNotifications}>
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
{/if}
</div>
<PrimaryNavItem title="Settings" onclick={showSettingsMenu}>
<Avatar icon="settings" src={$userProfile?.picture} class="!h-10 !w-10" />
+25 -16
View File
@@ -1,54 +1,63 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {displayPubkey} from "@welshman/util"
import {
session,
userFollows,
deriveUserWotScore,
deriveHandleForPubkey,
displayHandle,
deriveProfile,
deriveProfileDisplay,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import WotScore from "@lib/components/WotScore.svelte"
import WotScore from "@app/components/WotScore.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
import {clip} from "@app/toast"
type Props = {
pubkey: string
url?: string
showPubkey?: boolean
avatarSize?: number
}
const {pubkey, url}: Props = $props()
const {pubkey, url, showPubkey, avatarSize = 10}: Props = $props()
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const profileDisplay = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
const following = $derived(
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
)
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
</script>
<div class="flex max-w-full gap-3">
<div class="flex max-w-full items-start gap-3">
<Button onclick={openProfile} class="py-1">
<Avatar src={$profile?.picture} size={10} />
<Avatar src={$profile?.picture} size={avatarSize} />
</Button>
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
</Button>
<WotScore score={$score} active={following} />
</div>
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
<WotScore {pubkey} />
</div>
{#if $handle}
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{displayHandle($handle)}
</div>
{/if}
{#if showPubkey}
<div class="flex items-center gap-1 overflow-hidden text-ellipsis text-xs opacity-60">
{displayPubkey(pubkey)}
<Button onclick={copyPubkey} class="pt-1">
<Icon size={3} icon="copy" />
</Button>
</div>
{/if}
</div>
</div>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import {onMount} from "svelte"
import {load} from "@welshman/net"
import {Router} from "@welshman/router"
import type {Filter} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {formatTimestampRelative} from "@welshman/lib"
import {NOTE, ROOMS, MESSAGE, THREAD, COMMENT, getRelayTags, getListTags} from "@welshman/util"
import {repository, loadRelaySelections} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
import {membershipsByPubkey} from "@app/state"
import {goToEvent} from "@app/routes"
import {pushModal} from "@app/modal"
type Props = {
pubkey: string
url?: string
}
const {pubkey, url}: Props = $props()
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
const events = deriveEvents(repository, {filters})
const membership = $derived($membershipsByPubkey.get(pubkey))
const relays = $derived(getRelayTags(getListTags(membership)))
const viewEvent = () => goToEvent($events[0]!)
const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url})
onMount(async () => {
// Make sure we have their relay selections before we load their posts
await loadRelaySelections(pubkey)
// Load groups and at least one note, regardless of time frame
load({
filters: [
{authors: [pubkey], kinds: [ROOMS]},
{authors: [pubkey], limit: 1, kinds: [NOTE, MESSAGE, THREAD, COMMENT]},
],
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
})
})
</script>
<div class="flex flex-wrap gap-2">
{#if $events.length > 0}
<Button onclick={viewEvent} class="badge badge-neutral">
Last active {formatTimestampRelative($events[0].created_at)}
</Button>
{/if}
{#if relays.length > 0}
<Button onclick={openSpaces} class="badge badge-neutral">
{relays.length}
{relays.length === 1 ? "space" : "spaces"}
</Button>
{/if}
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
</script>
<div class="flex pr-3">
{#each props.pubkeys.slice(0, 15) as pubkey (pubkey)}
{#each props.pubkeys.toSorted().slice(0, 15) as pubkey (pubkey)}
<div class="z-feature -mr-3 inline-block">
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {...props} />
</div>
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {chunk, sleep, uniq} from "@welshman/lib"
import {
createEvent,
makeEvent,
createProfile,
PROFILE,
DELETE,
@@ -36,8 +36,8 @@
}
const chunks = chunk(500, repository.query([{authors: [$pubkey!]}]))
const profileEvent = createEvent(PROFILE, createProfile({name: "[deleted]"}))
const vanishEvent = createEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
const profileEvent = makeEvent(PROFILE, createProfile({name: "[deleted]"}))
const vanishEvent = makeEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
const denominator = chunks.length + 2
const relays = uniq([
...INDEXER_RELAYS,
@@ -75,7 +75,7 @@
}
}
await publishThunk({relays, event: createEvent(DELETE, {tags})})
await publishThunk({relays, event: makeEvent(DELETE, {tags})})
await incrementProgress()
}
+8 -42
View File
@@ -1,23 +1,13 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {
session,
userFollows,
deriveUserWotScore,
deriveHandleForPubkey,
displayHandle,
deriveProfile,
deriveProfileDisplay,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import WotScore from "@lib/components/WotScore.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import {canDecrypt, pubkeyLink} from "@app/state"
import {pushModal} from "@app/modal"
@@ -30,41 +20,17 @@
const {pubkey, url}: Props = $props()
const relays = removeNil([url])
const profile = deriveProfile(pubkey, relays)
const display = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const back = () => history.back()
const chatPath = makeChatPath([pubkey])
const openChat = () => ($canDecrypt ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath}))
const following = $derived(
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey),
)
</script>
<div class="column gap-4">
<div class="flex max-w-full gap-3">
<span class="py-1">
<Avatar src={$profile?.picture} size={10} />
</span>
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
<span class="text-bold overflow-hidden text-ellipsis">
{$display}
</span>
<WotScore score={$score} active={following} />
</div>
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
</div>
</div>
</div>
<div class="flex flex-col gap-4">
<Profile showPubkey avatarSize={14} {pubkey} {url} />
<ProfileInfo {pubkey} {url} />
<ProfileBadges {pubkey} {url} />
<ModalFooter>
<Button onclick={back} class="hidden md:btn md:btn-link">
<Icon icon="alt-arrow-left" />
@@ -72,8 +38,8 @@
</Button>
<div class="flex gap-2">
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
<Icon icon="user-circle" />
See Complete Profile
<Avatar src="/coracle.png" />
Open in Coracle
</Link>
<Button onclick={openChat} class="btn btn-primary">
<Icon icon="letter" />
+8 -4
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import {nthNe} from "@welshman/lib"
import type {Profile} from "@welshman/util"
import {
getTag,
createEvent,
makeEvent,
makeProfile,
editProfile,
createProfile,
@@ -24,16 +25,19 @@
const back = () => history.back()
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const router = Router.get()
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const relays = [...getMembershipUrls($userMembership)]
const scenarios = [router.FromRelays(getMembershipUrls($userMembership))]
if (shouldBroadcast) {
relays.push(...Router.get().FromUser().getUrls())
scenarios.push(router.FromUser(), router.Index())
template.tags = template.tags.filter(nthNe(0, "-"))
} else {
template.tags = uniqTags([...template.tags, PROTECTED])
}
const event = createEvent(template.kind, template)
const event = makeEvent(template.kind, template)
const relays = router.merge(scenarios).getUrls()
publishThunk({event, relays})
pushToast({message: "Your profile has been updated!"})
+1 -1
View File
@@ -14,5 +14,5 @@
</script>
{#if $profile}
<Content event={{content: $profile.about, tags: []}} hideMediaAtDepth={0} />
<Content event={{content: $profile.about || "", tags: []}} hideMediaAtDepth={0} />
{/if}
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {load} from "@welshman/net"
import {NOTE} from "@welshman/util"
import {fly} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
interface Props {
url: string
pubkey: string
limit?: number
fallback?: Snippet
}
const {url, pubkey, limit = 1, fallback}: Props = $props()
const events = load({
relays: [url],
filters: [{authors: [pubkey], kinds: [NOTE], limit}],
})
</script>
<div class="col-4">
<div class="flex flex-col gap-2">
{#await events}
<p class="center my-12 flex">
<Spinner loading />
</p>
{:then events}
{#each events as event (event.id)}
<div in:fly>
<NoteItem {url} {event} />
</div>
{:else}
{@render fallback?.()}
{/each}
{/await}
</div>
</div>
+4 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {preventDefault} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
@@ -8,13 +9,14 @@
type Props = {
pubkey: string
url?: string
unstyled?: boolean
}
const {pubkey, url}: Props = $props()
const {pubkey, url, unstyled}: Props = $props()
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script>
<Button onclick={preventDefault(openProfile)} class="link-content">
<Button onclick={preventDefault(openProfile)} class={cx({"link-content": !unstyled})}>
@<ProfileName {pubkey} {url} />
</Button>
+6 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {Writable} from "svelte/store"
import {type Instance} from "tippy.js"
import {append, remove, uniq} from "@welshman/lib"
import {profileSearch} from "@welshman/app"
@@ -15,11 +16,10 @@
interface Props {
value: string[]
autofocus?: boolean
term?: Writable<string>
}
let {value = $bindable(), autofocus = false}: Props = $props()
const term = writable("")
let {value = $bindable(), term = writable(""), autofocus = false}: Props = $props()
const search = (term: string) => $profileSearch.searchValues(term)
@@ -44,6 +44,9 @@
let instance: any = $state()
$effect(() => {
// @ts-ignore
oninput?.($term)
if ($term) {
popover?.show()
} else {
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {makeSpacePath} from "@app/routes"
import {getMembershipUrls, membershipsByPubkey} from "@app/state"
type Props = {
pubkey: string
}
const {pubkey}: Props = $props()
const spaceUrls = $derived(getMembershipUrls($membershipsByPubkey.get(pubkey)))
const back = () => history.back()
</script>
<div class="flex flex-col gap-2">
{#each spaceUrls as url (url)}
<div class="card2 bg-alt flex flex-row items-center gap-2">
<div class="flex-shrink-0">
<SpaceAvatar {url} />
</div>
<div class="flex flex-grow flex-col">
<RelayName {url} />
<div class="text-sm opacity-75">
{url}
</div>
</div>
<Link class="btn btn-primary" href={makeSpacePath(url)}>
Go to space
<Icon icon="alt-arrow-right" />
</Link>
</div>
{:else}
<div class="card2 bg-alt text-center">
<p class="opacity-75">No spaces found for this user</p>
</div>
{/each}
<ModalFooter>
<Button onclick={back} class="hidden md:btn md:btn-link">
<Icon icon="alt-arrow-left" />
Go back
</Button>
</ModalFooter>
</div>
+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}
+40 -13
View File
@@ -2,20 +2,29 @@
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import {groupBy, uniq, uniqBy, batch, displayList} from "@welshman/lib"
import {REACTION, getReplyFilters, getTag, REPORT, DELETE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {
REACTION,
getReplyFilters,
getEmojiTags,
getEmojiTag,
getTag,
REPORT,
DELETE,
} from "@welshman/util"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {load} from "@welshman/net"
import {pubkey, repository, displayProfileByPubkey} from "@welshman/app"
import {isMobile, preventDefault, stopPropagation} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Reaction from "@app/components/Reaction.svelte"
import EventReportDetails from "@app/components/EventReportDetails.svelte"
import {displayReaction} from "@app/state"
import {pushModal} from "@app/modal"
interface Props {
event: any
onReactionClick: any
event: TrustedEvent
deleteReaction: (event: TrustedEvent) => void
createReaction: (event: EventContent) => void
url?: string
reactionClass?: string
noTooltip?: boolean
@@ -24,7 +33,8 @@
const {
event,
onReactionClick,
deleteReaction,
createReaction,
url = "",
reactionClass = "",
noTooltip = false,
@@ -39,14 +49,31 @@
filters: [{kinds: [REACTION], "#e": [event.id]}],
})
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 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(
groupBy(
e => e.content,
uniqBy(e => e.pubkey + e.content, $reactions),
getReactionKey,
uniqBy(e => `${e.pubkey}${getReactionKey(e)}`, $reactions),
),
)
@@ -78,7 +105,7 @@
{#if url && $reports.length > 0}
<button
type="button"
data-tip="{`This content has been reported as "${displayList(reportReasons)}".`}}"
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
class="btn btn-error btn-xs tooltip-right flex items-center gap-1 rounded-full"
class:tooltip={!noTooltip && !isMobile}
onclick={stopPropagation(preventDefault(onReportClick))}>
@@ -86,12 +113,12 @@
<span>{$reports.length}</span>
</button>
{/if}
{#each groupedReactions.entries() as [content, events]}
{#each groupedReactions.entries() as [key, events]}
{@const pubkeys = events.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} reacted ${displayReaction(content)}`}
{@const onClick = () => onReactionClick(content, events)}
{@const tooltip = `${info} reacted`}
{@const onClick = () => onReactionClick(events)}
<button
type="button"
data-tip={tooltip}
@@ -101,7 +128,7 @@
class:border-solid={isOwn}
class:border-primary={isOwn}
onclick={stopPropagation(preventDefault(onClick))}>
<span>{displayReaction(content)}</span>
<Reaction event={events[0]} />
{#if events.length > 1}
<span>{events.length}</span>
{/if}
+43 -36
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {randomId} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import {uniqBy, nth} from "@welshman/lib"
import {displayRelayUrl, makeRoomMeta} from "@welshman/util"
import {deriveRelay, getThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -10,41 +10,41 @@
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {hasNip29} from "@app/state"
import {addRoomMembership, nip29, getThunkError} from "@app/commands"
import {hasNip29, loadChannel} from "@app/state"
import {makeSpacePath} from "@app/routes"
import {pushToast} from "@app/toast"
const {url} = $props()
const room = randomId()
const room = makeRoomMeta()
const relay = deriveRelay(url)
const back = () => history.back()
const tryCreate = async () => {
if (hasNip29($relay)) {
const createMessage = await getThunkError(nip29.createRoom(url, room))
room.tags = uniqBy(nth(0), [...room.tags, ["name", name]])
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
return pushToast({theme: "error", message: createMessage})
}
const createMessage = await getThunkError(createRoom(url, room))
const editMessage = await getThunkError(nip29.editMeta(url, room, {name}))
if (editMessage) {
return pushToast({theme: "error", message: editMessage})
}
const joinMessage = await getThunkError(nip29.joinRoom(url, room))
if (joinMessage && !joinMessage.includes("already")) {
return pushToast({theme: "error", message: joinMessage})
}
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
return pushToast({theme: "error", message: createMessage})
}
addRoomMembership(url, room, name)
goto(makeSpacePath(url, room))
const editMessage = await getThunkError(editRoom(url, room))
if (editMessage) {
return pushToast({theme: "error", message: editMessage})
}
const joinMessage = await getThunkError(joinRoom(url, room))
if (joinMessage && !joinMessage.includes("already")) {
return pushToast({theme: "error", message: joinMessage})
}
await loadChannel(url, room.id)
goto(makeSpacePath(url, room.id))
}
const create = async () => {
@@ -72,23 +72,30 @@
</div>
{/snippet}
</ModalHeader>
<Field>
{#snippet label()}
<p>Room Name</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="hashtag" />
<input bind:value={name} class="grow" type="text" />
</label>
{/snippet}
</Field>
{#if hasNip29($relay)}
<Field>
{#snippet label()}
<p>Room Name</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="hashtag" />
<input bind:value={name} class="grow" type="text" />
</label>
{/snippet}
</Field>
{:else}
<p class="bg-alt card2 row-2">
<Icon icon="danger" />
This relay does not support creating rooms.
</p>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={!name || loading}>
<Button type="submit" class="btn btn-primary" disabled={!name || loading || !hasNip29($relay)}>
<Spinner {loading}>Create Room</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@noble/hashes/utils"
import {hexToBytes} from "@welshman/lib"
import {makeSecret} from "@welshman/signer"
import {preventDefault, downloadText} from "@lib/html"
import Link from "@lib/components/Link.svelte"
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type {Profile} from "@welshman/util"
import {PROFILE, createProfile, makeProfile, createEvent} from "@welshman/util"
import {PROFILE, createProfile, makeProfile, makeEvent} from "@welshman/util"
import {loginWithNip01, publishThunk} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileEditForm from "@app/components/ProfileEditForm.svelte"
@@ -18,7 +18,7 @@
}
const onsubmit = ({profile, shouldBroadcast}: {profile: Profile; shouldBroadcast: boolean}) => {
const event = createEvent(PROFILE, createProfile(profile))
const event = makeEvent(PROFILE, createProfile(profile))
const relays = shouldBroadcast ? INDEXER_RELAYS : []
loginWithNip01(secret)
@@ -0,0 +1,84 @@
<script lang="ts">
import {displayUrl} from "@welshman/lib"
import {Pool, AuthStatus} from "@welshman/net"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import SpaceJoinConfirm, {confirmSpaceJoin} from "@app/components/SpaceJoinConfirm.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import {attemptRelayAccess} from "@app/commands"
type Props = {
url: string
}
const {url}: Props = $props()
const back = () => history.back()
const joinRelay = async () => {
const error = await attemptRelayAccess(url, claim)
if (error) {
return pushToast({theme: "error", message: error, timeout: 30_000})
}
const socket = Pool.get().get(url)
if (socket.auth.status === AuthStatus.None) {
pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
} else {
await confirmSpaceJoin(url)
}
}
const join = async () => {
loading = true
try {
await joinRelay()
} finally {
loading = false
}
}
let claim = $state("")
let loading = $state(false)
</script>
<form class="column gap-4" onsubmit={preventDefault(join)}>
<ModalHeader>
{#snippet title()}
<div>Request Access</div>
{/snippet}
{#snippet info()}
<div>Enter an invite code below to request access to {displayUrl(url)}.</div>
{/snippet}
</ModalHeader>
<Field>
{#snippet label()}
<p>Invite code*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="link-round" />
<input bind:value={claim} class="grow" type="text" />
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Join Space</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+6 -32
View File
@@ -1,49 +1,23 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {parse, renderAsHtml} from "@welshman/content"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {preventDefault} from "@lib/html"
import {ucFirst} from "@lib/util"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast"
import {clearModals} from "@app/modal"
import {attemptRelayAccess} from "@app/commands"
import SpaceAccessRequest from "@app/components/SpaceAccessRequest.svelte"
import {pushModal} from "@app/modal"
const {url, error} = $props()
const back = () => history.back()
const joinRelay = async () => {
const error = await attemptRelayAccess(url)
if (error) {
return pushToast({theme: "error", message: error})
}
pushToast({
message: "You have successfully joined the space!",
})
clearModals()
}
const join = async () => {
loading = true
try {
await joinRelay()
} finally {
loading = false
}
}
let loading = $state(false)
const requestAccess = () => pushModal(SpaceAccessRequest, {url})
</script>
<form class="column gap-4" onsubmit={preventDefault(join)}>
<form class="column gap-4" onsubmit={preventDefault(requestAccess)}>
<ModalHeader>
{#snippet title()}
<div>Access Error</div>
@@ -63,8 +37,8 @@
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Request Access</Spinner>
<Button type="submit" class="btn btn-primary">
Request Access
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
@@ -28,13 +28,19 @@
{/snippet}
</ModalHeader>
<p>
<Link class="text-primary" external href="https://relay.tools">relay.tools</Link> is a third-party
service that allows anyone to run their own relay for use with {PLATFORM_NAME}, or any other
<Link class="link" external href="https://relay.tools">relay.tools</Link> is a third-party service
that allows anyone to run their own relay for use with {PLATFORM_NAME}, or any other
nostr-compatible app.
</p>
<p>
Once you've created a relay of your own, come back here to link {PLATFORM_NAME} with your new relay.
</p>
<p>
Alternatively, you can
<Link external class="link" href="https://github.com/coracle-social/frith"
>run your own community relay</Link
>.
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
+42 -12
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {tryCatch} from "@welshman/lib"
import {tryCatch, first, removeNil} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {Pool, AuthStatus} from "@welshman/net"
import {preventDefault} from "@lib/html"
@@ -17,21 +17,36 @@
const back = () => history.back()
const joinRelay = async (invite: string) => {
const [raw, claim] = invite.split("|")
const url = normalizeRelayUrl(raw)
const error = await attemptRelayAccess(url, claim)
const joinRelay = async () => {
const promises: Promise<string | undefined>[] = []
const [rawUrl, rawClaim] = url.split("|")
const normalizedUrl = normalizeRelayUrl(rawUrl)
if (claim) {
promises.push(attemptRelayAccess(normalizedUrl, claim))
}
if (rawClaim) {
promises.push(attemptRelayAccess(normalizedUrl, rawClaim))
}
if (promises.length === 0) {
promises.push(attemptRelayAccess(normalizedUrl, ""))
}
const error = first(removeNil(await Promise.all(promises)))
if (error) {
return pushToast({theme: "error", message: error, timeout: 30_000})
}
const socket = Pool.get().get(url)
const socket = Pool.get().get(normalizedUrl)
if (socket.auth.status === AuthStatus.None) {
pushModal(SpaceJoinConfirm, {url}, {replaceState: true})
pushModal(SpaceJoinConfirm, {url: normalizedUrl}, {replaceState: true})
} else {
await confirmSpaceJoin(url)
await confirmSpaceJoin(normalizedUrl)
}
}
@@ -39,13 +54,14 @@
loading = true
try {
await joinRelay(url)
await joinRelay()
} finally {
loading = false
}
}
let url = $state("")
let claim = $state("")
let loading = $state(false)
const linkIsValid = $derived(
@@ -59,12 +75,12 @@
<div>Join a Space</div>
{/snippet}
{#snippet info()}
<div>Enter an invite code below to join an existing space.</div>
<div>Enter a relay URL below to join an existing space.</div>
{/snippet}
</ModalHeader>
<Field>
{#snippet label()}
<p>Invite code*</p>
<p>Relay URL*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
@@ -74,11 +90,25 @@
{/snippet}
{#snippet info()}
<p>
You can also directly join any relay by entering its URL here.
Enter the URL of the relay that hosts the space you'd like to join.
<Button class="link" onclick={() => pushModal(InfoRelay)}>What is a relay?</Button>
</p>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Invite Code (optional)</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="ticket" />
<input bind:value={claim} class="grow" type="text" />
</label>
{/snippet}
{#snippet info()}
<p>If you have an invite code, enter it here to get access.</p>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
+2 -1
View File
@@ -7,7 +7,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {clearModals} from "@app/modal"
import {addSpaceMembership} from "@app/commands"
import {addSpaceMembership, broadcastUserData} from "@app/commands"
const {url} = $props()
@@ -16,6 +16,7 @@
const tryJoin = async () => {
await addSpaceMembership(url)
broadcastUserData([url])
clearModals()
}
+15 -2
View File
@@ -1,13 +1,26 @@
<script module lang="ts">
import {goto} from "$app/navigation"
import {ROOM_META} from "@welshman/util"
import {load} from "@welshman/net"
import {makeSpacePath} from "@app/routes"
import {addSpaceMembership} from "@app/commands"
import {addSpaceMembership, broadcastUserData} from "@app/commands"
import {pushToast} from "@app/toast"
export const confirmSpaceJoin = async (url: string) => {
await addSpaceMembership(url)
goto(makeSpacePath(url), {replaceState: true})
const path = makeSpacePath(url)
if (window.location.pathname === path) {
load({
relays: [url],
filters: [{kinds: [ROOM_META]}],
})
}
broadcastUserData([url])
goto(path, {replaceState: true})
pushToast({
message: "Welcome to the space!",
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import {deriveRelay} from "@welshman/app"
import {fade} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {makeThreadPath, makeCalendarPath, makeRoomPath, makeSpacePath} from "@app/routes"
import {
hasNip29,
deriveUserRooms,
deriveOtherRooms,
makeChannelId,
channelsById,
} from "@app/state"
import {notifications} from "@app/notifications"
import {pushModal} from "@app/modal"
type Props = {
url: string
}
const {url}: Props = $props()
const relay = deriveRelay(url)
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const chatPath = makeSpacePath(url, "chat")
const threadsPath = makeThreadPath(url)
const calendarPath = makeCalendarPath(url)
const addRoom = () => pushModal(RoomCreate, {url})
const filteredRooms = $derived(() => {
if (!term) return [...$userRooms, ...$otherRooms]
const query = term.toLowerCase()
const allRooms = [...$userRooms, ...$otherRooms]
return allRooms.filter(room => {
const channel = $channelsById.get(makeChannelId(url, room))
const roomName = channel?.name || room
return roomName.toLowerCase().includes(query)
})
})
let term = $state("")
</script>
<div class="card2 bg-alt md:hidden">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<Icon icon="compass-big" />
Quick Links
</h3>
<div class="flex flex-col gap-2">
<Link href={threadsPath} class="btn btn-primary w-full justify-start">
<div class="relative flex items-center gap-2">
<Icon icon="notes-minimalistic" />
Threads
{#if $notifications.has(threadsPath)}
<div
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary-content"
transition:fade>
</div>
{/if}
</div>
</Link>
<Link href={calendarPath} class="btn btn-secondary w-full justify-start">
<div class="relative flex items-center gap-2">
<Icon icon="calendar-minimalistic" />
Calendar
{#if $notifications.has(calendarPath)}
<div
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary-content"
transition:fade>
</div>
{/if}
</div>
</Link>
{#if hasNip29($relay)}
{#if $userRooms.length + $otherRooms.length > 10}
<label class="input input-sm input-bordered flex flex-grow items-center gap-2">
<Icon icon="magnifer" size={4} />
<input bind:value={term} class="grow" type="text" placeholder="Search rooms..." />
</label>
{/if}
{#each filteredRooms() as room (room)}
{@const roomPath = makeRoomPath(url, room)}
{@const channel = $channelsById.get(makeChannelId(url, room))}
<Link href={roomPath} class="btn btn-neutral btn-sm relative w-full justify-start">
<div class="flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
{#if channel?.closed || channel?.private}
<Icon icon="lock" size={4} />
{:else}
<Icon icon="hashtag" />
{/if}
<ChannelName {url} {room} />
</div>
{#if $notifications.has(roomPath)}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary" transition:fade>
</div>
{/if}
</Link>
{/each}
<Button onclick={addRoom} class="btn btn-neutral btn-sm w-full justify-start">
<Icon icon="add-circle" />
Create Room
</Button>
{:else}
<Link href={chatPath} class="btn btn-neutral w-full justify-start">
<div class="relative flex items-center gap-2">
<Icon icon="chat-round" />
Chat
{#if $notifications.has(chatPath)}
<div class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary" transition:fade>
</div>
{/if}
</div>
</Link>
{/if}
</div>
</div>
@@ -0,0 +1,104 @@
<script lang="ts">
import {derived} from "svelte/store"
import {groupBy, ago, MONTH, first, last, uniq, avg, overlappingPairs} from "@welshman/lib"
import {MESSAGE, getTagValue} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ConversationCard from "@app/components/ConversationCard.svelte"
import {deriveEventsForUrl} from "@app/state"
type Props = {
url: string
}
const {url}: Props = $props()
const since = ago(MONTH)
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}])
const conversations = derived(messages, $messages => {
const convs = []
for (const [room, messages] of groupBy(e => getTagValue("h", e.tags), $messages).entries()) {
const avgTime = avg(overlappingPairs(messages).map(([a, b]) => a.created_at - b.created_at))
const groups: TrustedEvent[][] = []
const group: TrustedEvent[] = []
// Group conversations by time between messages
let prevCreatedAt = messages[0].created_at
for (const message of messages) {
if (prevCreatedAt - message.created_at < avgTime) {
group.push(message)
} else {
groups.push(group.splice(0))
}
prevCreatedAt = message.created_at
}
if (group.length > 0) {
groups.push(group.splice(0))
}
// Convert each group into a conversation
for (const events of groups) {
if (events.length < 2) {
continue
}
const latest = first(events)!
const earliest = last(events)!
const participants = uniq(events.map(msg => msg.pubkey))
convs.push({room, events, latest, earliest, participants})
}
}
return convs
})
const viewMore = () => {
limit += 3
}
let limit = $state(3)
</script>
<div class="card2 bg-alt">
<div class="flex flex-col gap-4">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<Icon icon="chat-round" />
Recent Conversations
</h3>
<div class="flex flex-col gap-4">
{#if $conversations.length === 0}
{#if $messages.length > 0}
{@const events = $messages.slice(0, 1)}
{@const event = events[0]}
{@const room = getTagValue("h", event.tags)}
<ConversationCard
{url}
{room}
{events}
latest={event}
earliest={event}
participants={[event.pubkey]} />
{:else}
<div class="py-8 text-center opacity-70">
<p>No recent conversations</p>
</div>
{/if}
{:else}
{#each $conversations.slice(0, limit) as { room, events, latest, earliest, participants } (latest.id)}
<ConversationCard {url} {room} {events} {latest} {earliest} {participants} />
{/each}
{#if $conversations.length > limit}
<Button class="btn btn-primary" onclick={viewMore}>
View more conversations
<Icon icon="alt-arrow-down" />
</Button>
{/if}
{/if}
</div>
</div>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import {deriveRelay} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import SocketStatusIndicator from "@lib/components/SocketStatusIndicator.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
interface Props {
url: string
}
const {url}: Props = $props()
const relay = deriveRelay(url)
const owner = $derived($relay?.profile?.pubkey)
</script>
<div class="card2 bg-alt flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<Icon icon="server" />
Relay Details
</h3>
<SocketStatusIndicator {url} />
</div>
{#if $relay?.profile}
{@const {software, version, supported_nips, limitation} = $relay.profile}
<div class="flex flex-wrap gap-1">
{#if owner}
<div class="badge badge-neutral">
<span class="ellipsize">Administrator: <ProfileLink unstyled pubkey={owner} /></span>
</div>
{/if}
{#if $relay?.profile?.contact}
<div class="badge badge-neutral">
<span class="ellipsize">Contact: {$relay.profile.contact}</span>
</div>
{/if}
{#if software}
<div class="badge badge-neutral">
<span class="ellipsize">Software: {software}</span>
</div>
{/if}
{#if version}
<div class="badge badge-neutral">
<span class="ellipsize">Version: {version}</span>
</div>
{/if}
{#if Array.isArray(supported_nips)}
<p class="badge badge-neutral">
<span class="ellipsize">Supported NIPs: {supported_nips.join(", ")}</span>
</p>
{/if}
{#if limitation?.auth_required}
<p class="badge badge-warning">
<span class="ellipsize">Auth Required</span>
</p>
{/if}
{#if limitation?.payment_required}
<p class="badge badge-warning">
<span class="ellipsize">Payment Required</span>
</p>
{/if}
{#if limitation?.min_pow_difficulty}
<p class="badge badge-warning">
<span class="ellipsize">Min PoW: {limitation?.min_pow_difficulty}</span>
</p>
{/if}
</div>
{/if}
</div>
+5 -11
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
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"
@@ -18,20 +17,15 @@
const path = makeThreadPath(url, event.id)
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
if (reaction) {
publishDelete({relays: [url], event: reaction})
} else {
publishReaction({event, content, relays: [url]})
}
}
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} {onReactionClick} reactionClass="tooltip-left" />
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
+4 -9
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {writable} from "svelte/store"
import {createEvent, THREAD} from "@welshman/util"
import {makeEvent, THREAD} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
@@ -10,7 +10,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {pushToast} from "@app/toast"
import {GENERAL, tagRoom, PROTECTED} from "@app/state"
import {PROTECTED} from "@app/state"
import {makeEditor} from "@app/editor"
const {url} = $props()
@@ -41,16 +41,11 @@
})
}
const tags = [
...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
["title", title],
PROTECTED,
]
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title], PROTECTED]
publishThunk({
relays: [url],
event: createEvent(THREAD, {content, tags}),
event: makeEvent(THREAD, {content, tags}),
})
history.back()
+10 -7
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {nth} from "@welshman/lib"
import {stopPropagation} from "svelte/legacy"
import {nth, noop} from "@welshman/lib"
import {PublishStatus} from "@welshman/net"
import {
MergedThunk,
@@ -60,9 +61,11 @@
{@const url = failedUrls[0]}
{@const status = $thunk.status[url]}
{@const message = $thunk.details[url]}
<div class="flex justify-end px-1 text-xs {restProps.class}">
<button
class="flex w-full justify-end px-1 text-xs {restProps.class}"
onclick={stopPropagation(noop)}>
<Tippy
class="flex items-center {restProps.class}"
class="flex items-center"
component={ThunkStatusDetail}
props={{url, message, status, retry}}
params={{interactive: true}}>
@@ -73,10 +76,10 @@
</span>
{/snippet}
</Tippy>
</div>
</button>
{:else if showPending}
<div class="flex justify-end px-1 text-xs {restProps.class}">
<span class="flex items-center gap-1 {restProps.class}">
<div class="flex w-full justify-end px-1 text-xs {restProps.class}">
<span class="flex items-center gap-1">
<span class="loading loading-spinner mx-1 h-3 w-3 translate-y-px"></span>
<span class="opacity-50">Sending...</span>
<button
@@ -84,7 +87,7 @@
class="underline transition-all"
class:link={canCancel}
class:opacity-25={!canCancel}
onclick={abort}>
onclick={stopPropagation(abort)}>
Cancel
</button>
</span>
@@ -15,19 +15,21 @@
<script lang="ts">
import {clamp} from "@welshman/lib"
import {pubkey, getFollows, deriveUserWotScore} from "@welshman/app"
interface Props {
score: any
max?: number
active?: boolean
pubkey: string
}
const {score, max = 100, active = false}: Props = $props()
const {pubkey: target}: Props = $props()
const max = 100
const radius = 6
const center = radius + 1
const normalizedScore = $derived(clamp([0, max], score) / max)
const score = deriveUserWotScore(target)
const active = $derived(getFollows($pubkey!).includes(target))
const normalizedScore = $derived(clamp([0, max], $score) / max)
const dashOffset = $derived(100 - 44 * normalizedScore)
const style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`)
const stroke = $derived(active ? "var(--primary)" : "var(--base-content)")
+4 -13
View File
@@ -1,14 +1,8 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {
userFollows,
deriveUserWotScore,
deriveHandleForPubkey,
displayHandle,
deriveProfileDisplay,
} from "@welshman/app"
import WotScore from "@lib/components/WotScore.svelte"
import {displayPubkey} from "@welshman/util"
import {deriveHandleForPubkey, displayHandle, deriveProfileDisplay} from "@welshman/app"
import WotScore from "@app/components/WotScore.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
type Props = {
@@ -21,9 +15,6 @@
const pubkey = value
const profileDisplay = deriveProfileDisplay(pubkey, removeNil([url]))
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const following = $derived(getPubkeyTagValues(getListTags($userFollows)).includes(pubkey))
</script>
<div class="flex max-w-full gap-3">
@@ -35,7 +26,7 @@
<div class="text-bold overflow-hidden text-ellipsis text-base">
{$profileDisplay}
</div>
<WotScore score={$score} active={following} />
<WotScore {pubkey} />
</div>
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
+88 -68
View File
@@ -1,71 +1,33 @@
import {mount} from "svelte"
import type {Writable} from "svelte/store"
import {get} from "svelte/store"
import type {StampedEvent} from "@welshman/util"
import {makeEvent, getTagValues, getListTags, BLOSSOM_AUTH} from "@welshman/util"
import {simpleCache, normalizeUrl, removeNil, now} from "@welshman/lib"
import {sha256} from "@welshman/lib"
import {
getTagValues,
encryptFile,
uploadBlob,
makeBlossomAuthEvent,
getListTags,
} from "@welshman/util"
import {Router} from "@welshman/router"
import {Nip01Signer} from "@welshman/signer"
import {signer, profileSearch, userBlossomServers} from "@welshman/app"
import type {FileAttributes} from "@welshman/editor"
import {Editor, MentionSuggestion, WelshmanExtension} from "@welshman/editor"
import {makeMentionNodeView} from "./MentionNodeView"
import ProfileSuggestion from "./ProfileSuggestion.svelte"
import {pushToast} from "@app/toast"
export const hasBlossomSupport = simpleCache(async ([url]: [string]) => {
const $signer = signer.get()
const headers: Record<string, string> = {
"X-Content-Type": "text/plain",
"X-Content-Length": "1",
"X-SHA-256": "73cb3858a687a8494ca3323053016282f3dad39d42cf62ca4e79dda2aac7d9ac",
}
try {
if ($signer) {
const event = await signer.get().sign(
makeEvent(BLOSSOM_AUTH, {
tags: [
["t", "upload"],
["server", url],
["expiration", String(now() + 30)],
],
}),
)
headers.Authorization = `Nostr ${btoa(JSON.stringify(event))}`
}
const res = await fetch(normalizeUrl(url) + "/upload", {method: "head", headers})
return res.status === 200
} catch (e) {
if (!String(e).includes("Failed to fetch")) {
console.error(e)
}
}
return false
})
export const getUploadUrl = async (spaceUrl?: string) => {
export const getBlossomServer = () => {
const userUrls = getTagValues("server", getListTags(userBlossomServers.get()))
const allUrls = removeNil([spaceUrl, ...userUrls])
for (let url of allUrls) {
url = url.replace(/^ws/, "http")
if (await hasBlossomSupport(url)) {
return url
}
for (const url of userUrls) {
return url.replace(/^ws/, "http")
}
return "https://cdn.satellite.earth"
}
export const signWithAssert = async (template: StampedEvent) => {
const event = await signer.get().sign(template)
return event!
}
export const makeEditor = async ({
aggressive = false,
autofocus = false,
@@ -76,7 +38,6 @@ export const makeEditor = async ({
submit,
uploading,
wordCount,
disableFileUpload,
}: {
aggressive?: boolean
autofocus?: boolean
@@ -87,7 +48,6 @@ export const makeEditor = async ({
submit: () => void
uploading?: Writable<boolean>
wordCount?: Writable<number>
disableFileUpload?: boolean
}) => {
return new Editor({
content,
@@ -96,9 +56,6 @@ export const makeEditor = async ({
extensions: [
WelshmanExtension.configure({
submit,
sign: signWithAssert,
defaultUploadType: "blossom",
defaultUploadUrl: await getUploadUrl(url),
extensions: {
placeholder: {
config: {
@@ -110,18 +67,81 @@ export const makeEditor = async ({
aggressive,
},
},
fileUpload: disableFileUpload
? false
: {
config: {
onDrop() {
uploading?.set(true)
},
onComplete() {
uploading?.set(false)
},
},
fileUpload: {
config: {
upload: async (attrs: FileAttributes) => {
let file: Blob = attrs.file
if (!file.type.match("image/(webp|gif)")) {
const {default: Compressor} = await import("compressorjs")
file = await new Promise((resolve, _reject) => {
new Compressor(file, {
maxWidth: 1024,
maxHeight: 1024,
convertSize: 2 * 1024 * 1024,
success: resolve,
error: e => {
// Non-images break compressor
if (e.toString().includes("File or Blob")) {
return resolve(file)
}
_reject(e)
},
})
})
}
const {ciphertext, key, nonce, algorithm} = await encryptFile(file)
const tags = [
["decryption-key", key],
["decryption-nonce", nonce],
["encryption-algorithm", algorithm],
]
file = new File([new Blob([ciphertext])], attrs.file.name, {type: attrs.file.type})
const server = getBlossomServer()
const hashes = [await sha256(await file.arrayBuffer())]
const $signer = signer.get() || Nip01Signer.ephemeral()
const authTemplate = makeBlossomAuthEvent({action: "upload", server, hashes})
const authEvent = await $signer.sign(authTemplate)
try {
const res = await uploadBlob(server, file, {authEvent})
let {uploaded, url, ...task} = await res.json()
if (!uploaded) {
return {error: "Server refused to process the file"}
}
// Always append file extension if missing
if (new URL(url).pathname.split(".").length === 1) {
url += "." + attrs.file.type.split("/")[1]
}
const result = {...task, tags, url}
return {result}
} catch (e: any) {
console.error(e)
return {error: e.toString()}
}
},
onDrop() {
uploading?.set(true)
},
onComplete() {
uploading?.set(false)
},
onUploadError(currentEditor, task) {
currentEditor.commands.removeFailedUploads()
pushToast({theme: "error", message: "Failed to upload file"})
uploading?.set(false)
},
},
},
nprofile: {
extend: {
addNodeView: () => makeMentionNodeView(url),
+8
View File
@@ -9,6 +9,7 @@ import {
makeChatPath,
makeThreadPath,
makeCalendarPath,
makeSpaceChatPath,
makeRoomPath,
} from "@app/routes"
import {chats, getUrlsForEvent, userRoomsByUrl, repositoryStore} from "@app/state"
@@ -75,8 +76,10 @@ export const notifications = derived(
const spacePath = makeSpacePath(url)
const threadPath = makeThreadPath(url)
const calendarPath = makeCalendarPath(url)
const messagesPath = makeSpaceChatPath(url)
const threadEvents = allThreadEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
const calendarEvents = allCalendarEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
const messagesEvents = allMessageEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
if (hasNotification(threadPath, threadEvents[0])) {
paths.add(spacePath)
@@ -88,6 +91,11 @@ export const notifications = derived(
paths.add(calendarPath)
}
if (hasNotification(messagesPath, messagesEvents[0])) {
paths.add(spacePath)
paths.add(messagesPath)
}
const commentsByThreadId = groupBy(
e => getTagValue("E", e.tags),
threadEvents.filter(spec({kind: COMMENT})),
+131
View File
@@ -0,0 +1,131 @@
import * as nip19 from "nostr-tools/nip19"
import {Capacitor} from "@capacitor/core"
import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications"
import {PushNotifications} from "@capacitor/push-notifications"
import {parseJson, poll} from "@welshman/lib"
import {isSignedEvent} from "@welshman/util"
import {goto} from "$app/navigation"
import {ucFirst} from "@lib/util"
import {VAPID_PUBLIC_KEY} from "@app/state"
export const platform = Capacitor.getPlatform()
export const platformName = platform === "ios" ? "iOS" : ucFirst(platform)
export const initializePushNotifications = () => {
if (platform === "web") return
PushNotifications.addListener("pushNotificationActionPerformed", (action: ActionPerformed) => {
const event = parseJson(action.notification.data.event)
const parsedRelays = parseJson(action.notification.data.relays)
const relays = Array.isArray(parsedRelays) ? parsedRelays : []
if (isSignedEvent(event)) {
goto("/" + nip19.neventEncode({id: event.id, relays}))
}
})
}
export const canSendPushNotifications = () => ["web", "android", "ios"].includes(platform)
export const getWebPushInfo = async () => {
if (!("serviceWorker" in navigator)) {
throw new Error("Service Worker not supported")
}
if (!("PushManager" in window)) {
throw new Error("Push messaging not supported")
}
if (Notification.permission === "denied") {
throw new Error("Push notifications are blocked")
}
if (Notification.permission !== "granted") {
const permission = await Notification.requestPermission()
if (permission !== "granted") {
throw new Error("Push notification permission denied")
}
}
const registration = await navigator.serviceWorker.ready
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
})
}
const {keys} = subscription.toJSON()
if (!keys) {
throw new Error(`Failed to get push info: no keys were returned`)
}
return {
endpoint: subscription.endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
}
}
export type PushInfo = {
device_token: string
bundle_identifier?: string
}
export const getCapacitorPushInfo = async () => {
let status = await PushNotifications.checkPermissions()
if (status.receive === "prompt") {
status = await PushNotifications.requestPermissions()
}
if (status.receive !== "granted") {
throw new Error("Failed to register for push notifications")
}
let device_token = ""
let error = "Failed to register for push notifications"
PushNotifications.addListener("registration", (token: Token) => {
device_token = token.value
})
PushNotifications.addListener("registrationError", (_error: RegistrationError) => {
error = _error.error
})
await PushNotifications.register()
await poll({
condition: () => Boolean(device_token),
signal: AbortSignal.timeout(5000),
})
if (!device_token) {
throw new Error(error)
}
const info: PushInfo = {device_token}
if (platform === "ios") {
info.bundle_identifier = "social.flotilla"
}
return info
}
export const getPushInfo = (): Promise<Record<string, string>> => {
switch (platform) {
case "web":
return getWebPushInfo()
case "ios":
case "android":
return getCapacitorPushInfo()
default:
throw new Error(`Invalid push platform: ${platform}`)
}
}
+28 -4
View File
@@ -13,13 +13,22 @@ import {
sortBy,
assoc,
now,
isNotNil,
filterVals,
fromPairs,
} from "@welshman/lib"
import {
MESSAGE,
DELETE,
THREAD,
EVENT_TIME,
AUTH_INVITE,
COMMENT,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS,
matchFilters,
getTagValues,
getTagValue,
@@ -48,8 +57,6 @@ import {
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
import {
ALERT,
ALERT_STATUS,
NOTIFIER_RELAY,
INDEXER_RELAYS,
getDefaultPubkeys,
@@ -343,7 +350,7 @@ export const makeCalendarFeed = ({
export const loadAlerts = (pubkey: string) =>
load({
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT], authors: [pubkey]}],
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID], authors: [pubkey]}],
})
export const loadAlertStatuses = (pubkey: string) =>
@@ -359,7 +366,7 @@ export const listenForNotifications = () => {
for (const [url, allRooms] of userRoomsByUrl.get()) {
// Limit how many rooms we load at a time, since we have to send a separate filter
// for each one due to nip 29 breaking postel's law
// for each one due to relay29 being picky
const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
load({
@@ -367,6 +374,7 @@ export const listenForNotifications = () => {
relays: [url],
filters: [
{kinds: [THREAD], limit: 1},
{kinds: [MESSAGE], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
],
@@ -377,6 +385,7 @@ export const listenForNotifications = () => {
relays: [url],
filters: [
{kinds: [THREAD], since: now()},
{kinds: [MESSAGE], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
],
@@ -430,3 +439,18 @@ export const discoverRelays = (lists: List[]) =>
.filter(isShareableRelayUrl)
.map(url => loadRelay(url)),
)
export const requestRelayClaim = async (url: string) => {
const filters = [{kinds: [AUTH_INVITE], limit: 1}]
const events = await load({filters, relays: [url]})
if (events.length > 0) {
return getTagValue("claim", events[0].tags)
}
}
export const requestRelayClaims = async (urls: string[]) =>
filterVals(
isNotNil,
fromPairs(await Promise.all(urls.map(async url => [url, await requestRelayClaim(url)]))),
)
+74 -1
View File
@@ -1,6 +1,20 @@
import type {Page} from "@sveltejs/kit"
import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {nthEq, sleep} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {tracker} from "@welshman/app"
import {scrollToEvent} from "@lib/html"
import {identity} from "@welshman/lib"
import {makeChatId, decodeRelay, encodeRelay, userRoomsByUrl} from "@app/state"
import {
getTagValue,
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
MESSAGE,
THREAD,
EVENT_TIME,
} from "@welshman/util"
import {makeChatId, entityLink, decodeRelay, encodeRelay, userRoomsByUrl, ROOM} from "@app/state"
export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) => {
let path = `/spaces/${encodeRelay(url)}`
@@ -21,6 +35,8 @@ export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}
export const makeRoomPath = (url: string, room: string) => `/spaces/${encodeRelay(url)}/${room}`
export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat")
export const makeThreadPath = (url: string, eventId?: string) =>
makeSpacePath(url, "threads", eventId)
@@ -46,3 +62,60 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
return 0
}
}
export const goToEvent = async (event: TrustedEvent, options: Record<string, any> = {}) => {
if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) {
await scrollToEvent(event.id)
}
const urls = Array.from(tracker.getRelays(event.id))
const path = await getEventPath(event, urls)
if (path.includes("://")) {
window.open(path)
} else {
goto(path, options)
await sleep(300)
await scrollToEvent(event.id)
}
}
export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
const room = getTagValue(ROOM, event.tags)
if (urls.length > 0) {
const url = urls[0]
if (event.kind === THREAD) {
return makeThreadPath(url, event.id)
}
if (event.kind === EVENT_TIME) {
return makeCalendarPath(url, event.id)
}
if (event.kind === MESSAGE) {
return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
}
const kind = event.tags.find(nthEq(0, "K"))?.[1]
const id = event.tags.find(nthEq(0, "E"))?.[1]
if (id && kind) {
if (parseInt(kind) === THREAD) {
return makeThreadPath(url, id)
}
if (parseInt(kind) === EVENT_TIME) {
return makeCalendarPath(url, id)
}
if (parseInt(kind) === MESSAGE) {
return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
}
}
}
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
}
+183 -136
View File
@@ -2,7 +2,10 @@ import twColors from "tailwindcss/colors"
import {get, derived} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {
on,
call,
remove,
uniqBy,
sortBy,
sort,
uniq,
@@ -15,10 +18,19 @@ import {
memoize,
addToMapKey,
identity,
groupBy,
always,
} from "@welshman/lib"
import {load} from "@welshman/net"
import {collection} from "@welshman/store"
import type {Socket} from "@welshman/net"
import {Pool, load, AuthStateEvent, SocketEvent} from "@welshman/net"
import {
collection,
custom,
deriveEvents,
deriveEventsMapped,
withGetter,
synced,
} from "@welshman/store"
import {
getIdFilters,
WRAP,
@@ -27,11 +39,20 @@ import {
REACTION,
ZAP_RESPONSE,
DIRECT_MESSAGE,
GROUP_META,
DIRECT_MESSAGE_FILE,
ROOM_META,
MESSAGE,
GROUPS,
ROOMS,
THREAD,
COMMENT,
ROOM_JOIN,
ROOM_ADD_USER,
ROOM_REMOVE_USER,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS,
getGroupTags,
getRelayTagValues,
getPubkeyTagValues,
@@ -41,6 +62,9 @@ import {
getListTags,
asDecryptedEvent,
normalizeRelayUrl,
getTag,
getTagValue,
getTagValues,
} from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59, decrypt} from "@welshman/signer"
@@ -65,24 +89,18 @@ import {
appContext,
} from "@welshman/app"
import type {Thunk, Relay} from "@welshman/app"
import {deriveEvents, deriveEventsMapped, withGetter, synced} from "@welshman/store"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
export const ROOM = "h"
export const GENERAL = "_"
export const PROTECTED = ["-"]
export const ALERT = 32830
export const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
export const ALERT_STATUS = 32831
export const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY
export const NOTIFIER_PUBKEY = "27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df"
// export const NOTIFIER_RELAY = 'wss://notifier.flotilla.social/'
export const NOTIFIER_RELAY = "ws://localhost:4738/"
export const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY
export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS)
@@ -98,7 +116,7 @@ export const PLATFORM_LOGO = PLATFORM_URL + "/pwa-192x192.png"
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
export const PLATFORM_RELAY = import.meta.env.VITE_PLATFORM_RELAY
export const PLATFORM_RELAYS = fromCsv(import.meta.env.VITE_PLATFORM_RELAYS)
export const PLATFORM_ACCENT = import.meta.env.VITE_PLATFORM_ACCENT
@@ -116,7 +134,7 @@ export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
export const NIP46_PERMS =
"nip44_encrypt,nip44_decrypt," +
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, GROUPS, WRAP, REACTION]
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION]
.map(k => `sign_event:${k}`)
.join(",")
@@ -163,8 +181,6 @@ export const entityLink = (entity: string) => `https://coracle.social/${entity}`
export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) =>
entityLink(nip19.nprofileEncode({pubkey, relays}))
export const tagRoom = (room: string, url: string) => [ROOM, room]
export const getDefaultPubkeys = () => {
const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle(getPubkeyTagValues(getListTags(get(userFollows))))
@@ -297,6 +313,7 @@ export type Settings = {
report_usage: boolean
report_errors: boolean
send_delay: number
font_size: number
}
}
@@ -306,6 +323,7 @@ export const defaultSettings = {
report_usage: true,
report_errors: true,
send_delay: 3000,
font_size: 1,
}
export const settings = deriveEventsMapped<Settings>(repository, {
@@ -335,15 +353,17 @@ export type Alert = {
tags: string[][]
}
export const alerts = deriveEventsMapped<Alert>(repository, {
filters: [{kinds: [ALERT]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
export const alerts = withGetter(
deriveEventsMapped<Alert>(repository, {
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
return {event, tags}
},
})
return {event, tags}
},
}),
)
// Alert Statuses
@@ -352,15 +372,20 @@ export type AlertStatus = {
tags: string[][]
}
export const alertStatuses = deriveEventsMapped<AlertStatus>(repository, {
filters: [{kinds: [ALERT_STATUS]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
export const alertStatuses = withGetter(
deriveEventsMapped<AlertStatus>(repository, {
filters: [{kinds: [ALERT_STATUS]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
return {event, tags}
},
})
return {event, tags}
},
}),
)
export const deriveAlertStatus = (address: string) =>
derived(alertStatuses, statuses => statuses.find(s => getTagValue("d", s.event.tags) === address))
// Membership
@@ -389,25 +414,27 @@ export const getMembershipRoomsByUrl = (url: string, list?: List) =>
sort(getGroupTags(getListTags(list)).filter(nthEq(2, url)).map(nth(1)))
export const memberships = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [GROUPS]}],
filters: [{kinds: [ROOMS]}],
itemToEvent: item => item.event,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
export const {
indexStore: membershipByPubkey,
indexStore: membershipsByPubkey,
deriveItem: deriveMembership,
loadItem: loadMembership,
} = collection({
name: "memberships",
store: memberships,
getKey: list => list.event.pubkey,
load: makeOutboxLoader(GROUPS),
load: makeOutboxLoader(ROOMS),
})
// Chats
export const chatMessages = deriveEvents(repository, {filters: [{kinds: [DIRECT_MESSAGE]}]})
export const chatMessages = deriveEvents(repository, {
filters: [{kinds: [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]}],
})
export type Chat = {
id: string
@@ -475,126 +502,93 @@ export const chatSearch = derived(chats, $chats =>
// Messages
export const messages = derived(
deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]}),
$events => $events,
)
// Nip29
export const groupMeta = deriveEvents(repository, {filters: [{kinds: [GROUP_META]}]})
export const hasNip29 = (relay?: Relay) =>
relay?.profile?.supported_nips?.map?.(String)?.includes?.("29")
export const messages = deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]})
// Channels
export type ChannelMeta = {
access: "public" | "private"
membership: "open" | "closed"
export type Channel = {
id: string
url: string
room: string
name: string
event: TrustedEvent
closed: boolean
private: boolean
picture?: string
about?: string
}
export type Channel = {
url: string
room: string
name: string
meta?: ChannelMeta
}
export const makeChannelId = (url: string, room: string) => `${url}'${room}`
export const makeChannelId = (url: string, room: string) => `${url}|${room}`
export const splitChannelId = (id: string) => id.split("'")
export const splitChannelId = (id: string) => id.split("|")
export const hasNip29 = (relay?: Relay) =>
relay?.profile?.supported_nips?.map?.(String)?.includes?.("29")
export const channelsById = withGetter(
derived(
[groupMeta, memberships, messages, getUrlsForEvent],
([$groupMeta, $memberships, $messages, $getUrlsForEvent]) => {
const channelsById = new Map<string, Channel>()
export const channelEvents = deriveEvents(repository, {filters: [{kinds: [ROOM_META]}]})
// Add meta using group meta events
for (const event of $groupMeta) {
const meta = fromPairs(event.tags)
const room = meta.d
export const channels = derived(
[channelEvents, getUrlsForEvent],
([$channelEvents, $getUrlsForEvent]) => {
const $channels: Channel[] = []
if (room) {
for (const url of $getUrlsForEvent(event.id)) {
const id = makeChannelId(url, room)
for (const event of $channelEvents) {
const meta = fromPairs(event.tags)
const room = meta.d
channelsById.set(id, {
url,
room,
name: meta.name || room,
meta: {
access: meta.private ? "private" : "public",
membership: meta.closed ? "closed" : "open",
picture: meta.picture,
about: meta.about,
},
})
}
}
}
// Add known rooms based on membership events
for (const membership of $memberships) {
for (const {url, room, name} of getMembershipRooms(membership)) {
if (room) {
for (const url of $getUrlsForEvent(event.id)) {
const id = makeChannelId(url, room)
if (!channelsById.has(id)) {
channelsById.set(id, {url, room, name})
}
$channels.push({
id,
url,
room,
event,
name: meta.name || room,
closed: Boolean(getTag("closed", event.tags)),
private: Boolean(getTag("private", event.tags)),
picture: meta.picture,
about: meta.about,
})
}
}
}
// Add rooms based on known messages
for (const event of $messages) {
const [_, room] = event.tags.find(nthEq(0, ROOM)) || []
if (room) {
for (const url of $getUrlsForEvent(event.id)) {
const id = makeChannelId(url, room)
if (!channelsById.has(id)) {
channelsById.set(id, {url, room, name: room})
}
}
}
}
return channelsById
},
),
return uniqBy(c => c.id, $channels)
},
)
export const deriveChannel = (url: string, room: string) =>
derived(channelsById, $channelsById => $channelsById.get(makeChannelId(url, room)))
export const channelsByUrl = derived(channels, $channels => groupBy(c => c.url, $channels))
export const channelsByUrl = derived(channelsById, $channelsById => {
const $channelsByUrl = new Map<string, Channel[]>()
export const {
indexStore: channelsById,
deriveItem: _deriveChannel,
loadItem: _loadChannel,
} = collection({
name: "channels",
store: channels,
getKey: channel => channel.id,
load: async (id: string) => {
const [url, room] = splitChannelId(id)
for (const channel of $channelsById.values()) {
pushToMapKey($channelsByUrl, channel.url, channel)
}
return $channelsByUrl
await load({
relays: [url],
filters: [{kinds: [ROOM_META], "#d": [room]}],
})
},
})
export const displayChannel = (url: string, room: string) => {
if (room === GENERAL) {
return "general"
}
export const deriveChannel = (url: string, room: string) => _deriveChannel(makeChannelId(url, room))
return channelsById.get().get(makeChannelId(url, room))?.name || room
}
export const loadChannel = (url: string, room: string) => _loadChannel(makeChannelId(url, room))
export const displayChannel = (url: string, room: string) =>
channelsById.get().get(makeChannelId(url, room))?.name || room
export const roomComparator = (url: string) => (room: string) =>
displayChannel(url, room).toLowerCase()
export const channelIsLocked = (channel?: Channel) =>
channel?.meta?.access === "private" && channel?.meta?.membership === "closed"
// User stuff
export const userSettings = withGetter(
@@ -614,26 +608,28 @@ export const userSettingValues = withGetter(
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingValues.get()[key] as T
export const userMembership = withGetter(
derived([pubkey, membershipByPubkey], ([$pubkey, $membershipByPubkey]) => {
derived([pubkey, membershipsByPubkey], ([$pubkey, $membershipsByPubkey]) => {
if (!$pubkey) return undefined
loadMembership($pubkey)
return $membershipByPubkey.get($pubkey)
return $membershipsByPubkey.get($pubkey)
}),
)
export const userRoomsByUrl = withGetter(
derived(userMembership, $userMembership => {
derived([userMembership, channelsById], ([$userMembership, $channelsById]) => {
const tags = getListTags($userMembership)
const $userRoomsByUrl = new Map<string, Set<string>>()
for (const [_, room, url] of getGroupTags(tags)) {
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
for (const url of getRelayTagValues(tags)) {
$userRoomsByUrl.set(normalizeRelayUrl(url), new Set())
}
for (const url of getRelayTagValues(tags)) {
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), GENERAL)
for (const [_, room, url] of getGroupTags(tags)) {
if ($channelsById.has(makeChannelId(url, room))) {
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
}
}
return $userRoomsByUrl
@@ -642,7 +638,7 @@ export const userRoomsByUrl = withGetter(
export const deriveUserRooms = (url: string) =>
derived(userRoomsByUrl, $userRoomsByUrl =>
sortBy(roomComparator(url), uniq(Array.from($userRoomsByUrl.get(url) || [GENERAL]))),
sortBy(roomComparator(url), uniq(Array.from($userRoomsByUrl.get(url) || []))),
)
export const deriveOtherRooms = (url: string) =>
@@ -653,6 +649,41 @@ export const deriveOtherRooms = (url: string) =>
),
)
export enum MembershipStatus {
Initial,
Pending,
Granted,
}
export const deriveUserMembershipStatus = (url: string, room: string) =>
derived(
[
pubkey,
deriveEventsForUrl(url, [
{kinds: [ROOM_JOIN, ROOM_ADD_USER, ROOM_REMOVE_USER], "#h": [room]},
]),
],
([$pubkey, $events]) => {
let status = MembershipStatus.Initial
for (const event of $events) {
if (event.kind === ROOM_JOIN && event.pubkey === $pubkey) {
status = MembershipStatus.Pending
}
if (event.kind === ROOM_REMOVE_USER && getTagValues("p", event.tags).includes($pubkey!)) {
break
}
if (event.kind === ROOM_ADD_USER && getTagValues("p", event.tags).includes($pubkey!)) {
return MembershipStatus.Granted
}
}
return status
},
)
// Other utils
export const encodeRelay = (url: string) =>
@@ -669,3 +700,19 @@ export const displayReaction = (content: string) => {
if (content === "-") return "👎"
return content
}
export const deriveSocket = (url: string) =>
custom<Socket>(set => {
const pool = Pool.get()
const socket = pool.get(url)
set(socket)
const subs = [
on(socket, SocketEvent.Error, () => set(socket)),
on(socket, SocketEvent.Status, () => set(socket)),
on(socket.auth, AuthStateEvent.Status, () => set(socket)),
]
return () => subs.forEach(call)
})
+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.7491 9.70957V9.00497C18.7491 5.13623 15.7274 2 12 2C8.27256 2 5.25087 5.13623 5.25087 9.00497V9.70957C5.25087 10.5552 5.00972 11.3818 4.5578 12.0854L3.45036 13.8095C2.43882 15.3843 3.21105 17.5249 4.97036 18.0229C9.57274 19.3257 14.4273 19.3257 19.0296 18.0229C20.789 17.5249 21.5612 15.3843 20.5496 13.8095L19.4422 12.0854C18.9903 11.3818 18.7491 10.5552 18.7491 9.70957Z" stroke="#1C274C" stroke-width="1.5"/>
<path d="M7.5 19C8.15503 20.7478 9.92246 22 12 22C14.0775 22 15.845 20.7478 16.5 19" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 674 B

+9
View File
@@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.755 2H7.24502C6.08614 2 5.50671 2 5.03939 2.16261C4.15322 2.47096 3.45748 3.18719 3.15795 4.09946C3 4.58055 3 5.17705 3 6.37006V20.3742C3 21.2324 3.985 21.6878 4.6081 21.1176C4.97417 20.7826 5.52583 20.7826 5.8919 21.1176L6.375 21.5597C7.01659 22.1468 7.98341 22.1468 8.625 21.5597C9.26659 20.9726 10.2334 20.9726 10.875 21.5597C11.5166 22.1468 12.4834 22.1468 13.125 21.5597C13.7666 20.9726 14.7334 20.9726 15.375 21.5597C16.0166 22.1468 16.9834 22.1468 17.625 21.5597L18.1081 21.1176C18.4742 20.7826 19.0258 20.7826 19.3919 21.1176C20.015 21.6878 21 21.2324 21 20.3742V6.37006C21 5.17705 21 4.58055 20.842 4.09946C20.5425 3.18719 19.8468 2.47096 18.9606 2.16261C18.4933 2 17.9139 2 16.755 2Z" stroke="#1C274C" stroke-width="1.5"/>
<path d="M10.5 11L17 11" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M7 11H7.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M7 7.5H7.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M7 14.5H7.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M10.5 7.5H17" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M10.5 14.5H17" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 16.0909V11.0975C21 6.80891 21 4.6646 19.682 3.3323C18.364 2 16.2426 2 12 2C7.75736 2 5.63604 2 4.31802 3.3323C3 4.6646 3 6.80891 3 11.0975V16.0909C3 19.1875 3 20.7358 3.73411 21.4123C4.08421 21.735 4.52615 21.9377 4.99692 21.9915C5.98402 22.1045 7.13673 21.0849 9.44216 19.0458C10.4612 18.1445 10.9708 17.6938 11.5603 17.5751C11.8506 17.5166 12.1494 17.5166 12.4397 17.5751C13.0292 17.6938 13.5388 18.1445 14.5578 19.0458C16.8633 21.0849 18.016 22.1045 19.0031 21.9915C19.4739 21.9377 19.9158 21.735 20.2659 21.4123C21 20.7358 21 19.1875 21 16.0909Z" stroke="#1C274D" stroke-width="1.5"/>
<path d="M15 6H9" stroke="#1C274D" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 784 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.96173 18.9111L9.42605 18.3221L8.96173 18.9111ZM12 5.50088L11.4596 6.02098C11.601 6.16787 11.7961 6.25088 12 6.25088C12.2039 6.25088 12.399 6.16787 12.5404 6.02098L12 5.50088ZM15.0383 18.9111L15.5026 19.5001L15.0383 18.9111ZM9.42605 18.3221C7.91039 17.1273 6.25307 15.9605 4.93829 14.48C3.64922 13.0285 2.75 11.3347 2.75 9.13734H1.25C1.25 11.8029 2.3605 13.8363 3.81672 15.476C5.24723 17.0868 7.07077 18.3755 8.49742 19.5001L9.42605 18.3221ZM2.75 9.13734C2.75 6.98647 3.96537 5.18277 5.62436 4.42444C7.23607 3.68772 9.40166 3.88282 11.4596 6.02098L12.5404 4.98078C10.0985 2.44377 7.26409 2.02563 5.00076 3.0602C2.78471 4.07317 1.25 6.42527 1.25 9.13734H2.75ZM8.49742 19.5001C9.00965 19.9039 9.55954 20.3345 10.1168 20.6602C10.6739 20.9857 11.3096 21.2502 12 21.2502V19.7502C11.6904 19.7502 11.3261 19.6295 10.8736 19.3651C10.4213 19.1008 9.95208 18.7368 9.42605 18.3221L8.49742 19.5001ZM15.5026 19.5001C16.9292 18.3755 18.7528 17.0868 20.1833 15.476C21.6395 13.8363 22.75 11.8029 22.75 9.13734H21.25C21.25 11.3347 20.3508 13.0285 19.0617 14.48C17.7469 15.9605 16.0896 17.1273 14.574 18.3221L15.5026 19.5001ZM22.75 9.13734C22.75 6.42527 21.2153 4.07317 18.9992 3.0602C16.7359 2.02563 13.9015 2.44377 11.4596 4.98078L12.5404 6.02098C14.5983 3.88282 16.7639 3.68772 18.3756 4.42444C20.0346 5.18277 21.25 6.98647 21.25 9.13734H22.75ZM14.574 18.3221C14.0479 18.7368 13.5787 19.1008 13.1264 19.3651C12.6739 19.6295 12.3096 19.7502 12 19.7502V21.2502C12.6904 21.2502 13.3261 20.9857 13.8832 20.6602C14.4405 20.3345 14.9903 19.9039 15.5026 19.5001L14.574 18.3221Z" fill="#1C274C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.0672 11.8568L20.4253 11.469L21.0672 11.8568ZM12.1432 2.93276L11.7553 2.29085V2.29085L12.1432 2.93276ZM21.25 12C21.25 17.1086 17.1086 21.25 12 21.25V22.75C17.9371 22.75 22.75 17.9371 22.75 12H21.25ZM12 21.25C6.89137 21.25 2.75 17.1086 2.75 12H1.25C1.25 17.9371 6.06294 22.75 12 22.75V21.25ZM2.75 12C2.75 6.89137 6.89137 2.75 12 2.75V1.25C6.06294 1.25 1.25 6.06294 1.25 12H2.75ZM15.5 14.25C12.3244 14.25 9.75 11.6756 9.75 8.5H8.25C8.25 12.5041 11.4959 15.75 15.5 15.75V14.25ZM20.4253 11.469C19.4172 13.1373 17.5882 14.25 15.5 14.25V15.75C18.1349 15.75 20.4407 14.3439 21.7092 12.2447L20.4253 11.469ZM9.75 8.5C9.75 6.41182 10.8627 4.5828 12.531 3.57467L11.7553 2.29085C9.65609 3.5593 8.25 5.86509 8.25 8.5H9.75ZM12 2.75C11.9115 2.75 11.8077 2.71008 11.7324 2.63168C11.6686 2.56527 11.6538 2.50244 11.6503 2.47703C11.6461 2.44587 11.6482 2.35557 11.7553 2.29085L12.531 3.57467C13.0342 3.27065 13.196 2.71398 13.1368 2.27627C13.0754 1.82126 12.7166 1.25 12 1.25V2.75ZM21.7092 12.2447C21.6444 12.3518 21.5541 12.3539 21.523 12.3497C21.4976 12.3462 21.4347 12.3314 21.3683 12.2676C21.2899 12.1923 21.25 12.0885 21.25 12H22.75C22.75 11.2834 22.1787 10.9246 21.7237 10.8632C21.286 10.804 20.7293 10.9658 20.4253 11.469L21.7092 12.2447Z" fill="#1C274C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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