Compare commits

...

146 Commits

Author SHA1 Message Date
Jon Staab 750830d593 Bump version again 2025-09-18 14:39:15 -07:00
Jon Staab 3c0f1a1d2f Restore icons 2025-09-18 14:13:08 -07:00
Jon Staab 4253b0ed29 Remove all icons 2025-09-18 14:12:08 -07:00
Jon Staab 3c9b3f23df Upload profile pictures instead of doing base64 2025-09-18 13:43:43 -07:00
Jon Staab e0d83608be Bump version, changelog 2025-09-18 13:09:03 -07:00
Jon Staab a0301d599b Bump welshman, rename stripExifData 2025-09-18 12:17:31 -07:00
Jon Staab 7dcaa0e8d7 Change login icon 2025-09-16 11:06:36 -07:00
Matthew Remmel 129f49bcc7 Compress profile pictures on upload 2025-09-15 10:55:01 -07:00
Jon Staab fc3b68c390 Fix default avatar icon 2025-09-11 16:44:02 -07:00
Jon Staab 52c7df8a15 Default to light mode 2025-09-11 14:47:06 -07:00
Jon Staab ce1c4dd488 Fix some icons 2025-09-11 12:43:18 -07:00
Jon Staab fc6a1a3819 Move alerts to their own page, add direct message alerts 2025-09-11 12:28:54 -07:00
Jon Staab 69bd6d0e70 Use new icons 2025-09-11 08:59:47 -07:00
Jon Staab 6d383d54e8 Fix app image clipping 2025-09-09 10:53:16 -07:00
Jon Staab 998c48b1d3 Wait for thunk errors 2025-09-08 08:34:52 -07:00
Jon Staab 7217d122b5 Re-work invite links 2025-09-08 08:34:52 -07:00
Jon Staab 1c37c5bb3d Make white labeled nav look less bad 2025-09-05 16:21:17 -07:00
Jon Staab e8f785b558 Bump welshman 2025-09-05 11:35:34 -07:00
Matthew Remmel c94d314f6d Use capacitor preferences package instead of localStorage 2025-09-05 11:34:52 -07:00
Jon Staab 2672a8f922 Include instructions in key file 2025-09-05 10:49:20 -07:00
hodlbod 8a8d80d692 Merge pull request #197 from coracle-social/auth-errors
Auth errors
2025-09-04 11:25:30 -07:00
Jon Staab 95698813c6 Monitor relay connections for restricted responses and show error to user 2025-09-04 11:25:12 -07:00
hodlbod 4001e877b4 Merge pull request #193 from coracle-social/trusted-relays
Buffer unsigned events until approved
2025-09-04 11:21:54 -07:00
Jon Staab 99defc6d79 Allow users to opt-in to spaces that strip signatures 2025-09-03 16:36:30 -07:00
Jon Staab a94883089e Rename username to nickname 2025-09-03 16:34:00 -07:00
hodlbod 5ea4aeb75c Merge pull request #186 from coracle-social/flotilla-180-rooms-disappear
Save rooms to local storage
2025-08-27 06:25:08 -07:00
Matthew Remmel 456d111925 Save rooms to local storage 2025-08-27 09:07:32 -04:00
Jon Staab 837ae4b38e Update changelog, bump version 2025-08-26 11:17:38 -07:00
Jon Staab ffbcbf86c3 Bump welshman 2025-08-26 11:08:59 -07:00
Matthew Remmel bcda637192 Merge pull request #182 from coracle-social/flotilla-148-deep-linking
Add mobile deep linking support
2025-08-26 13:37:40 -04:00
Matthew Remmel 72c7dd6126 Add missing data config to android manifest 2025-08-26 13:31:01 -04:00
Matthew Remmel a2a4b3599f Remove temporary code and comments 2025-08-26 13:17:25 -04:00
Matthew Remmel 4955a4f16c Add real values in app association files 2025-08-26 13:16:32 -04:00
Matthew Remmel bb1ff4fb11 Add temporary web event listener for deep link navigation testing in web
browser
2025-08-26 12:43:44 -04:00
Matthew Remmel b81f7c9ed3 Add basic deep link route handling 2025-08-26 12:14:11 -04:00
Matthew Remmel 689cfb6d45 Add placholder changes for deep linking 2025-08-26 10:37:45 -04:00
Jon Staab 9da3141650 Add indicator for who sent the most recent message in a converssation 2025-08-25 16:24:24 -07:00
Jon Staab e4fe18df2f Fix encrypted uploads, show error 2025-08-21 16:06:14 -07:00
Jon Staab ba80ebac63 Add contributing file, rename some files 2025-08-21 15:01:31 -07:00
Jon Staab d4943daa82 Add chat prompt to dashboard 2025-08-19 14:05:02 -07:00
Jon Staab cde03ec0fe Avoid reflow by showing chat thunk status in a toast 2025-08-19 14:03:04 -07:00
Jon Staab 4f6c08f8a2 Build better onboarding 2025-08-18 15:02:17 -07:00
Jon Staab 38e0fc53ad Update wallet to use welshman's session wallet 2025-08-18 13:26:28 -07:00
Jon Staab 2a30ca5306 Bump welshman, drop support for safe area insets 2025-08-06 15:09:22 -07:00
Jon Staab 4a4ea13bef Show relays a note was seen on 2025-08-04 12:32:44 -07:00
Jon Staab 239bd3f31a Remove invite code input from alert add screen 2025-08-01 12:57:29 -07:00
Jon Staab 831ec05012 Filter out non-global chat from global chat 2025-07-31 13:25:40 -07:00
Jon Staab 0cc0598287 Allow tapping on tippy triggers on mobile 2025-07-31 10:25:55 -07:00
Jon Staab 0a5bc618c2 Fix formatting 2025-07-30 16:37:05 -07:00
Jon Staab 069904f07a Only protect events if the relay will authenticate with the user 2025-07-30 16:31:40 -07:00
Jon Staab 03b42c8276 Monitor signer status 2025-07-30 15:54:10 -07:00
Jon Staab 8697cc23be Add signer status, re-work bunker login 2025-07-29 10:53:48 -07:00
Jon Staab 69e1f97e72 Display create at on event info 2025-07-22 10:10:20 -07:00
Jon Staab 3e832af3e4 Update version 2025-07-17 15:41:30 -07:00
Jon Staab 84b8650fa4 Shorten goals title for mobile 2025-07-17 15:40:07 -07:00
Jon Staab 83abb5aa94 Fix chat notifications so they take nip 29 into account 2025-07-17 15:35:49 -07:00
Jon Staab a12eddb47b Fix zaps on mobile 2025-07-17 15:29:26 -07:00
Jon Staab c87166247c Update zapstore.yaml 2025-07-17 15:21:05 -07:00
Jon Staab 037c8cb41b Disable zaps on ios 2025-07-17 14:39:59 -07:00
Jon Staab 79de2e1176 Bump version 2025-07-17 14:30:18 -07:00
Jon Staab d4b026a3ad Add zaps to threads/events 2025-07-15 15:56:55 -07:00
Jon Staab 00f383ff2e Add qr scanning for wallet connect 2025-07-15 15:49:26 -07:00
Jon Staab 6f6bb508db Handle invalid bunker url, update synced stores 2025-07-15 11:34:29 -07:00
Jon Staab e2a0672ca5 load messages in general on room relay 2025-07-09 14:28:07 -07:00
Jon Staab e2a5fe7a79 Fix sidebar overflow 2025-07-09 14:22:59 -07:00
Jon Staab 5d02ae75dc Bump welshman 2025-07-09 14:00:42 -07:00
Jon Staab 2460bbbc83 Fix balance coming from webln 2025-07-09 13:19:33 -07:00
Jon Staab 084d8d931b Load relay selections whenever we see a new pubkey 2025-07-09 09:17:45 -07:00
Jon Staab 6ee4ac1a89 Add funding goals 2025-07-07 15:28:36 -07:00
Jon Staab 1d07097350 Fix some zap bugs 2025-07-07 13:58:43 -07:00
Jon Staab 63d6b362c7 Remove info missing rooms 2025-07-07 12:46:17 -07:00
Jon Staab bfed277ea9 Add zaps 2025-07-04 06:22:19 -07:00
Jon Staab 9e8aa2ef3a show and copy npub 2025-07-02 16:48:44 -07:00
Jon Staab 4bbc0878f7 Bump apple version, add vapid key 2025-07-01 12:53:09 -07:00
Jon Staab 16a3ba2a9b Bump version 2025-06-30 11:08:42 -07:00
Jon Staab 7c11eb8947 Allow mark all as read on desktop 2025-06-30 11:03:02 -07:00
Jon Staab 6bdc8d4d9f Space alerts dialog 2025-06-30 10:41:42 -07:00
Jon Staab b9048936ba Tweak alerts button layout 2025-06-30 09:38:43 -07:00
Jon Staab b9620f4443 Add claim to alert add 2025-06-27 14:36:09 -07:00
Jon Staab f2249fe592 Handle conversations with no room 2025-06-27 09:44:01 -07:00
Jon Staab fd42a0e8d4 Clear badge when opening app 2025-06-27 09:41:30 -07:00
Jon Staab 37d52ba35f Show latest note as conversation 2025-06-27 09:00:43 -07:00
Jon Staab 3037323dc0 Add support for ios push notifications 2025-06-27 08:33:31 -07:00
Jon Staab 5301ef876d Fix notification badge for global chat 2025-06-24 17:36:14 -07:00
Jon Staab aa054d8b1a Fix ContentMention display 2025-06-24 17:23:07 -07:00
Jon Staab 3655790e5f Add fcm push notifications 2025-06-24 14:27:16 -07:00
Jon Staab 6cca823ed4 Get web push working 2025-06-23 11:16:25 -07:00
Jon Staab 18a383edab Update alert form to include push notifications 2025-06-19 10:01:16 -07:00
Jon Staab 43da7d628e Replace bunker with claim on alerts page 2025-06-18 17:02:32 -07:00
Jon Staab 2fae3ca248 Fix broadcasting user profiles when protected 2025-06-16 16:56:59 -07:00
Jon Staab d99ada44f5 Show link url if no title is available 2025-06-16 11:45:05 -07:00
Jon Staab cb0119b9b8 Update welshman 2025-06-16 10:12:24 -07:00
Jon Staab dac9ef8e4e Move some stuff to welshman, broadcast profile updates 2025-06-13 15:17:20 -07:00
Jon Staab 528917b90e Fix sort order of thread comments 2025-06-13 10:14:12 -07:00
Jon Staab a22db78967 rename createEvent to makeEvent 2025-06-10 13:35:57 -07:00
Jon Staab 5718510779 Bump versions 2025-06-09 15:29:13 -07:00
Jon Staab f877dc7fbe Add chat quick link 2025-06-09 15:27:00 -07:00
Jon Staab df03fb1116 Remove old docker workflow 2025-06-09 15:20:09 -07:00
Jon Staab 7455b49f8d Bump version
Build and Publish Docker Image / build-and-push (push) Failing after 9s
2025-06-09 15:15:58 -07:00
Jon Staab ae00eb0b9c Bump welshman and nostr-editor
Build and Publish Docker Image / build-and-push (push) Failing after 2m15s
2025-06-09 15:04:51 -07:00
Jon Staab b82e434c70 Try turning ci off 2025-06-09 14:02:06 -07:00
Jon Staab 576c9c2c95 Bump welshman 2025-06-09 13:48:46 -07:00
Jon Staab cef046b3ae Increase maxLength for recent activity 2025-06-09 13:48:46 -07:00
Jon Staab 18ae6f6044 Use new editor uploads 2025-06-09 13:48:46 -07:00
Jon Staab 664f3c01e0 Bump nostr-tools 2025-06-09 13:48:46 -07:00
Jon Staab 15e82c4e41 Link to frith in SpaceCreateExternal 2025-06-09 13:48:46 -07:00
Jon Staab 397ecf773e Show warning for non-nip29 relays 2025-06-09 13:48:46 -07:00
Jon Staab 45397e7fb8 Show image link if image fails to load 2025-06-09 13:48:46 -07:00
Jon Staab 11aa841241 Add profile room list 2025-06-09 13:48:46 -07:00
Jon Staab cc1c18d55f Update context file 2025-06-09 13:48:46 -07:00
Jon Staab e3fbd69e6e Tweak profiles/search 2025-06-09 13:48:46 -07:00
Jon Staab ac756bf266 tweak relay status component 2025-06-09 13:48:46 -07:00
Jon Staab 8e28ff13e9 Generalize goToMessage 2025-06-09 13:48:46 -07:00
Jon Staab d8b87db784 Flesh out recent activity component 2025-06-09 13:48:46 -07:00
Jon Staab 0b8c6c4a49 Add claude context file and mock up recent activity 2025-06-09 13:48:46 -07:00
Jon Staab 9f4f468bf0 Add tos and pp links 2025-06-09 13:48:46 -07:00
Jon Staab 7563dff621 Move relay status to its own component 2025-06-09 13:48:46 -07:00
Jon Staab f782898b62 Factor space recent activity into its own component 2025-06-09 13:48:46 -07:00
Jon Staab d0601400cd Move space quick links to its own file 2025-06-09 13:48:45 -07:00
Jon Staab d262da39e5 Tweak room search and owner display 2025-06-09 13:48:45 -07:00
Jon Staab 7d617d8399 Add latest note from admin to relay dashboard 2025-06-09 13:48:45 -07:00
Jon Staab d2b7db18af Show socket status on space dashboard 2025-06-09 13:48:45 -07:00
Jon Staab 89c2690254 Add new room dashboard layout 2025-06-09 13:48:45 -07:00
Jon Staab 34945d1c42 Fix chat menu item active state 2025-06-09 13:48:45 -07:00
Jon Staab 43b207c4dc Use kind 15 to send images in DMs 2025-06-09 13:48:45 -07:00
Jon Staab 55efb3fdfd Use encrypted uploads 2025-06-09 13:48:45 -07:00
Jon Staab c4a1ad2e33 Ignore roocode 2025-06-09 13:48:45 -07:00
Jon Staab fd8442c632 Remove space blossom detection 2025-06-09 13:48:45 -07:00
Jon Staab e0875eb9b9 Add minimal style for quotes of chat messages 2025-06-09 13:48:45 -07:00
Jon Staab 962ac7d80c Support copying and pasting npubs better 2025-06-09 13:48:45 -07:00
Jon Staab 5338ee11bc Update changelog 2025-06-09 13:48:45 -07:00
Jon Staab 6d2e9a037d Allow for multiple platform relays 2025-06-09 13:48:45 -07:00
Jon Staab ac8530bd9a Add non-nip29 chat, add leave room 2025-06-09 13:48:45 -07:00
Jon Staab f7d11cf124 Improve group membership detection 2025-06-09 13:48:45 -07:00
Jon Staab 72d85e5740 Unnest nip29 commands 2025-06-09 13:48:45 -07:00
Jon Staab e57b5721f6 Detect nip29 support for create room button 2025-06-09 13:48:45 -07:00
Jon Staab 4ba6c72459 Remove unmanaged groups 2025-06-09 13:48:45 -07:00
Jon Staab c33698c662 Remove general room 2025-06-09 13:48:45 -07:00
Jon Staab cf4e40c4cf Add github action to publish dockerfile 2025-06-09 13:48:45 -07:00
Jon Staab 664da505cd Improve forms for entering invite codes 2025-05-27 15:00:30 -07:00
Jon Staab 573d4e3cfb Add support for customizable accent content color 2025-05-19 16:56:19 -07:00
Jon Staab b2dc41f25b Re-arrange the readme 2025-05-19 15:17:26 -07:00
Jon Staab b3bc0e4957 Add github action to publish dockerfile 2025-05-19 11:54:19 -07:00
Jon Staab 0e79e5b9cc Add dockerfile 2025-05-15 16:20:46 -07:00
Jon Staab 34c7bfcffb Get rid of overries, bump welshman to lockstep versioning 2025-05-15 15:52:17 -07:00
Jon Staab fd9fee8f50 Add indexer, maybe improve safe area support 2025-05-15 10:15:41 -07:00
1558 changed files with 15566 additions and 3530 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
android
ios
build
+4 -3
View File
@@ -4,14 +4,15 @@ VITE_PLATFORM_URL=https://flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/flotilla.png
VITE_PLATFORM_RELAY=
VITE_PLATFORM_LOGO=static/logo.png
VITE_PLATFORM_RELAYS=
VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/
VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/,wss://indexer.coracle.social/
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+1 -1
View File
@@ -1 +1 @@
package-lock.json -diff
pnpm-lock.yaml -diff
+59
View File
@@ -0,0 +1,59 @@
name: Docker
on:
push:
branches: ['master']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
+1
View File
@@ -60,6 +60,7 @@ google-services.json
GoogleService-Info.plist
# IDEs and editors
.roo
.idea/
.vscode/
+6
View File
@@ -1,2 +1,8 @@
pnpm run lint
pnpm run check
if [[ ! -z $(cat package.json | grep 'link:') ]]; then
echo "Some packages are linked to local files!"
exit 1
fi
+70
View File
@@ -1,5 +1,75 @@
# Changelog
# 1.2.5
* Fix icons in build
# 1.2.4
* Add direct message alerts
* Add alert settings page
* Add instructions to key download
* Add option that allows relays to strip signatures
* Detect relays that mostly refuse to serve requests
* Compress and upload profile images
* Use system theme by default
* Switch icon set, refactor how they're included
* Use capacitor's preferences for storage instead of localStorage
# 1.2.3
* Add `created_at` to event info dialog
* Add signer status to profile page
* Re-work bunker login flow
* Add in-app onboarding flow
* Only protect events if relay authenticates
* Filter out non-global chats from global chat
* Improve publish status indicator
* Fix encrypted upload content type
* Add relays to event details dialog
* Add universal link handler for apps
# 1.2.2
* Fix phantom chat notifications
* Fix zaps on mobile
# 1.2.1
* Add zaps to chat, threads, and events
* Add funding goals
* Add NWC support
* Add wallet settings page
* Handle invalid bunker url
* Fix sidebar overflow
* Fix profile npub display
# 1.2.0
* Fix sort order of thread comments
* Fix link display when no title is available
* Fix making profiles non-protected
* Replace bunker url with relay claims for notifier auth
* Add push notifications on all platforms
* Add "mark all as read" on desktop
* Re-design space dashboard
# 1.1.1
* Add chat quick link
# 1.1.0
* Add better theming support
* Improve forms for entering invite codes
* Rely more heavily on NIP 29 for rooms
* Support multiple platform relays
* Remove default general room
* Remove room tag from threads/calendars
* Show pubkey on profile detail
* Support pasting pubkey into chat start dialog
* Add minimal style for quoted messages
# 1.0.4
* Fix thunk status click handler
+96
View File
@@ -0,0 +1,96 @@
# Contributing guidelines
## Project Overview
Flotilla is a svelte/typescript/capacitor project. It's intended to be an alternative to Discord for Nostr users. A high-quality UX is a priority, with an emphasis on well-tested, intuitive designs, and robust implementations.
## Getting Started
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. To run the project on Android or iOS, use Android Studio or Xcode.
The `master` branch is automatically deployed to production, so always work on feature branches based on the `dev` branch. This project frequently uses unreleased versions of [welshman](https://welshman.coracle.social), using `pnpm` link to hotlink a local copy of the code. To set that up, clone welshman to the parent directory of your `coracle` client, then add `link:../welshman/packages/packagename` to the `pnpm.overrides` section of your `package.json`. Below is a nodejs script that will do that automatically for you:
```javascript
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
packageJson.pnpm.overrides = Object.keys(packageJson.dependencies)
.filter(pkg => pkg.startsWith('@welshman/'))
.reduce((acc, pkg) => {
const packageName = pkg.split('/')[1]
acc[pkg] = `link:../welshman/packages/${packageName}`
return acc
}, {})
fs.writeFileSync('./package.json', JSON.stringify(packageJson, null, 2) + '\n')
console.log('Added welshman package overrides.')
```
Be sure to avoid committing overrides to either `package.json` or `pnpm-lock.yaml`. These overrides can generally be added, installed, and removed, and will persist until another `pnpm install` command gets run.
## File Structure
The main parts of the application are as follows:
- `static` - static assets like fonts, images, etc.
- `src/assets` - svgs for use as icons.
- `src/lib` - general purpose components and utilities.
- `src/app/core/state` - environment variables, constants, custom stores, and some utilities derived from them.
- `src/app/core/requests` - utilities related to loading data from the nostr network.
- `src/app/core/commands` - utilities related to publishing nostr events and uploading media to blossom servers.
- `src/app/utils` - other application logic, including stuff related to modals, routing, etc.
- `src/app/editor` - configuration for `@welshman/editor` for use in various app views.
- `src/app/components` - reusable components that depend on other `app` stuff.
- `src/routes` - file-based routing interpreted by sveltekit.
Application organization is based on an acyclic dependency graph:
- `routes` can depend on anything
- `app/components` can depend on anything in `app` or `lib`
- `app/utils` and `app/core` can only depend on `lib`
- `lib` (and everything else) can depend only on external libraries
The main stylistic/organizational rule when working in this project is that imports should be sorted based on the dependency graph. Third-party libraries should come first, then `lib`, then `app`.
## System Architecture
Flotilla's architecture generally mirrors the file structure. State is stored using Svelte `store`s provided either by `@welshman/app` or by `app/core/state`, allowing for idiomatic svelte 4 usage (svelte 5 runes are [ghey](https://habla.news/u/hodlbod@coracle.social/1739830562159) and not allowed outside of UI components).
State is then synchronized to local storage or indexeddb using storage helpers provided by welshman in `routes/+layout.svelte`. Other top level synchronization logic generally belongs there.
`app/core/state` contains all environment variables, constants, custom stores, and utilities derived from them. Most stores are `derived` from our event `repository` using `deriveEventsMapped`, which efficiently queries the repository and maps events to custom data structures. Some of these data structures are provided by `welshman`, and some are defined in `app/core/state`. In either case, they can always be mapped back to an event, which is important for updating replaceables without dropping unknown data.
Here are a few important domain objects:
- Spaces are relays used as community groups. Their `url`s are core to a lot of data and components, and are frequently passed around from place to place.
- Chats are direct message conversations. There is currently some ambiguity in routing, since relays that don't support NIP 29 also have a "chat" tab, which uses vanilla NIP-C7.
- NIP 29 groups are variously called "rooms" and "channels". Conventionally, a "room" is a group id, while a "channel" as an object representing the group's metadata.
- "Alerts" are records of requests the user has made to be notified, following [this NIP](https://github.com/nostr-protocol/nips/pull/1796)
`app/core/requests` contains utilities related to loading data from the nostr network. This might include feed manager utilities, loaders, or listeners.
`app/core/commands` contains utilities related to publishing nostr events and uploading media to blossom servers. This also includes utilities related to sending lighting payments, authenticating with relays, or probing relay policy. Event creation should generally be split into `make` functions which build the event, and `publish` functions which publish the event using `publishThunk`.
Any of these utilities can be included either in `app/components` or `routes`. Crucial to keep in mind is that nearly all global state runs through welshman's `repository` in a unidirectional way. To update state, run `publishThunk`, which immediately publishes the event to the local repository. State can be read from the repository using `deriveEventsMapped` or other utilities provided by welshman like `deriveProfile`.
Thunks are designed to reduce UI latency, handling signatures and delayed sending the background. In most cases, thunk status should be displayed to the user so that they can cancel sending or address errors.
Toast, modals, and sidebar dialogs are controlled in `app/util/modal` and `app/util/toast`. In both cases, component objects can be passed along with parameters, but care has to be taken that the calling component either doesn't unmount before the modal (as when one modal replaces another), or that `$state.snapshot` is appropriately called on any state runes. These components frequently run into weird svelte compiler bugs too, in which case you may have to do some silly things to cope.
## Issues and Pull Requests
All work by contributors should be done against an issue. If there is no issue for the work you're doing, please open one or ask the project owner to open one. All PRs should be opened against the `dev` branch (unless for hotfixes).
## Communication
Discussion about development is done [on Flotilla](https://app.flotilla.social/spaces/internal.coracle.social). The group is currently closed, so please let me know if you'd like access.
## Project License
This project is licensed under the MIT license. By contributing, you agree to waive all intellectual property rights to your contributions to this project.
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-slim
# Install pnpm
RUN npm install -g pnpm@latest
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm i
# Copy the rest of the application
COPY . .
# Build the application
ENV NODE_OPTIONS=--max_old_space_size=16384
RUN pnpm run build
# Default to serving the build directory
CMD ["npx", "serve", "build"]
+15 -78
View File
@@ -4,14 +4,6 @@ A discord-like nostr client based on the idea of "relays as groups".
If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797
# Deploy
To run your own Flotilla, it's as simple as:
- `pnpm install`
- `pnpm run build`
- `npx serve build`
## Environment
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
@@ -20,7 +12,7 @@ You can also optionally create an `.env` file and populate it with the following
- `VITE_PLATFORM_URL` - The url where the app will be hosted. This is only used for build-time population of meta tags.
- `VITE_PLATFORM_NAME` - The name of the app
- `VITE_PLATFORM_LOGO` - A logo url for the app
- `VITE_PLATFORM_RELAY` - A relay url that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the platform relay the home page.
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
- `VITE_GLITCHTIP_API_KEY` - A Sentry DSN for use with glitchtip (error reporting)
@@ -28,84 +20,29 @@ You can also optionally create an `.env` file and populate it with the following
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
## Nginx/TLS (optional)
## Development
If you'd like to set up flotilla on a server you control, you'll want to set up a reverse proxy and provision a TSL certificate for the domain you'll be using. You should also make sure to add swap to your server.
See [./CONTRIBUTING.md](CONTRIBUTING.md).
There will be some parts of the following templates, for example `<SERVER NAME>`, which you'll need to fill in before running the code.
## Deployment
First, create an `A` record with your DNS provider pointing to the IP of your server. This will allow certbot to create your certificate later.
Next install `nginx`, `git`, and `certbot`. If you're on a debian- or ubuntu-based distro, run `sudo apt-get update && sudo apt-get install nginx git certbot python3-certbot-nginx`.
Now, create a new user where your code will be stored, clone the repository, fill in your `.env` file, and build the app.
To run your own Flotilla, it's as simple as:
```sh
# Replace with your password
PASSWORD=<YOUR PASSWORD HERE>
# Add the user and set a password
adduser flotilla
echo flotilla:$PASSWORD | chpasswd
# Login as flotilla
sudo su flotilla
# Go to flotilla's home directory
cd ~
# Install nvm, yarn, clone repos
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# Update PATH
. ~/.bashrc
# Clone repository and install dependencies
git clone https://github.com/coracle-social/flotilla.git
cd ~/flotilla
nvm install
nvm use
pnpm i
# Optionally create and populate .env to suit your use case
# Build the app
NODE_OPTIONS=--max_old_space_size=16384 pnpm run build
# Exit back to root
exit
pnpm install
pnpm run build
npx serve build
```
Once you've exited back to root, you can set up nginx. Place the following in a file named after your domain in the `/etc/nginx/sites-available` directory, for example, `flotilla.example.com`. This should match the `A` record you registered above.
Or, if you prefer to use a container:
```conf
server {
listen 80;
server_name <SERVER NAME>;
root /home/flotilla/flotilla/build;
index index.html;
location / {
try_files $uri /index.html;
}
}
```sh
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
```
Now you can run `certbot`, which will provision a TLS certificate for your domain and update your nginx configuration.
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
```sh
mkdir ./mount
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
```
certbot --nginx -d <SERVER NAME>
```
Now, enable the site and restart nginx. If you want to be careful, run `nginx -t` before restarting nginx.
```
ln -s /etc/nginx/sites-{available,enabled}/<SERVER NAME>
service nginx restart
```
Now, visit your domain. You should be all set up!
# Development
Run `pnpm run dev` to get a dev server, and `pnpm run check:watch` to watch for typescript errors. When you're ready to commit, run `pnpm run format && pnpm run lint` and fix any errors that come up.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 18
versionName "1.0.4"
versionCode 26
versionName "1.2.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+5
View File
@@ -9,8 +9,13 @@ 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-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capawesome-capacitor-android-dark-mode-support')
implementation project(':capawesome-capacitor-badge')
implementation project(':nostr-signer-capacitor-plugin')
}
+8
View File
@@ -20,6 +20,13 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.flotilla.social" />
</intent-filter>
</activity>
<provider
@@ -34,4 +41,5 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>
+2 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
@@ -11,6 +11,7 @@
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:ignore="NewApi">true</item>
</style>
+15
View File
@@ -2,11 +2,26 @@
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-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@7.0.2_@capacitor+core@7.2.0/node_modules/@capacitor/preferences/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-android-dark-mode-support'
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@7.0.0_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@7.0.1_@capacitor+core@7.2.0/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')
+6 -7
View File
@@ -1,16 +1,15 @@
#!/usr/bin/env bash
set -e
# Fetch tags and set to env vars
git fetch --prune --unshallow --tags
git describe --tags --abbrev=0
git fetch --prune --unshallow --tags || true
git describe --tags --abbrev=0 || true
export VITE_BUILD_VERSION=$RENDER_GIT_COMMIT
export VITE_BUILD_HASH=$RENDER_GIT_COMMIT
# Remove link overrides
node remove-pnpm-overrides.js package.json
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
pnpm i --no-frozen-lockfile
# Install dependencies
CI=0 pnpm i
# Rebuild sharp
pnpm rebuild
+7
View File
@@ -7,6 +7,9 @@ const config: CapacitorConfig = {
server: {
androidScheme: "https"
},
android: {
adjustMarginsForEdgeToEdge: false,
},
plugins: {
SplashScreen: {
androidSplashResourceName: "splash"
@@ -15,6 +18,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 = 11;
CURRENT_PROJECT_VERSION = 18;
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.4;
MARKETING_VERSION = 1.2.5;
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 = 11;
CURRENT_PROJECT_VERSION = 18;
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.4;
MARKETING_VERSION = 1.2.5;
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>
+12
View File
@@ -0,0 +1,12 @@
<?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>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:app.flotilla.social</string>
</array>
</dict>
</plist>
+4
View File
@@ -11,8 +11,12 @@ 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 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@7.0.2_@capacitor+core@7.2.0/node_modules/@capacitor/preferences'
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
+27 -31
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "1.0.4",
"version": "1.2.5",
"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,38 @@
},
"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/preferences": "^7.0.2",
"@capacitor/push-notifications": "^7.0.1",
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
"@capawesome/capacitor-badge": "^7.0.1",
"@getalby/sdk": "^5.1.0",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.35.0",
"@sveltejs/adapter-static": "^3.0.4",
"@tiptap/core": "^2.12.0",
"@types/qrcode": "^1.5.5",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "^0.2.5",
"@welshman/content": "^0.2.2",
"@welshman/dvm": "^0.2.0",
"@welshman/editor": "^0.2.4",
"@welshman/feeds": "^0.2.2",
"@welshman/lib": "^0.2.2",
"@welshman/net": "^0.2.3",
"@welshman/relay": "^0.2.0",
"@welshman/router": "^0.2.0",
"@welshman/signer": "^0.2.3",
"@welshman/store": "^0.2.0",
"@welshman/util": "^0.2.3",
"@welshman/app": "^0.4.7",
"@welshman/content": "^0.4.7",
"@welshman/editor": "^0.4.7",
"@welshman/feeds": "^0.4.7",
"@welshman/lib": "^0.4.7",
"@welshman/net": "^0.4.7",
"@welshman/relay": "^0.4.7",
"@welshman/router": "^0.4.7",
"@welshman/signer": "^0.4.7",
"@welshman/store": "^0.4.7",
"@welshman/util": "^0.4.7",
"compressorjs": "^1.2.1",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
@@ -71,25 +78,14 @@
"husky": "^9.1.6",
"idb": "^8.0.0",
"nostr-signer-capacitor-plugin": "^0.0.4",
"nostr-tools": "^2.7.2",
"nostr-tools": "^2.14.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4"
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2",
"tippy.js": "^6.3.7"
},
"pnpm": {
"overrides": {
"@welshman/lib": "link:../welshman/packages/lib",
"@welshman/util": "link:../welshman/packages/util",
"@welshman/app": "link:../welshman/packages/app",
"@welshman/content": "link:../welshman/packages/content",
"@welshman/dvm": "link:../welshman/packages/dvm",
"@welshman/feeds": "link:../welshman/packages/feeds",
"@welshman/net": "link:../welshman/packages/net",
"@welshman/relay": "link:../welshman/packages/relay",
"@welshman/router": "link:../welshman/packages/router",
"@welshman/signer": "link:../welshman/packages/signer",
"@welshman/store": "link:../welshman/packages/store",
"@welshman/editor": "link:../welshman/packages/editor"
},
"ignoredBuiltDependencies": [
"@sentry/cli",
"esbuild"
+983 -58
View File
File diff suppressed because it is too large Load Diff
-25
View File
@@ -1,25 +0,0 @@
// This script is necessary for installing stuff on a host, since our links don't exist there.
import fs from "fs"
const pkgName = process.argv[2]
if (!pkgName?.endsWith("package.json")) {
console.log("File passed was not a package.json file")
process.exit(1)
}
const pkg = JSON.parse(fs.readFileSync(pkgName, "utf8"))
if (pkg.pnpm && pkg.pnpm.overrides) {
// Use $package notation to make sure we only get one copy of each welshman dependency
// TODO: move welshman to a single package to straighten all this out.
for (const k of Object.keys(pkg.pnpm.overrides)) {
pkg.pnpm.overrides[k] = '$' + k
}
fs.writeFileSync(pkgName, JSON.stringify(pkg, null, 2) + "\n")
console.log("Removed pnpm.overrides from package.json")
} else {
console.log("No pnpm.overrides found in package.json")
}
+6 -6
View File
@@ -46,10 +46,10 @@
: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);
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
}
[data-theme] {
@@ -160,11 +160,11 @@
}
.card2 {
@apply rounded-box p-6 text-base-content;
@apply rounded-box p-4 text-base-content sm:p-6;
}
.card2.card2-sm {
@apply p-4 text-base-content;
@apply p-2 text-base-content sm:p-4;
}
.column {
-454
View File
@@ -1,454 +0,0 @@
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {randomId, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
DELETE,
REPORT,
PROFILE,
INBOX_RELAYS,
RELAYS,
FOLLOWS,
REACTION,
AUTH_JOIN,
GROUP_JOIN,
GROUP_LEAVE,
GROUP_CREATE,
GROUP_EDIT_META,
GROUPS,
COMMENT,
isSignedEvent,
createEvent,
displayProfile,
normalizeRelayUrl,
makeList,
addToListPublicly,
removeFromListByPredicate,
getTag,
getListTags,
getRelayTags,
getRelayTagValues,
toNostrURI,
getRelaysFromList,
RelayMode,
} from "@welshman/util"
import {Pool, PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
import {
pubkey,
signer,
repository,
publishThunk,
profilesByPubkey,
relaySelectionsByPubkey,
tagEvent,
tagEventForReaction,
userRelaySelections,
userInboxRelaySelections,
nip44EncryptToSelf,
loadRelay,
clearStorage,
dropSession,
tagEventForComment,
tagEventForQuote,
thunkIsComplete,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {
tagRoom,
PROTECTED,
userMembership,
INDEXER_RELAYS,
ALERT,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
userRoomsByUrl,
} from "@app/state"
// Utils
export const getPubkeyHints = (pubkey: string) => {
const selections = relaySelectionsByPubkey.get().get(pubkey)
const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
const hints = relays.length ? relays : INDEXER_RELAYS
return hints
}
export const getPubkeyPetname = (pubkey: string) => {
const profile = profilesByPubkey.get().get(pubkey)
const display = displayProfile(profile)
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({
id: parent.id,
kind: parent.kind,
author: parent.pubkey,
relays: Router.get().Event(parent).limit(3).getUrls(),
})
tags = [...tags, tagEventForQuote(parent)]
content = toNostrURI(nevent) + "\n\n" + content
}
return {content, tags}
}
// Log out
export const logout = async () => {
const $pubkey = pubkey.get()
if ($pubkey) {
dropSession($pubkey)
}
await clearStorage()
localStorage.clear()
}
// Synchronization
export const broadcastUserData = async (relays: string[]) => {
const authors = [pubkey.get()!]
const kinds = [RELAYS, INBOX_RELAYS, FOLLOWS, PROFILE]
const events = repository.query([{kinds, authors}])
for (const event of events) {
if (isSignedEvent(event)) {
await publishThunk({event, relays}).result
}
}
}
// 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 event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
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)])
return publishThunk({event, relays})
}
export const addRoomMembership = async (url: string, room: string, name: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const newTags = [
["r", url],
["group", room, url, name],
]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays})
}
export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
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)])
return publishThunk({event, relays})
}
export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
const list = get(userRelaySelections) || makeList({kind: RELAYS})
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
if (read && write) {
tags.push(["r", url])
} else if (read) {
tags.push(["r", url, "read"])
} else if (write) {
tags.push(["r", url, "write"])
}
return publishThunk({
event: createEvent(list.kind, {tags}),
relays: [
url,
...INDEXER_RELAYS,
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
}
export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
// Only update inbox policies if they already exist or we're adding them
if (enabled || getRelaysFromList(list).includes(url)) {
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
if (enabled) {
tags.push(["relay", url])
}
return publishThunk({
event: createEvent(list.kind, {tags}),
relays: [
...INDEXER_RELAYS,
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
}
}
// Relay access
export const checkRelayAccess = async (url: string, claim = "") => {
const socket = Pool.get().get(url)
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
const thunk = publishThunk({
event: createEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url],
})
const error = await getThunkError(thunk)
if (error) {
const message =
socket.auth.details?.replace(/^\w+: /, "") ||
error?.replace(/^\w+: /, "") ||
"join request rejected"
// 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
// Ignore messages about the relay ignoring ours
if (error?.startsWith("mute: ")) return
return message
}
}
export const checkRelayProfile = async (url: string) => {
const relay = await loadRelay(url)
if (!relay?.profile) {
return "Sorry, we weren't able to find that relay."
}
}
export const checkRelayConnection = async (url: string) => {
const socket = Pool.get().get(url)
socket.attemptToOpen()
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
return `Failed to connect`
}
}
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))
// Only raise an error if it's not a timeout.
// If it is, odds are the problem is with our signer, not the relay
if (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
return `Failed to authenticate (${socket.auth.details})`
}
}
export const attemptRelayAccess = async (url: string, claim = "") => {
const checks = [
() => checkRelayConnection(url),
() => checkRelayAccess(url, claim),
() => checkRelayAuth(url),
]
for (const check of checks) {
const error = await check()
if (error) {
return error
}
}
}
// Actions
export const makeDelete = ({event}: {event: TrustedEvent}) => {
const tags = [["k", String(event.kind)], ...tagEvent(event)]
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(PROTECTED)
tags.push(groupTag)
}
return createEvent(DELETE, {tags})
}
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
publishThunk({event: makeDelete({event}), relays})
export type ReportParams = {
event: TrustedEvent
content: string
reason: string
}
export const makeReport = ({event, reason, content}: ReportParams) => {
const tags = [
["p", event.pubkey],
["e", event.id, reason],
]
return createEvent(REPORT, {content, tags})
}
export const publishReport = ({
relays,
event,
reason,
content,
}: ReportParams & {relays: string[]}) =>
publishThunk({event: makeReport({event, reason, content}), relays})
export type ReactionParams = {
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeReaction = ({content, event, tags: paramTags = []}: ReactionParams) => {
const tags = [...paramTags, ...tagEventForReaction(event)]
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(PROTECTED)
tags.push(groupTag)
}
return createEvent(REACTION, {content, tags})
}
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
publishThunk({event: makeReaction(params), relays})
export type CommentParams = {
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeComment = ({event, content, tags = []}: CommentParams) =>
createEvent(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
}
export const makeAlert = async ({cron, email, feed, bunker, secret, description}: AlertParams) => {
const tags = [
["feed", JSON.stringify(feed)],
["cron", cron],
["email", email],
["locale", LOCALE],
["timezone", TIMEZONE],
["description", description],
["channel", "email"],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
]
if (bunker) {
tags.push(["nip46", secret, bunker])
}
return createEvent(ALERT, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
}
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
+94 -116
View File
@@ -1,30 +1,41 @@
<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 type {Filter} from "@welshman/util"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import {pubkey} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
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,
} from "@app/state"
import {loadAlertStatuses} from "@app/requests"
import {publishAlert} from "@app/commands"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import {alerts, getMembershipUrls, userMembership} from "@app/core/state"
import {requestRelayClaim} from "@app/core/requests"
import {createAlert} from "@app/core/commands"
import {canSendPushNotifications} from "@app/util/push"
import {pushToast} from "@app/util/toast"
type Props = {
url?: string
channel?: string
notifyChat?: boolean
notifyThreads?: boolean
notifyCalendar?: boolean
hideSpaceField?: boolean
}
let {
url = "",
channel = "email",
notifyChat = true,
notifyThreads = true,
notifyCalendar = true,
hideSpaceField = false,
}: Props = $props()
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100
const minute = randomInt(0, 59)
@@ -32,49 +43,21 @@
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 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,29 +88,37 @@
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 claim = url ? await requestRelayClaim(url) : undefined
await thunk.result
await loadAlertStatuses($pubkey!)
const {error} = await createAlert({
feed: makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(url)),
claims: claim ? {[url]: claim} : {},
description: `for ${displayList(display)} on ${displayRelayUrl(url)}`,
email: channel === "email" ? {cron, email} : undefined,
})
pushToast({message: "Your alert has been successfully created!"})
back()
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Your alert has been successfully created!"})
back()
}
} finally {
loading = false
}
}
onMount(() => {
if (!canSendPushNotifications()) {
channel = "email"
}
})
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
@@ -135,14 +126,24 @@
{#snippet title()}
Add an Alert
{/snippet}
{#snippet info()}
Enable notifications to keep up to date on activity you care about.
{/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 +165,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,61 +180,36 @@
</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
>.
</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" />
<Icon icon={AltArrowLeft} />
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" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+4 -5
View File
@@ -1,9 +1,8 @@
<script lang="ts">
import Confirm from "@lib/components/Confirm.svelte"
import type {Alert} from "@app/state"
import {NOTIFIER_RELAY} from "@app/state"
import {publishDelete} from "@app/commands"
import {pushToast} from "@app/toast"
import type {Alert} from "@app/core/state"
import {deleteAlert} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
type Props = {
alert: Alert
@@ -12,7 +11,7 @@
const {alert}: Props = $props()
const confirm = () => {
publishDelete({event: alert.event, relays: [NOTIFIER_RELAY]})
deleteAlert(alert)
pushToast({message: "Your alert has been deleted!"})
history.back()
}
+8 -36
View File
@@ -1,13 +1,14 @@
<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 {getTagValue, getTagValues} from "@welshman/util"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
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 {pushModal} from "@app/modal"
import AlertStatus from "@app/components/AlertStatus.svelte"
import type {Alert} from "@app/core/state"
import {pushModal} from "@app/util/modal"
type Props = {
alert: Alert
@@ -15,8 +16,6 @@
const {alert}: Props = $props()
const address = $derived(getAddress(alert.event))
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags))
@@ -35,36 +34,9 @@
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<Button class="py-1" onclick={startDelete}>
<Icon icon="trash-bin-2" />
<Icon icon={TrashBin2} />
</Button>
<div class="flex-inline gap-1">{description}</div>
</div>
{#if status}
{@const statusText = getTagValue("status", status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip="The notification server did not respond to your request.">
Inactive
</span>
{/if}
<AlertStatus {alert} />
</div>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import {getAddress, getTagValue} from "@welshman/util"
import type {Alert} from "@app/core/state"
import {deriveAlertStatus} from "@app/core/state"
type Props = {
alert: Alert
}
const {alert}: Props = $props()
const status = deriveAlertStatus(getAddress(alert.event))
</script>
{#if $status}
{@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", $status.tags)}>
Active
</span>
{:else if statusText === "pending"}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", $status.tags)}>
Pending
</span>
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", $status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span>
{/if}
{:else}
<span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip="The notification server did not respond to your request.">
Inactive
</span>
{/if}
+100 -26
View File
@@ -1,38 +1,112 @@
<script lang="ts">
import {onMount} from "svelte"
import {pubkey} from "@welshman/app"
import {sleep} from "@welshman/lib"
import {getTagValue, getAddress} from "@welshman/util"
import {isRelayFeed, findFeed} from "@welshman/feeds"
import Inbox from "@assets/icons/inbox.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
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"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {alerts, dmAlert, deriveAlertStatus, userInboxRelays, getAlertFeed} from "@app/core/state"
import {deleteAlert, createDmAlert} from "@app/core/commands"
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 dmStatus = $derived($dmAlert ? deriveAlertStatus(getAddress($dmAlert.event)) : undefined)
const filteredAlerts = $derived(
$alerts.filter(alert => {
const feed = getAlertFeed(alert)
// Skip non-feeds and DM alerts
if (!feed || alert === $dmAlert) return false
// If we have a space url, only match feeds for this space
if (url) return findFeed(feed, f => isRelayFeed(f) && f.includes(url))
return true
}),
)
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
const uncheckDmAlert = async (message: string) => {
await sleep(100)
toggle.checked = false
pushToast({theme: "error", message})
}
const onToggle = async () => {
if ($dmAlert) {
deleteAlert($dmAlert)
} else {
if ($userInboxRelays.length === 0) {
return uncheckDmAlert("Please set up your messaging relays before enabling alerts.")
}
const {error} = await createDmAlert()
if (error) {
return uncheckDmAlert(error)
}
pushToast({message: "Your alert has been successfully created!"})
}
}
let toggle: HTMLInputElement
</script>
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon="inbox" />
Alerts
</strong>
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
<Icon icon="add-circle" />
Add Alert
</Button>
<div class="col-4">
<div class="card2 bg-alt flex flex-col gap-6 shadow-xl">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<Icon icon={Inbox} />
Alerts
</strong>
<Button class="btn btn-primary btn-sm" onclick={startAlert}>
<Icon icon={AddCircle} />
Add Alert
</Button>
</div>
<div class="col-4">
{#each filteredAlerts as alert (alert.event.id)}
<AlertItem {alert} />
{:else}
<p class="text-center opacity-75 py-12">Nothing here yet!</p>
{/each}
</div>
</div>
<div class="col-4">
{#each $alerts as alert (alert.event.id)}
<AlertItem {alert} />
{:else}
<p class="text-center opacity-75 py-12">No alerts found</p>
{/each}
<div class="card2 bg-alt flex flex-col gap-4 shadow-xl">
<div class="flex justify-between">
<p>Notify me about new direct messages</p>
<input
type="checkbox"
class="toggle toggle-primary"
bind:this={toggle}
checked={Boolean($dmAlert)}
oninput={onToggle} />
</div>
{#if $dmStatus}
{@const status = getTagValue("status", $dmStatus.tags) || "error"}
{#if status !== "ok"}
<div class="alert alert-error border border-solid border-error bg-transparent text-error">
<p>
{getTagValue("message", $dmStatus.tags) ||
"The notification server did not respond to your request."}
</p>
</div>
{/if}
{/if}
</div>
</div>
+2 -2
View File
@@ -7,8 +7,8 @@
import PrimaryNav from "@app/components/PrimaryNav.svelte"
import EmailConfirm from "@app/components/EmailConfirm.svelte"
import PasswordReset from "@app/components/PasswordReset.svelte"
import {BURROW_URL} from "@app/state"
import {modals, pushModal} from "@app/modal"
import {BURROW_URL} from "@app/core/state"
import {modals, pushModal} from "@app/util/modal"
interface Props {
children: Snippet
+15 -69
View File
@@ -1,79 +1,25 @@
<script module lang="ts">
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
export class BunkerConnectController {
url = $state("")
bunker = $state("")
loading = $state(false)
clientSecret = makeSecret()
abortController = new AbortController()
broker = new Nip46Broker({clientSecret: this.clientSecret, relays: SIGNER_RELAYS})
onNostrConnect: (response: Nip46ResponseWithResult) => void
constructor({onNostrConnect}: {onNostrConnect: (response: Nip46ResponseWithResult) => void}) {
this.onNostrConnect = onNostrConnect
}
async start() {
this.url = await this.broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await this.broker.waitForNostrconnect(this.url, this.abortController.signal)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
this.loading = true
this.onNostrConnect(response)
}
}
stop() {
this.broker.cleanup()
this.abortController.abort()
}
}
</script>
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
import QRCode from "@app/components/QRCode.svelte"
import {pushToast} from "@app/toast"
import type {Nip46Controller} from "@app/util/nip46"
type Props = {
controller: BunkerConnectController
controller: Nip46Controller
}
const {controller}: Props = $props()
onMount(() => {
controller.start()
})
onDestroy(() => {
controller.stop()
})
const {url, loading} = controller
</script>
{#if controller.url}
<div class="flex justify-center" out:slideAndFade>
<QRCode code={controller.url} />
</div>
{#if $url}
{#if $loading}
<div class="flex justify-center">
<Spinner loading>Establishing connection...</Spinner>
</div>
{:else}
<div class="flex flex-col items-center gap-2">
<QRCode code={$url} />
<p class="text-sm opacity-75">Scan with your signer to log in, or click to copy.</p>
</div>
{/if}
{/if}
+29 -7
View File
@@ -1,16 +1,32 @@
<script lang="ts">
import {pushModal} from "@app/modal"
import InfoBunker from "@app/components/InfoBunker.svelte"
import {debounce} from "throttle-debounce"
import Scanner from "@lib/components/Scanner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import CpuBolt from "@assets/icons/cpu-bolt.svg?dataurl"
import QrCode from "@assets/icons/qr-code.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import InfoBunker from "@app/components/InfoBunker.svelte"
import type {Nip46Controller} from "@app/util/nip46"
import {pushModal} from "@app/util/modal"
type Props = {
bunker: string
loading: boolean
controller: Nip46Controller
}
let {loading, bunker = $bindable("")}: Props = $props()
const {controller}: Props = $props()
const {loading, bunker} = controller
const toggleScanner = () => {
showScanner = !showScanner
}
const onScan = debounce(1000, async (data: string) => {
showScanner = false
$bunker = data
})
let showScanner = $state(false)
</script>
<Field>
@@ -19,8 +35,11 @@
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
<Icon icon={CpuBolt} />
<input disabled={$loading} bind:value={$bunker} class="grow" placeholder="bunker://" />
<Button onclick={toggleScanner}>
<Icon icon={QrCode} />
</Button>
</label>
{/snippet}
{#snippet info()}
@@ -30,3 +49,6 @@
</p>
{/snippet}
</Field>
{#if showScanner}
<Scanner onscan={onScan} />
{/if}
+11 -7
View File
@@ -8,9 +8,10 @@
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {makeCalendarPath} from "@app/routes"
import {pushModal} from "@app/modal"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeCalendarPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
const {
url,
@@ -22,14 +23,17 @@
showActivity?: boolean
} = $props()
const shouldProtect = canEnforceNip70(url)
const path = makeCalendarPath(url, event.id)
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
@@ -44,7 +48,7 @@
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editEvent}>
<Icon size={4} icon="pen" />
<Icon size={4} icon={Pen2} />
Edit Event
</Button>
</li>
+28 -20
View File
@@ -2,10 +2,13 @@
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"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import MapPoint from "@assets/icons/map-point.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
@@ -13,9 +16,10 @@
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/core/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
import {pushToast} from "@app/util/toast"
import {canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
@@ -32,6 +36,8 @@
const {url, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const back = () => history.back()
@@ -63,20 +69,22 @@
}
const ed = await editor
const event = createEvent(EVENT_TIME, {
content: ed.getText({blockSeparator: "\n"}).trim(),
tags: [
["d", initialValues?.d || randomId()],
["title", title],
["location", location || ""],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
PROTECTED,
],
})
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [
["d", initialValues?.d || randomId()],
["title", title],
["location", location || ""],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
]
if (await shouldProtect) {
tags.push(PROTECTED)
}
const event = makeEvent(EVENT_TIME, {content, tags})
pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]})
@@ -126,7 +134,7 @@
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
<Icon icon={GallerySend} />
{/if}
</Button>
</div>
@@ -154,14 +162,14 @@
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="map-point" />
<Icon icon={MapPoint} />
<input bind:value={location} class="grow" type="text" />
</label>
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
@@ -6,6 +6,7 @@
formatTimestampAsTime,
} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
type Props = {
@@ -24,7 +25,7 @@
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p>
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
<Icon icon={ClockCircle} size={4} />
<span class="sm:hidden">{formatTimestampAsDate(start)}</span>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
+1 -1
View File
@@ -4,7 +4,7 @@
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import {makeCalendarPath} from "@app/routes"
import {makeCalendarPath} from "@app/util/routes"
type Props = {
url: string
+4 -2
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import {fromPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import MapPoint from "@assets/icons/map-point.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
@@ -15,12 +17,12 @@
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
<span class="flex items-center gap-1">
<Icon icon="user-circle" size={4} />
<Icon icon={UserCircle} size={4} />
Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span>
{#if meta.location}
<span class="flex items-start gap-1">
<Icon icon="map-point" class="mt-[2px]" size={4} />
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
<span class="break-words">{meta.location}</span>
</span>
{/if}
+4 -2
View File
@@ -2,6 +2,8 @@
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Plane from "@assets/icons/plane-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
@@ -48,7 +50,7 @@
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="gallery-send" />
<Icon icon={GallerySend} />
{/if}
</Button>
<div class="chat-editor flex-grow overflow-hidden">
@@ -59,6 +61,6 @@
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
disabled={$uploading}
onclick={submit}>
<Icon icon="plain" />
<Icon icon={Plane} />
</Button>
</form>
@@ -2,6 +2,7 @@
import type {TrustedEvent} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {slide} from "@lib/transition"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
@@ -30,6 +31,6 @@
expandMode="disabled" />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon="close-circle" />
<Icon icon={CloseCircle} />
</Button>
</div>
+20 -14
View File
@@ -5,31 +5,33 @@
import {isMobile} from "@lib/html"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ChannelMessageZapButton from "@app/components/ChannelMessageZapButton.svelte"
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
import {colors} from "@app/state"
import {publishDelete, publishReaction} from "@app/commands"
import {pushModal} from "@app/modal"
import {colors, ENABLE_ZAPS} from "@app/core/state"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
interface Props {
url: string
room: string
event: TrustedEvent
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
inert?: boolean
}
const {url, room, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const thunk = $thunks[event.id]
const shouldProtect = canEnforceNip70(url)
const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey, [url])
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
@@ -41,10 +43,11 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = (template: EventContent) =>
publishReaction({...template, event, relays: [url]})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<TapTarget
@@ -76,14 +79,14 @@
</div>
{/if}
<div class="text-sm">
<Content {event} {url} />
<Content minimalQuote {event} {url} />
{#if thunk}
<ThunkStatus {thunk} class="mt-2" />
<ThunkFailure showToastOnRetry {thunk} class="mt-2" />
{/if}
</div>
</div>
</div>
<div class="row-2 ml-10 mt-1">
<div class="row-2 ml-10 mt-1 pl-1">
<ReactionSummary
{url}
{event}
@@ -95,10 +98,13 @@
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
<ChannelMessageEmojiButton {url} {room} {event} />
{#if ENABLE_ZAPS}
<ChannelMessageZapButton {url} {event} />
{/if}
<ChannelMessageEmojiButton {url} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon="reply" size={4} />
<Icon icon={Reply} size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} />
@@ -1,19 +1,23 @@
<script lang="ts">
import {noop} from "@welshman/lib"
import type {NativeEmoji} from "emoji-picker-element/shared"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import {publishReaction} from "@app/commands"
import {publishReaction, canEnforceNip70} from "@app/core/commands"
const {url, room, event} = $props()
const {url, event} = $props()
// Tell svelte-check to shut up
noop(room)
const shouldProtect = canEnforceNip70(url)
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, relays: [url], content: emoji.unicode})
const onEmoji = async (emoji: NativeEmoji) =>
publishReaction({
event,
relays: [url],
content: emoji.unicode,
protect: await shouldProtect,
})
</script>
<EmojiButton {onEmoji} class="btn join-item btn-xs">
<Icon icon="smile-circle" size={4} />
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
+7 -4
View File
@@ -5,7 +5,10 @@
import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
import {pushModal} from "@app/util/modal"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
const {url, event, onClick} = $props()
@@ -28,21 +31,21 @@
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl">
<li>
<Button onclick={showInfo}>
<Icon size={4} icon="code-2" />
<Icon size={4} icon={Code2} />
Message Details
</Button>
</li>
{#if event.pubkey === $pubkey}
<li>
<Button onclick={showDelete} class="text-error">
<Icon size={4} icon="trash-bin-2" />
<Icon size={4} icon={TrashBin2} />
Delete Message
</Button>
</li>
{:else}
<li>
<Button class="text-error" onclick={report}>
<Icon size={4} icon="danger" />
<Icon size={4} icon={Danger} />
Report Content
</Button>
</li>
@@ -1,6 +1,7 @@
<script lang="ts">
import {type Instance} from "tippy.js"
import {between} from "@welshman/lib"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
@@ -29,7 +30,7 @@
<div class="flex">
<Button class="btn join-item btn-xs" onclick={open}>
<Icon icon="menu-dots" size={4} />
<Icon icon={MenuDots} size={4} />
</Button>
<Tippy
bind:popover
@@ -5,10 +5,17 @@
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {publishReaction} from "@app/commands"
import {pushModal} from "@app/modal"
import {ENABLE_ZAPS} from "@app/core/state"
import {publishReaction, canEnforceNip70} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
type Props = {
url: string
@@ -18,9 +25,16 @@
const {url, event, reply}: Props = $props()
const onEmoji = ((event: TrustedEvent, url: string, emoji: NativeEmoji) => {
const shouldProtect = canEnforceNip70(url)
const onEmoji = (async (event: TrustedEvent, url: string, emoji: NativeEmoji) => {
history.back()
publishReaction({event, relays: [url], content: emoji.unicode})
publishReaction({
event,
relays: [url],
content: emoji.unicode,
protect: await shouldProtect,
})
}).bind(undefined, event, url)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
@@ -37,20 +51,26 @@
<div class="col-2">
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon="smile-circle" />
<Icon size={4} icon={SmileCircle} />
Send Reaction
</Button>
{#if ENABLE_ZAPS}
<ZapButton replaceState {url} {event} class="btn btn-secondary w-full">
<Icon size={4} icon={Bolt} />
Send Zap
</ZapButton>
{/if}
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon="reply" />
<Icon size={4} icon={Reply} />
Send Reply
</Button>
<Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon="code-2" />
<Icon size={4} icon={Code2} />
Message Details
</Button>
{#if event.pubkey === $pubkey}
<Button class="btn btn-neutral text-error" onclick={showDelete}>
<Icon size={4} icon="trash-bin-2" />
<Icon size={4} icon={TrashBin2} />
Delete Message
</Button>
{/if}
@@ -0,0 +1,11 @@
<script lang="ts">
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
const {url, event} = $props()
</script>
<ZapButton {url} {event} class="btn join-item btn-xs">
<Icon icon={Bolt} size={4} />
</ZapButton>
+2 -6
View File
@@ -1,11 +1,7 @@
<script lang="ts">
import {GENERAL, channelsById, makeChannelId} from "@app/state"
import {channelsById, makeChannelId} from "@app/core/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}
+93 -25
View File
@@ -1,16 +1,37 @@
<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,
} from "@welshman/util"
import {
pubkey,
tagPubkey,
sendWrapped,
loadUsingOutbox,
mergeThunks,
loadInboxRelaySelections,
inboxRelaySelectionsByPubkey,
} from "@welshman/app"
import type {AbstractThunk} from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -26,15 +47,17 @@
import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {
INDEXER_RELAYS,
userSettingValues,
userSettingsValues,
deriveChat,
splitChatId,
PLATFORM_NAME,
} from "@app/state"
import {pushModal} from "@app/modal"
import {prependParent} from "@app/commands"
} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
type Props = {
id: string
@@ -61,13 +84,63 @@
}
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
const thunks: AbstractThunk[] = []
for (let i = 0; i < templates.length; i++) {
const template = templates[i]
thunks.push(
await sendWrapped({pubkeys, template, delay: $userSettingsValues.send_delay + ms(i)}),
)
}
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk: mergeThunks(thunks)},
},
})
clearParent()
@@ -98,7 +171,7 @@
id,
type: "note",
value: event,
showPubkey: created_at - previousCreatedAt > int(15, MINUTE) || previousPubkey !== pubkey,
showPubkey: created_at - previousCreatedAt > int(2, MINUTE) || previousPubkey !== pubkey,
})
previousDate = date
@@ -110,13 +183,8 @@
})
onMount(() => {
// Don't use loadInboxRelaySelection because we want to force reload
for (const pubkey of others) {
loadUsingOutbox({
pubkey,
kind: INBOX_RELAYS,
relays: INDEXER_RELAYS,
})
loadInboxRelaySelections(pubkey, INDEXER_RELAYS, true)
}
const observer = new ResizeObserver(() => {
@@ -183,7 +251,7 @@
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} {label} not configured.">
<Icon icon="danger" />
<Icon icon={Danger} />
{count}
</div>
{/if}
@@ -191,13 +259,13 @@
{/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">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
<Icon icon={Danger} />
Your inbox is not configured.
</p>
<p>
@@ -210,7 +278,7 @@
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon="danger" />
<Icon icon={Danger} />
{missingInboxes.length}
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured.
</p>
+16 -2
View File
@@ -2,6 +2,8 @@
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Plane from "@assets/icons/plane-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
@@ -20,6 +22,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 +44,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={GallerySend} />
{/if}
</Button>
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
@@ -53,6 +67,6 @@
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
disabled={$uploading}
onclick={submit}>
<Icon icon="plain" />
<Icon icon={Plane} />
</Button>
</form>
+2 -1
View File
@@ -2,6 +2,7 @@
import type {TrustedEvent} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {slide} from "@lib/transition"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
@@ -30,6 +31,6 @@
expandMode="disabled" />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon="close-circle" />
<Icon icon={CloseCircle} />
</Button>
</div>
+8 -12
View File
@@ -1,15 +1,16 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {WRAP} from "@welshman/util"
import {repository} from "@welshman/app"
import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {canDecrypt, PLATFORM_NAME, ensureUnwrapped} from "@app/state"
import {clearModals} from "@app/modal"
import {PLATFORM_NAME} from "@app/core/state"
import {enableGiftWraps} from "@app/core/commands"
import {clearModals} from "@app/util/modal"
const {next} = $props()
@@ -18,12 +19,7 @@
let loading = $state(false)
const enableChat = async () => {
canDecrypt.set(true)
for (const event of repository.query([{kinds: [WRAP]}])) {
ensureUnwrapped(event)
}
enableGiftWraps()
clearModals()
goto(nextUrl)
}
@@ -60,12 +56,12 @@
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Enable Messages</Spinner>
<Icon icon="alt-arrow-right" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+8 -3
View File
@@ -9,8 +9,8 @@
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {makeChatPath} from "@app/routes"
import {notifications} from "@app/notifications"
import {makeChatPath} from "@app/util/routes"
import {notifications} from "@app/util/notifications"
interface Props {
id: string
@@ -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(() => {
@@ -59,6 +59,11 @@
{/if}
</div>
<p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
<span class="opacity-50">
{#if props.messages[0].pubkey === $pubkey}
You:
{/if}
</span>
{props.messages[0].content}
</p>
</div>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import {waitForThunkCompletion} from "@welshman/app"
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
import Check from "@assets/icons/check.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {dmAlert, userInboxRelays} from "@app/core/state"
import {deleteAlert, createDmAlert} from "@app/core/commands"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
const markAsRead = () => {
setChecked("/chat/*")
history.back()
}
const enableAlerts = async () => {
if ($userInboxRelays.length === 0) {
return pushToast({
theme: "error",
message: "Please set up your messaging relays before enabling alerts.",
})
}
enablingAlert = true
try {
const {error} = await createDmAlert()
if (error) {
return pushToast({theme: "error", message: error})
}
} finally {
enablingAlert = false
}
}
const disableAlerts = async () => {
disablingAlert = true
try {
await waitForThunkCompletion(deleteAlert($dmAlert!))
} finally {
disablingAlert = false
}
}
let enablingAlert = $state(false)
let disablingAlert = $state(false)
</script>
<div class="col-2">
<Button class="btn btn-primary" onclick={startChat}>
<Icon size={5} icon={ChatSquare} />
Start chat
</Button>
<Button class="btn btn-neutral" onclick={markAsRead}>
<Icon size={5} icon={Check} />
Mark all read
</Button>
{#if (!enablingAlert && $dmAlert) || disablingAlert}
<Button class="btn btn-neutral" onclick={disableAlerts} disabled={disablingAlert}>
{#if !disablingAlert}
<Icon size={4} icon={BellOff} />
{/if}
<Spinner loading={disablingAlert}>Disable alerts</Spinner>
</Button>
{:else}
<Button class="btn btn-neutral" onclick={enableAlerts} disabled={enablingAlert}>
{#if !enablingAlert}
<Icon size={4} icon={Bell} />
{/if}
<Spinner loading={enablingAlert}>Enable alerts</Spinner>
</Button>
{/if}
</div>
-25
View File
@@ -1,25 +0,0 @@
<script lang="ts">
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ChatStart from "@app/components/ChatStart.svelte"
import {setChecked} from "@app/notifications"
import {pushModal} from "@app/modal"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
const markAsRead = () => {
setChecked("/chat/*")
history.back()
}
</script>
<div class="col-2">
<Button class="btn btn-primary" onclick={startChat}>
<Icon size={4} icon="add-circle" />
Start chat
</Button>
<Button class="btn btn-neutral" onclick={markAsRead}>
<Icon size={4} icon="check-circle" />
Mark all read
</Button>
</div>
+9 -8
View File
@@ -4,6 +4,7 @@
import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
import {isMobile} from "@lib/html"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
@@ -11,13 +12,13 @@
import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {colors} from "@app/state"
import {makeDelete, makeReaction} from "@app/commands"
import {pushModal} from "@app/modal"
import {colors} from "@app/core/state"
import {makeDelete, makeReaction} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
interface Props {
event: TrustedEvent
@@ -37,10 +38,10 @@
const reply = () => replyTo(event)
const deleteReaction = (event: TrustedEvent) =>
sendWrapped({template: makeDelete({event}), pubkeys})
sendWrapped({template: makeDelete({event, protect: false}), pubkeys})
const createReaction = (template: EventContent) =>
sendWrapped({template: makeReaction({event, ...template}), pubkeys})
sendWrapped({template: makeReaction({event, protect: false, ...template}), pubkeys})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
@@ -59,7 +60,7 @@
</script>
{#if thunk}
<ThunkStatus {thunk} class="mt-1" />
<ThunkFailure showToastOnRetry {thunk} class="mt-1" />
{/if}
<div
data-event={event.id}
@@ -87,7 +88,7 @@
class="opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}
onclick={togglePopover}>
<Icon icon="menu-dots" size={4} />
<Icon icon={MenuDots} size={4} />
</button>
</Tippy>
{/if}
@@ -2,9 +2,10 @@
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import {makeReaction} from "@app/commands"
import {makeReaction} from "@app/core/commands"
interface Props {
event: TrustedEvent
@@ -14,9 +15,9 @@
const {event, pubkeys}: Props = $props()
const onEmoji = (emoji: NativeEmoji) =>
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
sendWrapped({template: makeReaction({event, content: emoji.unicode, protect: false}), pubkeys})
</script>
<EmojiButton {onEmoji} class="btn join-item btn-xs">
<Icon icon="smile-circle" size={4} />
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
+5 -3
View File
@@ -3,7 +3,9 @@
import Button from "@lib/components/Button.svelte"
import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {pushModal} from "@app/modal"
import {pushModal} from "@app/util/modal"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
const {event, pubkeys, popover, replyTo} = $props()
@@ -19,10 +21,10 @@
<ChatMessageEmojiButton {event} {pubkeys} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon size={4} icon="reply" />
<Icon size={4} icon={Reply} />
</Button>
{/if}
<Button class="btn join-item btn-xs" onclick={showInfo}>
<Icon size={4} icon="code-2" />
<Icon size={4} icon={Code2} />
</Button>
</div>
@@ -6,9 +6,13 @@
import Button from "@lib/components/Button.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {makeReaction} from "@app/commands"
import {pushModal} from "@app/modal"
import {clip} from "@app/toast"
import {makeReaction} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import {clip} from "@app/util/toast"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
type Props = {
pubkeys: string[]
@@ -20,7 +24,7 @@
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back()
sendWrapped({template: makeReaction({event, content: emoji.unicode}), pubkeys})
sendWrapped({template: makeReaction({event, content: emoji.unicode, protect: false}), pubkeys})
}).bind(undefined, event, pubkeys)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
@@ -40,19 +44,19 @@
<div class="col-2">
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon="smile-circle" />
<Icon size={4} icon={SmileCircle} />
Send Reaction
</Button>
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon="reply" />
<Icon size={4} icon={Reply} />
Send Reply
</Button>
<Button class="btn btn-neutral w-full" onclick={copyText}>
<Icon size={4} icon="copy" />
<Icon size={4} icon={Copy} />
Copy Text
</Button>
<Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon="code-2" />
<Icon size={4} icon={Code2} />
Message Details
</Button>
</div>
+40 -4
View File
@@ -1,20 +1,56 @@
<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"
import Button from "@lib/components/Button.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {makeChatPath} from "@app/routes"
import {makeChatPath} from "@app/util/routes"
const back = () => history.back()
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,17 +64,17 @@
</ModalHeader>
<Field>
{#snippet input()}
<ProfileMultiSelect autofocus bind:value={pubkeys} />
<ProfileMultiSelect autofocus bind:value={pubkeys} {term} />
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={pubkeys.length === 0}>
Create Chat
<Icon icon="alt-arrow-right" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+38
View File
@@ -0,0 +1,38 @@
<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, canEnforceNip70} from "@app/core/commands"
import {makeThreadPath} from "@app/util/routes"
interface Props {
url: any
event: any
showActivity?: boolean
}
const {url, event, showActivity = false}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const path = makeThreadPath(url, event.id)
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</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>
+14 -5
View File
@@ -20,6 +20,7 @@
} from "@welshman/content"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
@@ -31,7 +32,7 @@
import ContentQuote from "@app/components/ContentQuote.svelte"
import ContentTopic from "@app/components/ContentTopic.svelte"
import ContentMention from "@app/components/ContentMention.svelte"
import {entityLink, userSettingValues} from "@app/state"
import {entityLink, userSettingsValues} from "@app/core/state"
interface Props {
event: any
@@ -40,6 +41,7 @@
showEntire?: boolean
hideMediaAtDepth?: number
expandMode?: string
minimalQuote?: boolean
depth?: number
url?: string
}
@@ -51,6 +53,7 @@
showEntire = $bindable(false),
hideMediaAtDepth = 1,
expandMode = "block",
minimalQuote = false,
depth = 0,
url,
}: Props = $props()
@@ -66,7 +69,7 @@
if (!parsed || hideMediaAtDepth <= depth) return false
if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) {
if (isLink(parsed) && $userSettingsValues.show_media && isStartOrEnd(i)) {
return true
}
@@ -99,7 +102,7 @@
}
let warning = $state(
$userSettingValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
)
const shortContent = $derived(
@@ -120,7 +123,7 @@
<div class="relative">
{#if warning}
<div class="card2 card2-sm bg-alt row-2">
<Icon icon="danger" />
<Icon icon={Danger} />
<p>
This note has been flagged by the author as "{warning}".<br />
<Button class="link" onclick={ignoreWarning}>Show anyway</Button>
@@ -153,7 +156,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
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type {ParsedEmojiValue} from "@welshman/content"
import {imgproxy} from "@app/state"
import {imgproxy} from "@app/core/state"
export let value: ParsedEmojiValue
+10 -12
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import {ellipsize, postJson} from "@welshman/lib"
import {dufflepud, imgproxy} from "@app/state"
import {ellipsize, displayUrl, postJson} from "@welshman/lib"
import {dufflepud, imgproxy} from "@app/core/state"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/modal"
import {pushModal} from "@app/util/modal"
const {value, event} = $props()
@@ -31,7 +31,7 @@
</script>
<Link external href={url} class="my-2 block">
<div class="overflow-hidden rounded-box leading-[0]">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/)}
<video controls src={url} class="max-h-96 object-contain object-center">
<track kind="captions" />
@@ -52,15 +52,13 @@
alt="Link preview"
onerror={onError}
src={imgproxy(preview.image)}
class="bg-alt max-h-72 object-contain object-center" />
{/if}
{#if preview.title}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
+36 -32
View File
@@ -1,49 +1,53 @@
<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 {imgproxy} from "@app/state"
import {onMount, onDestroy} from "svelte"
import {displayUrl} from "@welshman/lib"
import {getTags, decryptFile, getTagValue, tagsFromIMeta} from "@welshman/util"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import {imgproxy} from "@app/core/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={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</a>
{:else}
<img alt="" {src} onerror={onError} {...props} />
{/if}
+4 -3
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import {displayUrl} from "@welshman/lib"
import {preventDefault} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import {pushModal} from "@app/modal"
import {pushModal} from "@app/util/modal"
const {value} = $props()
@@ -16,12 +17,12 @@
{#if url.match(/\.(jpe?g|png|gif|webp)$/)}
<!-- Use a real link so people can copy the href -->
<a href={url} class="link-content whitespace-nowrap" onclick={preventDefault(expand)}>
<Icon icon="link-round" size={3} class="inline-block" />
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</a>
{:else}
<Link external href={url} class="link-content whitespace-nowrap">
<Icon icon="link-round" size={3} class="inline-block" />
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</Link>
{/if}
+4 -5
View File
@@ -1,11 +1,10 @@
<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"
import {pushModal} from "@app/util/modal"
type Props = {
value: ProfilePointer
@@ -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/core/state"
import {goToEvent} from "@app/util/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>
+3 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {clip} from "@app/toast"
import {clip} from "@app/util/toast"
const {value} = $props()
@@ -9,6 +10,6 @@
</script>
<Button onclick={copy} class="link-content">
<Icon icon="bolt" size={3} class="inline-block translate-y-px" />
<Icon icon={Bolt} size={3} class="inline-block translate-y-px" />
{value.slice(0, 16)}...
</Button>
@@ -0,0 +1,75 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
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/util/routes"
import {displayChannel} from "@app/core/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="truncate font-medium text-blue-400">
#{displayChannel(url, room)}
</span>
<span class="opacity-50"></span>
{/if}
<span class="text-nowrap">{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={AltArrowLeft} />
<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
@@ -4,8 +4,8 @@
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal} from "@app/modal"
import {BURROW_URL} from "@app/state"
import {pushModal} from "@app/util/modal"
import {BURROW_URL} from "@app/core/state"
const {email, confirm_token} = $props()
+24 -6
View File
@@ -3,35 +3,53 @@
import type {Instance} from "tippy.js"
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import EventMenu from "@app/components/EventMenu.svelte"
import {publishReaction} from "@app/commands"
import {ENABLE_ZAPS} from "@app/core/state"
import {publishReaction, canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
noun: string
event: TrustedEvent
hideZap?: boolean
customActions?: Snippet
}
const {url, noun, event, customActions}: Props = $props()
const {url, noun, event, hideZap, customActions}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const showPopover = () => popover?.show()
const hidePopover = () => popover?.hide()
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, content: emoji.unicode, relays: [url]})
const onEmoji = async (emoji: NativeEmoji) =>
publishReaction({
event,
content: emoji.unicode,
relays: [url],
protect: await shouldProtect,
})
let popover: Instance | undefined = $state()
</script>
<Button class="join rounded-full">
{#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
<Icon icon={Bolt} size={4} />
</ZapButton>
{/if}
<EmojiButton {onEmoji} class="btn join-item btn-neutral btn-xs">
<Icon icon="smile-circle" size={4} />
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
<Tippy
bind:popover
@@ -39,7 +57,7 @@
props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Button class="btn join-item btn-neutral btn-xs" onclick={showPopover}>
<Icon icon="menu-dots" size={4} />
<Icon icon={MenuDots} size={4} />
</Button>
</Tippy>
</Button>
+3 -2
View File
@@ -6,7 +6,8 @@
import {deriveEvents} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app"
import {notifications} from "@app/notifications"
import {notifications} from "@app/util/notifications"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props()
@@ -21,7 +22,7 @@
</script>
<div class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full">
<Icon icon="reply" />
<Icon icon={Reply} />
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
</div>
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
+5 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import Confirm from "@lib/components/Confirm.svelte"
import {publishDelete} from "@app/commands"
import {clearModals} from "@app/modal"
import {publishDelete, canEnforceNip70} from "@app/core/commands"
import {clearModals} from "@app/util/modal"
type Props = {
url: string
@@ -11,8 +11,10 @@
const {url, event}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const confirm = async () => {
await publishDelete({event, relays: [url]})
await publishDelete({event, relays: [url], protect: await shouldProtect})
clearModals()
}
+42 -6
View File
@@ -1,12 +1,18 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {Router} from "@welshman/router"
import {LOCALE, secondsToDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {displayRelayUrl} from "@welshman/util"
import FileText from "@assets/icons/file-text.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import {clip} from "@app/toast"
import {trackerStore} from "@app/core/state"
import {clip} from "@app/util/toast"
type Props = {
url?: string
@@ -17,11 +23,17 @@
const relays = url ? [url] : Router.get().Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays})
const seenOn = $trackerStore.getRelays(event.id)
const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2)
const copyLink = () => clip(nevent1)
const copyPubkey = () => clip(npub1)
const copyJson = () => clip(json)
const formatter = new Intl.DateTimeFormat(LOCALE, {
dateStyle: "long",
timeStyle: "long",
})
</script>
<div class="column gap-4">
@@ -33,16 +45,24 @@
<div>The full details of this event are shown below.</div>
{/snippet}
</ModalHeader>
<FieldInline>
{#snippet label()}
<p>Created At</p>
{/snippet}
{#snippet input()}
<p>{formatter.format(secondsToDate(event.created_at))}</p>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Event Link</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="file" />
<Icon icon={FileText} />
<input type="text" class="ellipsize min-w-0 grow" value={nevent1} />
<Button onclick={copyLink} class="flex items-center">
<Icon icon="copy" />
<Icon icon={Copy} />
</Button>
</label>
{/snippet}
@@ -53,19 +73,35 @@
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-circle" />
<Icon icon={UserCircle} />
<input type="text" class="ellipsize min-w-0 grow" value={npub1} />
<Button onclick={copyPubkey} class="flex items-center">
<Icon icon="copy" />
<Icon icon={Copy} />
</Button>
</label>
{/snippet}
</FieldInline>
{#if !url && seenOn.size > 0}
<FieldInline>
{#snippet label()}
<p>Seen On</p>
{/snippet}
{#snippet input()}
<div class="flex flex-wrap gap-2">
{#each seenOn as url, i (url)}
<span class="bg-alt badge flex gap-1">
{displayRelayUrl(url)}
</span>
{/each}
</div>
{/snippet}
</FieldInline>
{/if}
<div class="relative">
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
<p class="absolute right-2 top-2 flex flex-grow items-center justify-between">
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
<Icon icon="copy" /> Copy
<Icon icon={Copy} /> Copy
</Button>
</p>
</div>
+9 -5
View File
@@ -10,7 +10,11 @@
import EventReport from "@app/components/EventReport.svelte"
import EventShare from "@app/components/EventShare.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/modal"
import {pushModal} from "@app/util/modal"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
type Props = {
url: string
@@ -43,14 +47,14 @@
{#if isRoot}
<li>
<Button onclick={share}>
<Icon size={4} icon="share-circle" />
<Icon size={4} icon={ShareCircle} />
Share to Chat
</Button>
</li>
{/if}
<li>
<Button onclick={showInfo}>
<Icon size={4} icon="code-2" />
<Icon size={4} icon={Code2} />
{noun} Details
</Button>
</li>
@@ -58,14 +62,14 @@
{#if event.pubkey === $pubkey}
<li>
<Button onclick={showDelete} class="text-error">
<Icon size={4} icon="trash-bin-2" />
<Icon size={4} icon={TrashBin2} />
Delete {noun}
</Button>
</li>
{:else}
<li>
<Button class="text-error" onclick={report}>
<Icon size={4} icon="danger" />
<Icon size={4} icon={Danger} />
Report Content
</Button>
</li>
+12 -5
View File
@@ -3,17 +3,20 @@
import {writable} from "svelte/store"
import {isMobile, preventDefault} from "@lib/html"
import {fly} from "@lib/transition"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
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 {publishComment, canEnforceNip70} from "@app/core/commands"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
import {pushToast} from "@app/util/toast"
const {url, event, onClose, onSubmit} = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
@@ -23,7 +26,11 @@
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()
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (!content) {
return pushToast({
@@ -75,7 +82,7 @@
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
+6 -4
View File
@@ -3,11 +3,13 @@
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast"
import {publishReport} from "@app/commands"
import {pushToast} from "@app/util/toast"
import {publishReport} from "@app/core/commands"
const {url, event} = $props()
@@ -78,12 +80,12 @@
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Send Report</Spinner>
<Icon icon="alt-arrow-right" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+5 -3
View File
@@ -6,18 +6,20 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import {publishDelete} from "@app/commands"
import {publishDelete, canEnforceNip70} from "@app/core/commands"
const {url, event} = $props()
const shouldProtect = canEnforceNip70(url)
const reports = deriveEvents(repository, {
filters: [{kinds: [REPORT], "#e": [event.id]}],
})
const back = () => history.back()
const deleteReport = (report: TrustedEvent) => {
publishDelete({event: report, relays: [url]})
const deleteReport = async (report: TrustedEvent) => {
publishDelete({event: report, relays: [url], protect: await shouldProtect})
if ($reports.length === 0) {
history.back()
+7 -5
View File
@@ -2,14 +2,16 @@
import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util"
import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {channelsByUrl} from "@app/state"
import {makeRoomPath} from "@app/routes"
import {setKey} from "@app/implicit"
import {channelsByUrl} from "@app/core/state"
import {makeRoomPath} from "@app/util/routes"
import {setKey} from "@lib/implicit"
const {url, noun, event}: {url: string; noun: string; event: TrustedEvent} = $props()
@@ -50,12 +52,12 @@
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={!selection}>
Share {noun}
<Icon icon="alt-arrow-right" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+38
View File
@@ -0,0 +1,38 @@
<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, canEnforceNip70} from "@app/core/commands"
import {makeGoalPath} from "@app/util/routes"
interface Props {
url: any
event: any
showActivity?: boolean
}
const {url, event, showActivity = false}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const path = makeGoalPath(url, event.id)
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} hideZap noun="Goal" />
</div>
</div>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import {writable} from "svelte/store"
import {makeEvent, ZAP_GOAL} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor"
import {canEnforceNip70} from "@app/core/commands"
const {url} = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const back = () => history.back()
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading) return
if (!content) {
return pushToast({
theme: "error",
message: "Please provide a title for your funding goal.",
})
}
const ed = await editor
const summary = ed.getText({blockSeparator: "\n"}).trim()
if (!summary.trim()) {
return pushToast({
theme: "error",
message: "Please provide details about your funding goal.",
})
}
const tags = [
...ed.storage.nostr.getEditorTags(),
["summary", summary],
["amount", String(amount)],
["relays", url],
]
if (await shouldProtect) {
tags.push(PROTECTED)
}
publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content, tags}),
})
history.back()
}
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
let content = $state("")
let amount = $state(1000)
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
<ModalHeader>
{#snippet title()}
<div>Create a Funding Goal</div>
{/snippet}
{#snippet info()}
<div>Request contributions for your fundraiser.</div>
{/snippet}
</ModalHeader>
<div class="col-8 relative">
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={content}
class="grow"
type="text"
placeholder="What do funds go towards?" />
</label>
{/snippet}
</Field>
<div class="relative">
<Field>
{#snippet label()}
<p>Details*</p>
{/snippet}
{#snippet input()}
<div class="note-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
</Field>
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
<div class="flex flex-col gap-1">
<FieldInline>
{#snippet label()}
Goal Amount (sats)*
{/snippet}
{#snippet input()}
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-28" />
<p class="opacity-50">sats</p>
</label>
</div>
{/snippet}
</FieldInline>
<input
class="range range-primary -mt-2"
type="range"
min="1000"
max="100000"
step="1000"
bind:value={amount} />
</div>
</div>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary">Create Goal</Button>
</ModalFooter>
</form>
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import GoalActions from "@app/components/GoalActions.svelte"
import GoalSummary from "@app/components/GoalSummary.svelte"
import {makeGoalPath} from "@app/util/routes"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const summary = getTagValue("summary", event.tags)
</script>
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
<p class="text-2xl">{event.content}</p>
<Content
event={{content: summary, tags: event.tags}}
{url}
expandMode="inline"
minLength={50}
maxLength={300} />
<GoalSummary {url} {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span>
<GoalActions showActivity {url} {event} />
</div>
</Link>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import {now, DAY, uniq, sum} from "@welshman/lib"
import type {Zap, TrustedEvent} from "@welshman/util"
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
import {deriveEventsMapped} from "@welshman/store"
import {repository, getValidZap} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const zaps = deriveEventsMapped<Zap>(repository, {
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
itemToEvent: item => item.response,
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
})
const goalAmount = parseInt(getTagValue("amount", event.tags) || "0")
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
const contributorsCount = $derived(uniq($zaps.map(zap => zap.request.pubkey)).length)
const daysOld = Math.ceil((now() - event.created_at) / DAY)
</script>
<div class="card2 bg-alt flex flex-col gap-8">
<div class="flex gap-8">
<div>
<p class="text-xl text-primary">{zapAmount} sats</p>
<p class="text-sm opacity-75">funded of {goalAmount} sats</p>
</div>
<div>
<p class="text-xl">{contributorsCount}</p>
<p class="text-sm opacity-75">{contributorsCount === 1 ? "contributor" : "contributors"}</p>
</div>
<div>
<p class="text-xl">{daysOld}</p>
<p class="text-sm opacity-75">{daysOld === 1 ? "day" : "days"} old</p>
</div>
</div>
<progress class="progress progress-primary" value={zapAmount} max={goalAmount}></progress>
<ZapButton {url} {event} class="btn btn-primary lg:m-auto lg:px-20">
<Icon icon={Bolt} />
Contribute to this goal
</ZapButton>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import {PLATFORM_NAME} from "@app/state"
import {PLATFORM_NAME} from "@app/core/state"
</script>
<div class="column gap-4">
+1 -1
View File
@@ -2,7 +2,7 @@
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"
import {PLATFORM_NAME} from "@app/core/state"
</script>
<div class="column gap-4">
+8 -6
View File
@@ -1,13 +1,15 @@
<script lang="ts">
import {session} from "@welshman/app"
import Link from "@lib/components/Link.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileEject from "@app/components/ProfileEject.svelte"
import {PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
import {PLATFORM_NAME} from "@app/core/state"
import {pushModal} from "@app/util/modal"
const back = () => history.back()
@@ -27,8 +29,8 @@
</p>
<p>
On <Link external href="https://nostr.com/">Nostr</Link>, <strong>you</strong> control your own
identity and social data, through the magic of crytography. The basic idea is that you have a
<strong>public key</strong>, which acts as your user id, and a
identity and social data, through the magic of cryptography. The basic idea is that you have a
<strong>public key</strong>, which acts as your user ID, and a
<strong>private key</strong> which allows you to prove your identity.
</p>
{#if $session?.email}
@@ -39,11 +41,11 @@
<p>If you'd like to switch to self-custody, please click below to get started.</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={startEject}>
<Icon icon="check-circle" />
<Icon icon={CheckCircle} />
I want to hold my own keys
</Button>
</ModalFooter>
+1 -1
View File
@@ -2,7 +2,7 @@
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"
import {PLATFORM_NAME} from "@app/core/state"
</script>
<div class="column gap-4">
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
const back = () => history.back()
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>What are digital signatures?</div>
{/snippet}
</ModalHeader>
<p>
Most online services ask their users to trust them that they're being honest, and they usually
are. However, traditional social media platforms have the ability to <strong
>create forged content</strong> that can appear to be genuinely authored, but which are actually
counterfeit.
</p>
<p>
On <Link external href="https://nostr.com/">Nostr</Link>, all your content is authenticated
using <strong>digital signatures</strong>, which cryptographically tie a particular person to a
given post or message.
</p>
<p>
The result is that you don't normally have to trust service providers not to tamper with the
information flowing through the network — instead, your client software can prove that a given
piece of data is authentic.
</p>
<Button class="btn btn-primary" onclick={back}>Got it</Button>
</div>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import {deriveZapperForPubkey} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
const {pubkey} = $props()
const zapper = deriveZapperForPubkey(pubkey)
const back = () => history.back()
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>Unable to Zap</div>
{/snippet}
</ModalHeader>
<p>
Zapping <ProfileLink {pubkey} class="!text-primary" /> isn't possible right now because
{#if $zapper}
their zap receiver isn't correctly set up.
{:else}
they don't currently have a zap receiver set up.
{/if}
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
</div>
@@ -1,7 +1,11 @@
<script lang="ts">
import {randomId} from "@welshman/lib"
import {preventDefault, stopPropagation} from "@lib/html"
import {randomId, call} from "@welshman/lib"
import {preventDefault, stopPropagation, compressFile} from "@lib/html"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import {uploadFile} from "@app/core/commands"
interface Props {
file?: File | undefined
@@ -24,14 +28,14 @@
active = false
}
const onDrop = (e: any) => {
const onDrop = async (e: any) => {
active = false
file = e.dataTransfer.files[0]
file = await compressFile(e.dataTransfer.files[0])
}
const onChange = (e: any) => {
file = e.target.files[0]
const onChange = async (e: any) => {
file = await compressFile(e.target.files[0])
}
const onClear = () => {
@@ -44,20 +48,29 @@
let initialUrl = $state(url)
$effect(() => {
if (file) {
const reader = new FileReader()
call(async () => {
if (file) {
const {result} = await uploadFile(file)
reader.addEventListener(
"load",
() => {
url = reader.result as string
},
false,
)
reader.readAsDataURL(file)
} else {
url = initialUrl
}
if (result?.url) {
url = result.url
} else {
const reader = new FileReader()
reader.addEventListener(
"load",
() => {
url = reader.result as string
},
false,
)
reader.readAsDataURL(file)
}
} else {
url = initialUrl
}
})
})
</script>
@@ -84,14 +97,14 @@
tabindex="-1"
onmousedown={stopPropagation(onClear)}
ontouchstart={stopPropagation(onClear)}>
<Icon icon="close-circle" class="scale-150 !bg-base-300" />
<Icon icon={CloseCircle} class="scale-150 !bg-base-300" />
</span>
{:else}
<Icon icon="add-circle" class="scale-150 !bg-base-300" />
<Icon icon={AddCircle} class="scale-150 !bg-base-300" />
{/if}
</div>
{#if !url}
<Icon icon="gallery-send" size={7} />
<Icon icon={GallerySend} size={7} />
{/if}
</label>
</form>
+8 -6
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import Login from "@assets/icons/login-3.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
@@ -6,8 +8,8 @@
import CardButton from "@lib/components/CardButton.svelte"
import LogIn from "@app/components/LogIn.svelte"
import SignUp from "@app/components/SignUp.svelte"
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/core/state"
import {pushModal} from "@app/util/modal"
const logIn = () => pushModal(LogIn)
@@ -21,9 +23,9 @@
<p class="text-center">The chat app built for self-hosted communities.</p>
</div>
<Button onclick={logIn}>
<CardButton class="!btn-primary">
<CardButton class="btn-primary">
{#snippet icon()}
<div><Icon icon="login-2" size={7} /></div>
<div><Icon icon={Login} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Log in</div>
@@ -33,10 +35,10 @@
{/snippet}
</CardButton>
</Button>
<Button onclick={signUp}>
<Button onclick={signUp} class="dark:btn-neutral">
<CardButton>
{#snippet icon()}
<div><Icon icon="add-circle" size={7} /></div>
<div><Icon icon={AddCircle} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Create an account</div>
+14 -10
View File
@@ -3,6 +3,10 @@
import {Capacitor} from "@capacitor/core"
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
import Widget from "@assets/icons/widget-2.svg?dataurl"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
import Compass from "@assets/icons/compass-big.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
@@ -10,11 +14,11 @@
import InfoNostr from "@app/components/InfoNostr.svelte"
import LogInBunker from "@app/components/LogInBunker.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal, clearModals} from "@app/modal"
import {PLATFORM_NAME, BURROW_URL} from "@app/state"
import {pushToast} from "@app/toast"
import {loadUserData} from "@app/requests"
import {setChecked} from "@app/notifications"
import {pushModal, clearModals} from "@app/util/modal"
import {PLATFORM_NAME, BURROW_URL} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {loadUserData} from "@app/core/requests"
import {setChecked} from "@app/util/notifications"
let signers: any[] = $state([])
let loading: string | undefined = $state()
@@ -96,7 +100,7 @@
{#if loading === "nip07"}
<span class="loading loading-spinner mr-3"></span>
{:else}
<Icon icon="widget" />
<Icon icon={Widget} />
{/if}
Log in with Extension
</Button>
@@ -116,7 +120,7 @@
{#if loading === "password"}
<span class="loading loading-spinner mr-3"></span>
{:else}
<Icon icon="key" />
<Icon icon={Key} />
{/if}
Log in with Password
</Button>
@@ -125,7 +129,7 @@
onclick={loginWithBunker}
{disabled}
class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="cpu" />
<Icon icon={Cpu} />
Log in with Remote Signer
</Button>
{#if BURROW_URL && hasSigner}
@@ -133,7 +137,7 @@
{#if loading === "password"}
<span class="loading loading-spinner mr-3"></span>
{:else}
<Icon icon="key" />
<Icon icon={Key} />
{/if}
Log in with Password
</Button>
@@ -144,7 +148,7 @@
{disabled}
href="https://nostrapps.com#signers"
class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
<Icon icon="compass" />
<Icon icon={Compass} />
Browse Signer Apps
</Link>
{/if}
+77 -35
View File
@@ -1,24 +1,34 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {loginWithNip01, loginWithNip46} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import BunkerConnect, {BunkerConnectController} from "@app/components/BunkerConnect.svelte"
import BunkerConnect from "@app/components/BunkerConnect.svelte"
import BunkerUrl from "@app/components/BunkerUrl.svelte"
import {loadUserData} from "@app/requests"
import {clearModals} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
import {SIGNER_RELAYS, NIP46_PERMS} from "@app/state"
import {Nip46Controller} from "@app/util/nip46"
import {loadUserData} from "@app/core/requests"
import {clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
import {SIGNER_RELAYS, NIP46_PERMS} from "@app/core/state"
const back = () => history.back()
const back = () => {
if (mode === "connect") {
selectBunker()
} else {
history.back()
}
}
const controller = new BunkerConnectController({
const controller = new Nip46Controller({
onNostrConnect: async (response: Nip46ResponseWithResult) => {
const pubkey = await controller.broker.getPublicKey()
@@ -30,21 +40,23 @@
},
})
const {loading, bunker} = controller
const onSubmit = async () => {
if (controller.loading) return
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl(controller.bunker)
if (!signerPubkey || relays.length === 0) {
return pushToast({
theme: "error",
message: "Sorry, it looks like that's an invalid bunker link.",
})
}
controller.loading = true
if ($loading) return
try {
const {signerPubkey, connectSecret, relays} = Nip46Broker.parseBunkerUrl($bunker)
if (!signerPubkey || relays.length === 0) {
return pushToast({
theme: "error",
message: "Sorry, it looks like that's an invalid bunker link.",
})
}
controller.loading.set(true)
const {clientSecret} = controller
const broker = new Nip46Broker({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS)
@@ -64,43 +76,73 @@
message: "Something went wrong, please try again!",
})
}
} catch (e) {
console.error(e)
return pushToast({
theme: "error",
message: "Something went wrong, please try again!",
})
} finally {
controller.loading = false
controller.loading.set(false)
}
clearModals()
}
const selectConnect = () => {
mode = "connect"
}
const selectBunker = () => {
mode = "bunker"
}
let mode: string = $state("bunker")
$effect(() => {
// For testing and for play store reviewers
if (controller.bunker === "reviewkey") {
if ($bunker === "reviewkey") {
loginWithNip01(makeSecret())
}
})
onMount(() => {
controller.start()
})
onDestroy(() => {
controller.stop()
})
</script>
<form class="column gap-4" onsubmit={preventDefault(onSubmit)}>
<ModalHeader>
{#snippet title()}
<div>Log In</div>
<div>Log In with a Signer</div>
{/snippet}
{#snippet info()}
<div>Connect your signer by scanning the QR code below or pasting a bunker link.</div>
<div>Using a remote signer app helps you keep your keys safe.</div>
{/snippet}
</ModalHeader>
<BunkerConnect {controller} />
<BunkerUrl loading={controller.loading} bind:bunker={controller.bunker} />
<div class:hidden={mode !== "bunker"}></div>
{#if mode === "connect"}
<BunkerConnect {controller} />
{:else}
<BunkerUrl {controller} />
<Button class="btn {$bunker ? 'btn-neutral' : 'btn-primary'}" onclick={selectConnect}
>Log in with a QR code instead</Button>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={controller.loading}>
<Icon icon="alt-arrow-left" />
<Button class="btn btn-link" onclick={back} disabled={$loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button
type="submit"
class="btn btn-primary"
disabled={controller.loading || !controller.bunker}>
<Spinner loading={controller.loading}>Next</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
{#if mode === "bunker"}
<Button type="submit" class="btn btn-primary" disabled={$loading || !$bunker}>
<Spinner loading={$loading}>Next</Spinner>
<Icon icon={AltArrowRight} />
</Button>
{/if}
</ModalFooter>
</form>
+19 -9
View File
@@ -8,15 +8,25 @@
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
import {loadUserData} from "@app/requests"
import {clearModals, pushModal} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
import {NIP46_PERMS, BURROW_URL, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO} from "@app/state"
import {loadUserData} from "@app/core/requests"
import {clearModals, pushModal} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
import {
NIP46_PERMS,
BURROW_URL,
PLATFORM_URL,
PLATFORM_NAME,
PLATFORM_LOGO,
} from "@app/core/state"
interface Props {
email?: string
@@ -115,7 +125,7 @@
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-rounded" />
<Icon icon={UserRounded} />
<input bind:value={email} />
</label>
{/snippet}
@@ -126,7 +136,7 @@
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="key" />
<Icon icon={Key} />
<input bind:value={password} type="password" />
</label>
{/snippet}
@@ -138,12 +148,12 @@
</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !email || !password}>
<Spinner {loading}>Next</Spinner>
<Icon icon="alt-arrow-right" />
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</form>
+3 -2
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {logout} from "@app/commands"
import {logout} from "@app/core/commands"
const back = () => history.back()
@@ -32,7 +33,7 @@
<p class="text-center">Your local database will be cleared.</p>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon="alt-arrow-left" />
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
+46 -13
View File
@@ -1,20 +1,27 @@
<script lang="ts">
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import Settings from "@assets/icons/settings-minimalistic.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import Wallet from "@assets/icons/wallet.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import LogOut from "@app/components/LogOut.svelte"
import {PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
import {PLATFORM_NAME} from "@app/core/state"
import {pushModal} from "@app/util/modal"
const logout = () => pushModal(LogOut)
</script>
<div class="column menu gap-2">
<Link replaceState href="/settings/profile">
<CardButton>
<CardButton class="dark:btn-neutral">
{#snippet icon()}
<div><Icon icon="user-rounded" size={7} /></div>
<div><Icon icon={UserRounded} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Profile</div>
@@ -24,10 +31,36 @@
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/relays">
<CardButton>
<Link replaceState href="/settings/alerts">
<CardButton class="dark:btn-neutral">
{#snippet icon()}
<div><Icon icon="server" size={7} /></div>
<div><Icon icon={Bell} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Alerts</div>
{/snippet}
{#snippet info()}
<div>Set up email digests and push notifications</div>
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/wallet">
<CardButton class="dark:btn-neutral">
{#snippet icon()}
<div><Icon icon={Wallet} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Wallet</div>
{/snippet}
{#snippet info()}
<div>Connect a bitcoin wallet for sending social tips</div>
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/relays">
<CardButton class="dark:btn-neutral">
{#snippet icon()}
<div><Icon icon={Server} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Relays</div>
@@ -37,10 +70,10 @@
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings">
<CardButton>
<Link replaceState href="/settings/content">
<CardButton class="dark:btn-neutral">
{#snippet icon()}
<div><Icon icon="settings" size={7} /></div>
<div><Icon icon={Settings} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Settings</div>
@@ -51,9 +84,9 @@
</CardButton>
</Link>
<Link replaceState href="/settings/about">
<CardButton>
<CardButton class="dark:btn-neutral">
{#snippet icon()}
<div><Icon icon="code-2" size={7} /></div>
<div><Icon icon={Code2} size={7} /></div>
{/snippet}
{#snippet title()}
<div>About</div>
@@ -64,6 +97,6 @@
</CardButton>
</Link>
<Button onclick={logout} class="btn btn-neutral">
<Icon icon="exit" /> Log Out
<Icon icon={Exit} /> Log Out
</Button>
</div>
+95 -39
View File
@@ -1,7 +1,20 @@
<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 AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
import Login from "@assets/icons/login-3.svg?dataurl"
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
@@ -12,25 +25,34 @@
import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import ProfileList from "@app/components/ProfileList.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import Alerts from "@app/components/Alerts.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
import {
ENABLE_ZAPS,
userRoomsByUrl,
hasMembershipUrl,
memberships,
deriveUserRooms,
deriveOtherRooms,
} from "@app/state"
import {notifications} from "@app/notifications"
import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes"
hasNip29,
alerts,
} from "@app/core/state"
import {notifications} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {makeSpacePath} from "@app/util/routes"
const {url} = $props()
const relay = deriveRelay(url)
const chatPath = makeSpacePath(url, "chat")
const goalsPath = makeSpacePath(url, "goals")
const threadsPath = makeSpacePath(url, "threads")
const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url)))
const openMenu = () => {
showMenu = true
@@ -55,6 +77,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,39 +97,41 @@
})
</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>
<Icon icon="alt-arrow-down" />
<strong class="ellipsize flex items-center gap-3">
{displayRelayUrl(url)}
</strong>
<Icon icon={AltArrowDown} />
</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" />
<Icon icon={UserRounded} />
View Members ({members.length})
</Button>
</li>
<li>
<Button onclick={createInvite}>
<Icon icon="link-round" />
<Icon icon={LinkRound} />
Create Invite
</Button>
</li>
<li>
{#if $userRoomsByUrl.has(url)}
<Button onclick={leaveSpace} class="text-error">
<Icon icon="exit" />
<Icon icon={Exit} />
Leave Space
</Button>
{:else}
<Button onclick={joinSpace} class="bg-primary text-primary-content">
<Icon icon="login-2" />
<Icon icon={Login} />
Join Space
</Button>
{/if}
@@ -109,44 +140,69 @@
</Popover>
{/if}
</div>
<div class="flex min-h-0 flex-col gap-1 overflow-auto">
<div class="flex max-h-[calc(100vh-150px)] min-h-0 flex-col gap-1 overflow-auto">
<SecondaryNavItem {replaceState} href={makeSpacePath(url)}>
<Icon icon="home-smile" /> Home
<Icon icon={HomeSmile} /> Home
</SecondaryNavItem>
{#if ENABLE_ZAPS}
<SecondaryNavItem
{replaceState}
href={goalsPath}
notification={$notifications.has(goalsPath)}>
<Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem>
{/if}
<SecondaryNavItem
{replaceState}
href={threadsPath}
notification={$notifications.has(threadsPath)}>
<Icon icon="notes-minimalistic" /> Threads
<Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem>
<SecondaryNavItem
{replaceState}
href={calendarPath}
notification={$notifications.has(calendarPath)}>
<Icon icon="calendar-minimalistic" /> Calendar
<Icon icon={CalendarMinimalistic} /> 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={AddCircle} />
Create room
</SecondaryNavItem>
{:else}
<SecondaryNavItem
{replaceState}
href={chatPath}
notification={$notifications.has(chatPath)}>
<Icon icon={ChatRound} /> Chat
</SecondaryNavItem>
{/if}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {replaceState} {url} {room} />
{/each}
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
</div>
</SecondaryNavSection>
<div class="p-4">
<button class="btn btn-neutral btn-sm w-full" onclick={manageAlerts}>
<Icon icon={Bell} />
Manage Alerts
</button>
</div>
</div>
+5 -4
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import MenuSpace from "@app/components/MenuSpace.svelte"
import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes"
import {pushDrawer} from "@app/modal"
import {notifications} from "@app/util/notifications"
import {makeSpacePath} from "@app/util/routes"
import {pushDrawer} from "@app/util/modal"
const {url} = $props()
@@ -14,7 +15,7 @@
</script>
<Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden">
<Icon icon="menu-dots" />
<Icon icon={MenuDots} />
{#if $notifications.has(path)}
<div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-primary"></div>
{/if}

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