Compare commits

..

98 Commits

Author SHA1 Message Date
mplorentz 7f0770e901 Change screen sharing icon 2026-03-26 11:44:01 -04:00
mplorentz c01b0287f5 Improve pinned video layout 2026-03-26 11:42:08 -04:00
mplorentz 112f5517a7 Add a button to spotlight a video feed 2026-03-26 11:36:52 -04:00
mplorentz eb8117cc95 Add basic screen sharing 2026-03-26 10:59:50 -04:00
mplorentz edceb92acc add video to livekit calls 2026-03-26 10:49:14 -04:00
mplorentz ce30820108 feature/23-voice-room/poc (#93)
Add voice rooms

Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-03-16 20:38:05 +00:00
Jon Staab 147c756cc1 Update readme to point to .env.local 2026-03-16 13:36:06 -07:00
Jon Staab c7fb404404 Add -s flag to readme 2026-03-13 13:23:31 -07:00
Jon Staab 2546146ca8 Tweak back button, hide on desktop 2026-03-13 09:11:08 -07:00
Jon Staab ffa776fd42 Make mobile check on relay page reactive 2026-03-12 17:31:28 -07:00
Jon Staab a59ffb8758 Fix some icons, add privacy nav item, add close button to modal dialog, make settings menu nicer 2026-03-12 17:21:53 -07:00
Jon Staab 9e74c94871 Show navigation on space landing on mobile 2026-03-12 16:44:22 -07:00
Jon Staab 77294e7f1c Factor primary nav spaces into its own component, fix non-nip29 default page 2026-03-12 15:08:31 -07:00
Jon Staab 57f2f4a619 Use new space icon 2026-03-12 14:45:19 -07:00
Jon Staab 1df2284ea3 Return more details about notification registration failure 2026-03-12 14:07:52 -07:00
Jon Staab 189af077e7 Fix file uploads on android 2026-03-12 13:51:05 -07:00
Jon Staab 10e4d83bce Hide add member button for non-members 2026-03-12 12:35:08 -07:00
Jon Staab 5d6661f964 Speed up boot, prune stores 2026-03-12 11:33:04 -07:00
Jon Staab e6e11bb8f2 Massive indexeddb optimization 2026-03-12 11:01:15 -07:00
Jon Staab 0e65e834da Bump welshman 2026-03-12 08:20:15 -07:00
Jon Staab 19f532c12e Allow nested modals 2026-03-11 16:29:24 -07:00
Jon Staab bfc997ba37 Tweak icon picker modal 2026-03-11 16:15:37 -07:00
Jon Staab 99966a976e Overhaul relay settings page 2026-03-11 15:58:05 -07:00
Jon Staab cd54bc2880 Add up/edit to chats 2026-03-10 15:46:59 -07:00
Jon Staab ffdd689331 Add another pomade signer 2026-03-10 11:09:59 -07:00
Jon Staab af41d81981 Add pomade signers 2026-03-10 10:15:53 -07:00
Jon Staab 10d28ed364 Update zapstore.yaml 2026-03-09 21:12:51 -07:00
Jon Staab b02f4bd53a Update version 2026-03-09 21:12:51 -07:00
Jon Staab 7ce8e3dbe6 Fix classified images 2026-03-09 21:12:51 -07:00
Jon Staab 2446d5cdb8 Add StringMultiInput for OTPs 2026-03-09 21:12:51 -07:00
Jon Staab d015018a16 Fix up edit 2026-03-09 21:12:51 -07:00
Jon Staab 6231c75e34 Handle prompt-with-rationale 2026-03-09 21:12:51 -07:00
Jon Staab 2f3bc6cc6f Handle profile update errors 2026-03-09 21:12:50 -07:00
Jon Staab 16c6015919 Move from .env to .env.local 2026-03-09 21:12:50 -07:00
Jon Staab e6b291cc68 Tweak some error messages 2026-03-09 21:12:37 -07:00
Jon Staab ae523c1ca6 Refactor pomade, add password reset flow 2026-03-09 21:12:37 -07:00
Jon Staab 7c86c1477f Add LogInSelect step for disambiguating pomade sessions 2026-03-09 21:12:37 -07:00
Jon Staab 71f162f20d Update pomade implementation 2026-03-09 21:12:37 -07:00
mplorentz eeacaca725 Fix a docker rebuild issue (#88)
The Docker build wasn't making use of docker's cache because the .git directory was being copied into the build context. This means that even if the app did not change, if anything in git changed then docker would rebuild the entire app.

This excludes the .git folder from the docker build, instead relying on the user to pass in the build hash at build time. Which is annoying but I don't think there's a better way around it.

This was annoying me because I am deploying a self-hosted version of flotilla from a git branch via ansible and it was rebuilding flotilla every time.

Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: coracle/flotilla#88
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-03-09 21:12:35 -07:00
Jon Staab af52ee25eb Bring back some notification badges 2026-03-09 21:12:10 -07:00
Jon Staab eef32ca11e Make sync logic more robust 2026-03-09 21:12:10 -07:00
Jon Staab 1ae821bff8 Bump welshman 2026-03-09 21:12:10 -07:00
Jon Staab 65483a6ef0 Support unban/unallow 2026-03-09 21:12:10 -07:00
Jon Staab 606a9343d9 Fix WalletPay 2026-03-09 21:12:10 -07:00
Jon Staab 7dfa6538be Bump welshman 2026-03-09 21:12:10 -07:00
Jon Staab 476d010ebe Blobify images so users can open them easier 2026-03-09 21:12:10 -07:00
Jon Staab 96d2efebc8 Add manual invoice payment 2026-03-09 21:12:10 -07:00
Jon Staab f60f5af424 Update link_deps 2026-03-09 21:12:10 -07:00
Jon Staab 3da0334083 Fix enter selecting an option when there is no term. Closes #84 2026-03-09 21:12:10 -07:00
triesap c970038943 Bootstrap Tauri desktop shell for evaluation (#66)
Adds a minimal Tauri desktop bootstrap. Run with: pnpm run tauri:dev

Reviewed-on: coracle/flotilla#66
Co-authored-by: triesap <tyson@radroots.org>
Co-committed-by: triesap <tyson@radroots.org>
2026-03-09 21:12:10 -07:00
triesap 4000477bdb Classifieds tags (#18) (#65)
Closes #18

Reviewed-on: coracle/flotilla#65
Co-authored-by: triesap <tyson@radroots.org>
Co-committed-by: triesap <tyson@radroots.org>
2026-03-09 21:12:10 -07:00
triesap ba11d53922 Show wallet status when wallet is unreachable 2026-03-09 21:12:10 -07:00
Jon Staab beef606024 Remove capacitor plugin from overrides 2026-03-09 21:12:10 -07:00
Jon Staab 2adf64da55 Bump welshman and pomade 2026-03-09 21:12:10 -07:00
Jon Staab fd3fb8573c Update nostr signer capacitor plugin 2026-03-09 21:12:09 -07:00
Jon Staab e0d94d9794 Fix safe area inset for modal footer 2026-03-09 21:12:09 -07:00
Jon Staab 7d049150a0 Bump nip55 signer 2026-03-09 21:12:09 -07:00
Jon Staab 527ef59adc Update pomade version 2026-03-09 21:12:09 -07:00
Jon Staab b39775daef Fix svgs with 302 redirects on safari 2026-03-09 21:12:09 -07:00
Jon Staab 4bdb21560a Fix mask-repeat property 2026-03-09 21:12:09 -07:00
Jon Staab 797a9c32aa Refine space join dialogs and discover page 2026-03-09 21:12:09 -07:00
mplorentz bc864b29f8 Reopen the last DM that was open when navigating back to chat (#81)
#60

Co-authored-by: mplorentz <mplorentz@users.noreply.github.com>
Reviewed-on: coracle/flotilla#81
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-03-09 21:12:09 -07:00
Jon Staab 482121db5c Add content-visibility class 2026-03-09 21:12:09 -07:00
Jon Staab 0fa26c8d0a Get rid of ChatEnable, automatically enable unwrapping when the user first visits the dms page. Closes #72 2026-03-09 21:12:09 -07:00
Jon Staab f5c768d6a7 Enable auth for relays we're publishing to 2026-03-09 21:12:09 -07:00
triesap c43544734a Drag and drop space icons (#17) (#78)
Closes #17

Co-authored-by: Jon Staab <shtaab@gmail.com>
Reviewed-on: coracle/flotilla#78
Co-authored-by: triesap <tyson@radroots.org>
Co-committed-by: triesap <tyson@radroots.org>
2026-03-09 21:12:09 -07:00
Jon Staab 86d99916f7 re-order some menu items 2026-03-09 21:12:09 -07:00
Jon Staab 135dbc8789 Fix iOS zoom bug 2026-03-09 21:12:09 -07:00
Jon Staab fc14de9b0f Reset to old home page 2026-03-09 21:12:09 -07:00
Jon Staab c77197d959 Continue working on feed page 2026-03-09 21:12:09 -07:00
Jon Staab 56dddbdd86 Add better muting, add EventReducer 2026-03-09 21:12:09 -07:00
mplorentz cbafcf6939 Add back button to settings menu 2026-03-09 21:12:09 -07:00
Jon Staab 4b156ee699 Work on feed page 2026-03-09 21:12:09 -07:00
Jon Staab a4e883b09a Prevent error loop on images 2026-03-09 21:12:09 -07:00
triesap b114a724e2 Page titles (#16) (#62)
Closes #16

Reviewed-on: coracle/flotilla#62
Co-authored-by: triesap <tyson@radroots.org>
Co-committed-by: triesap <tyson@radroots.org>
2026-03-09 21:12:09 -07:00
Jon Staab 621c0d839c tweak how at works 2026-03-09 21:12:09 -07:00
Jon Staab 021c1fc7c4 Fix scroll to event behavior 2026-03-09 21:12:08 -07:00
Jon Staab bda91080ab Pin scroll position to at'd event until user scrolls 2026-03-09 21:12:08 -07:00
Jon Staab a9828be25c Simplify goToEvent 2026-03-09 21:12:08 -07:00
Jon Staab dde9dbfbfe Add forward scrolling to makeMakeFeed 2026-03-09 21:12:08 -07:00
Jon Staab ca7d126a3c Make createScroller honor reverse param 2026-03-09 21:12:08 -07:00
Jon Staab 7f6450375b Fix duplicate ids in chat 2026-03-09 21:12:08 -07:00
Jon Staab c9954db3fe Use compressorjs-next 2026-03-09 21:12:08 -07:00
Jon Staab 3d268f1f9d Refactor SpaceSearch into its own component 2026-03-09 21:12:08 -07:00
Ben 66a7a2a7af Space search 2026-03-09 21:12:08 -07:00
Jon Staab 7823e1d803 Fix editing messages with html tags 2026-03-09 21:12:08 -07:00
Jon Staab d5e91ce874 Fix DM media detection 2026-03-09 21:12:08 -07:00
Jon Staab 6f32c1932f Make hover target for menu button more reasonable 2026-03-09 21:12:08 -07:00
Jon Staab cb06c4e954 Watch tracker in feed utils 2026-03-09 21:12:08 -07:00
Jon Staab 9188c0a8bc Revert makeFeed changes 2026-03-09 21:12:08 -07:00
Jon Staab 30653fe344 Clean up report item design, bad/restore user actions, space description input, add feed to home page 2026-03-09 21:12:07 -07:00
Jon Staab 5bb55c453f Tweak wallet page 2026-03-09 21:12:07 -07:00
Jon Staab 3024e08ca5 Fix makeFeed (maybe) 2026-03-09 21:12:07 -07:00
Jon Staab aaf1f25167 Tweak room detail 2026-03-09 21:12:07 -07:00
Jon Staab aabbb758a4 Fix scroll to bottom button safe insets 2026-03-09 21:12:07 -07:00
Jon Staab d824f928b5 Disable wallet on ios 2026-03-09 21:12:07 -07:00
Jon Staab 445ed27eb8 Add rewrite to dockerfile 2026-02-17 12:01:12 -08:00
Jon Staab 21f3970ca8 Use explicit image name in workflow file 2026-02-17 11:48:52 -08:00
97 changed files with 3055 additions and 1284 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
VITE_POMADE_SIGNERS=
VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
VITE_PLATFORM_URL=https://app.flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
@@ -15,7 +15,7 @@ VITE_PUSH_BRIDGE=wss://npb.coracle.social/
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,nostr-01.uid.ovh,relay.keychat.io,relay.0xchat.com
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_GLITCHTIP_API_KEY=
+1 -1
View File
@@ -6,7 +6,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
IMAGE_NAME: coracle-social/flotilla
jobs:
build-and-push-image:
+1
View File
@@ -25,6 +25,7 @@ android/app/src/main/assets/public/
# Web/JavaScript
node_modules/
.pnpm-store/
build/
.svelte-kit/
+5
View File
@@ -1,5 +1,10 @@
# Changelog
# Current
* Enable email/password login
* Add up/edit to direct messages
# 1.6.5
* Attempt to fix permission grant for notifications
+3 -2
View File
@@ -4,6 +4,8 @@
FROM node:20-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends curl
RUN npm install -g pnpm@latest
WORKDIR /app
@@ -20,7 +22,6 @@ ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
ENV NODE_OPTIONS=--max_old_space_size=16384
RUN pnpm run build
# Stage 2: Runtime
FROM node:20-alpine
WORKDIR /app
@@ -28,4 +29,4 @@ WORKDIR /app
# Copy only the built output - no source, no .env, no dev deps
COPY --from=builder /app/build ./build
CMD ["npx", "serve", "build"]
CMD ["npx", "serve", "-s", "build"]
+2 -2
View File
@@ -6,7 +6,7 @@ If you would like to be interoperable with Flotilla, please check out this guide
## Environment
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples):
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
- `VITE_PLATFORM_URL` - The url where the app will be hosted
@@ -29,7 +29,7 @@ To run your own Flotilla, it's as simple as:
```sh
pnpm install
pnpm run build
npx serve build
npx serve -s build
```
Or, if you prefer to use a container:
+2
View File
@@ -42,4 +42,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
</manifest>
+3
View File
@@ -4,6 +4,9 @@ const config: CapacitorConfig = {
appId: "social.flotilla",
appName: "Flotilla",
webDir: "build",
ios: {
scheme: "Flotilla Chat",
},
android: {
adjustMarginsForEdgeToEdge: true,
},
+8 -6
View File
@@ -20,8 +20,16 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>Flotilla uses the microphone for voice chat in rooms.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -47,11 +55,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>
+11 -10
View File
@@ -65,16 +65,16 @@
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.8.8",
"@welshman/content": "^0.8.8",
"@welshman/editor": "^0.8.8",
"@welshman/feeds": "^0.8.8",
"@welshman/lib": "^0.8.8",
"@welshman/net": "^0.8.8",
"@welshman/router": "^0.8.8",
"@welshman/signer": "^0.8.8",
"@welshman/store": "^0.8.8",
"@welshman/util": "^0.8.8",
"@welshman/app": "^0.8.9",
"@welshman/content": "^0.8.9",
"@welshman/editor": "^0.8.9",
"@welshman/feeds": "^0.8.9",
"@welshman/lib": "^0.8.9",
"@welshman/net": "^0.8.9",
"@welshman/router": "^0.8.9",
"@welshman/signer": "^0.8.9",
"@welshman/store": "^0.8.9",
"@welshman/util": "^0.8.9",
"compressorjs-next": "^1.1.2",
"daisyui": "^4.12.24",
"date-picker-svelte": "^2.17.0",
@@ -83,6 +83,7 @@
"fuse.js": "^7.1.0",
"husky": "^9.1.7",
"idb": "^8.0.3",
"livekit-client": "^2.17.2",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.6.14",
+199 -111
View File
@@ -58,7 +58,7 @@ importers:
version: 1.9.7
'@pomade/core':
specifier: ^0.2.1
version: 0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
version: 0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
'@poppanator/sveltekit-svg':
specifier: ^4.2.1
version: 4.2.1(rollup@2.80.0)(svelte@5.48.0)(svgo@3.3.2)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0))
@@ -81,35 +81,35 @@ importers:
specifier: ^0.6.8
version: 0.6.8(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.48.0)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0)))(@vite-pwa/assets-generator@0.2.6)(vite-plugin-pwa@0.21.2(@vite-pwa/assets-generator@0.2.6)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0))(workbox-build@7.3.0)(workbox-window@7.3.0))
'@welshman/app':
specifier: ^0.8.8
version: 0.8.8(b90dd618d8ad3ba87405490e903259ce)
specifier: ^0.8.9
version: 0.8.9(56a9569377ccbc308c0adef0d87b4892)
'@welshman/content':
specifier: ^0.8.8
version: 0.8.8(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.8.9
version: 0.8.9(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/editor':
specifier: ^0.8.8
version: 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.8.9
version: 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/feeds':
specifier: ^0.8.8
version: 0.8.8(827c582d718d0d373e9315813bab1085)
specifier: ^0.8.9
version: 0.8.9(e7b1650516a86ec271bd2c0f047c2e03)
'@welshman/lib':
specifier: ^0.8.8
version: 0.8.8
specifier: ^0.8.9
version: 0.8.9
'@welshman/net':
specifier: ^0.8.8
version: 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
specifier: ^0.8.9
version: 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router':
specifier: ^0.8.8
version: 0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))
specifier: ^0.8.9
version: 0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer':
specifier: ^0.8.8
version: 0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.8.9
version: 0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/store':
specifier: ^0.8.8
version: 0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
specifier: ^0.8.9
version: 0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
'@welshman/util':
specifier: ^0.8.8
version: 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.8.9
version: 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
compressorjs-next:
specifier: ^1.1.2
version: 1.1.2
@@ -134,6 +134,9 @@ importers:
idb:
specifier: ^8.0.3
version: 8.0.3
livekit-client:
specifier: ^2.17.2
version: 2.17.3(@types/dom-mediacapture-record@1.0.22)
nostr-signer-capacitor-plugin:
specifier: github:coracle-social/nostr-signer-capacitor-plugin#main
version: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1)
@@ -737,6 +740,9 @@ packages:
'@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
'@bufbuild/protobuf@1.10.1':
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
'@canvas/image-data@1.1.0':
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
@@ -1283,6 +1289,12 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@livekit/mutex@1.1.1':
resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
'@livekit/protocol@1.44.0':
resolution: {integrity: sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==}
'@noble/ciphers@0.5.3':
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
@@ -1823,6 +1835,9 @@ packages:
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/dom-mediacapture-record@1.0.22':
resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
'@types/eslint@9.6.1':
resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==}
@@ -1967,83 +1982,83 @@ packages:
'@vite-pwa/assets-generator':
optional: true
'@welshman/app@0.8.8':
resolution: {integrity: sha512-pyySouAJwGZ2RSC29egiFft38Ctuioodon6xWFxB7HvJ9Llsh5b53qjkrQcAYM7lUAzXwtalf2v4Z3EwYdUObg==}
'@welshman/app@0.8.9':
resolution: {integrity: sha512-2ff0Y9JzSVqJz9qY8vPDY7CC9xBZ5KQPLlVRX2OGnwopmLm9P68i6u8eJG53caxCUv+d7RCDXNlYOkFH6hr7nw==}
peerDependencies:
'@pomade/core': ^0.1.3
'@welshman/feeds': 0.8.8
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8
'@welshman/router': 0.8.8
'@welshman/signer': 0.8.8
'@welshman/store': 0.8.8
'@welshman/util': 0.8.8
'@pomade/core': ^0.2.1
'@welshman/feeds': 0.8.9
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9
'@welshman/router': 0.8.9
'@welshman/signer': 0.8.9
'@welshman/store': 0.8.9
'@welshman/util': 0.8.9
svelte: ^4.0.0 || ^5.0.0
'@welshman/content@0.8.8':
resolution: {integrity: sha512-5jh2YMoqINzkOEVSDZec6JbAqiC0WThwRuPwJOwiJlAFYQ4LC0MAT1HQ8z9pht/0TXdjYQUu2X+jngqqICNOiw==}
'@welshman/content@0.8.9':
resolution: {integrity: sha512-K9r1hAooqM857Ze4i0kF/LSqOZjhuYDsbY07kA1pjbkfrgf8cLuaVP+qicDxGfHD4kKDWuCb/PkUf2kC8nOjuQ==}
peerDependencies:
nostr-tools: ^2.19.4
'@welshman/editor@0.8.8':
resolution: {integrity: sha512-54WD2d6HEEiuoPgl/LeE4eaLtF2/SrYObk+IE9UUrJjoXcK/BK3vt8ltzazvBLR8ntfKOQINc4DhkeuBxiiCpA==}
'@welshman/editor@0.8.9':
resolution: {integrity: sha512-iDBp/qaZBGaaKfSk+7hrJkgzssovZLAkoT5ULjFoBpT9NfpJu/5WYfoUYwKxa6fS9Z84veS39y/PW1LFBNPnpg==}
peerDependencies:
'@welshman/lib': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.9
'@welshman/util': 0.8.9
nostr-editor: ^1.1.1
nostr-tools: ^2.19.4
'@welshman/feeds@0.8.8':
resolution: {integrity: sha512-o5JuptpWSNr6wtbM0RfSxTJgZStaNxPz160tE9u0SZzs1/a9sq/Yzesw7s+g0nKukRjBbl70DOqpTqOqfXAEIw==}
'@welshman/feeds@0.8.9':
resolution: {integrity: sha512-8JI6rrETqDqa9VdU0eEP1OBvTjnampfgs0ZhdOELy9itFcuqDz8wPScnw3sygApL53tkFv4n2xnjZE7E3N3U/w==}
peerDependencies:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8
'@welshman/router': 0.8.8
'@welshman/signer': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9
'@welshman/router': 0.8.9
'@welshman/signer': 0.8.9
'@welshman/util': 0.8.9
'@welshman/lib@0.8.8':
resolution: {integrity: sha512-77ZfVtodV05276ceR8c+JdDFqhOpmy2W6PkgDYbnKstQzKb5TN6wBvcLKxJppTzWMeWbyi2JADsuOYvW1jpOSQ==}
'@welshman/lib@0.8.9':
resolution: {integrity: sha512-Gk9MXaJNuLL9EguP2RnoaGaQy6x0BrneZfj9gL5t6ZNIF+1g+maJssKDbCRjdDPeuNQbRhh7AlSGSQUJuhkq6Q==}
engines: {node: '>=12.0.0'}
'@welshman/net@0.8.8':
resolution: {integrity: sha512-Rug3GzVzyABG21g++cCLOVXdjAieV6rJUZqstE8i/olZvOEWZpZ9R901DoUSDR07U2HTrAwHQrjgb1HmH4jiDQ==}
'@welshman/net@0.8.9':
resolution: {integrity: sha512-0brgfS7pHlE23CmAVLZ/RCGVvyjq+MX4NAhFyqxXuCCcqN7Lf9t60aQ6Z0aKl2dA79yVDtS6d1S53RR1rNS+Ew==}
peerDependencies:
'@welshman/lib': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.9
'@welshman/util': 0.8.9
'@welshman/router@0.8.8':
resolution: {integrity: sha512-j5O7F7KGQtOIvBJctEiUNcLfHBUnhHlYHxUx7ImPPurc1zLzt3JovvJJFubXMQoQ26D01DsK/AA1L5WZNebUhA==}
'@welshman/router@0.8.9':
resolution: {integrity: sha512-Kjf7CyO8wvnsVS3TX0eRUVd327F4vsDUdJFpo1MYjKRmgwj7ebOiofY8Fx011BX/GcpVVq7pUFs5792cSjlsrQ==}
peerDependencies:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9
'@welshman/util': 0.8.9
'@welshman/signer@0.8.8':
resolution: {integrity: sha512-rswHrTdc1+yvAno2h3JELzjp+LCfiYfUr8ACvwSSHAqDwrtezppfh0WDEPaYBp2EVSJ6tKMM1sVey0quO63aMw==}
version: 0.8.8
'@welshman/signer@0.8.9':
resolution: {integrity: sha512-PVnZn5Rz+10hH35f350JVo1ug3qFeunraOpCcPkmo8XVA0bEEY5503OMGfu1osfEI6KMseoFPU/AdZrh+w+ZQQ==}
version: 0.8.9
peerDependencies:
'@noble/curves': ^1.9.7
'@noble/hashes': ^2.0.1
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9
'@welshman/util': 0.8.9
nostr-signer-capacitor-plugin: '*'
nostr-tools: ^2.19.4
'@welshman/store@0.8.8':
resolution: {integrity: sha512-mTFueKZi9CtrtvCZT5eT5QaLMs94LxQg4y7oO5PZp9wv8EGSnB9p7XIflM0OfpKwF7c0pu1RdXcjVlvMDsC6QQ==}
'@welshman/store@0.8.9':
resolution: {integrity: sha512-wchFOvQB/E5/j5oyqw0QmIx1XzWtm0b3b2mtNUKF7bdtX7YskiSeLcXalJTALD+WkW02cGzBw2SvoJjtDiyWnw==}
peerDependencies:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9
'@welshman/util': 0.8.9
svelte: ^4.0.0 || ^5.0.0
'@welshman/util@0.8.8':
resolution: {integrity: sha512-SNT1VXab6ce36EVfjs1A2uwWs5elYTI4eXi8SUuj42k8CqNIAtG+bOf/JFIxXNTfl3NSxxZdWzpLLZWBqgpAxQ==}
'@welshman/util@0.8.9':
resolution: {integrity: sha512-oOijx0PCsTVhPOPr+5HS4mZFntrtHAW8cdBvJqu/Asf+m6UrvVCeuoF3NDtKhWbkuD6uZnfeJy6WDKVhTptZEA==}
peerDependencies:
'@noble/curves': ^1.9.7
'@welshman/lib': 0.8.8
'@welshman/lib': 0.8.9
nostr-tools: ^2.19.4
'@xml-tools/parser@1.0.11':
@@ -3334,6 +3349,9 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
jose@6.2.1:
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
@@ -3438,6 +3456,11 @@ packages:
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
livekit-client@2.17.3:
resolution: {integrity: sha512-htwsAL/BMylY/zwdcT/z00U789csbi9DldSW7DO+5tz7Q15pwu++E1X+ZdtZDfkmlysfQLLibdcqlyg9FY7veQ==}
peerDependencies:
'@types/dom-mediacapture-record': ^1
load-json-file@4.0.0:
resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==}
engines: {node: '>=4'}
@@ -3472,6 +3495,10 @@ packages:
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -4273,6 +4300,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
@@ -4302,6 +4332,13 @@ packages:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'}
sdp-transform@2.15.0:
resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==}
hasBin: true
sdp@3.2.1:
resolution: {integrity: sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -4703,6 +4740,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typed-emitter@2.1.0:
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
typescript-eslint@8.53.1:
resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4847,6 +4887,10 @@ packages:
webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
webrtc-adapter@9.0.4:
resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==}
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -5733,6 +5777,8 @@ snapshots:
'@braintree/sanitize-url@7.1.1': {}
'@bufbuild/protobuf@1.10.1': {}
'@canvas/image-data@1.1.0': {}
'@capacitor-community/safe-area@8.0.1(@capacitor/core@8.0.1)':
@@ -6298,6 +6344,12 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@livekit/mutex@1.1.1': {}
'@livekit/protocol@1.44.0':
dependencies:
'@bufbuild/protobuf': 1.10.1
'@noble/ciphers@0.5.3': {}
'@noble/ciphers@1.3.0': {}
@@ -6436,15 +6488,15 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@pomade/core@0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))':
'@pomade/core@0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies:
'@frostr/bifrost': 1.0.7(typescript@5.9.3)
'@noble/hashes': 2.0.1
'@peculiar/x509': 1.14.3
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/signer': 0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/signer': 0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
cbor-x: 1.6.0
hash-wasm: 4.12.0
nostr-tools: 2.20.0(typescript@5.9.3)
@@ -6859,6 +6911,8 @@ snapshots:
'@types/cookie@0.6.0': {}
'@types/dom-mediacapture-record@1.0.22': {}
'@types/eslint@9.6.1':
dependencies:
'@types/estree': 1.0.8
@@ -7031,26 +7085,26 @@ snapshots:
optionalDependencies:
'@vite-pwa/assets-generator': 0.2.6
'@welshman/app@0.8.8(b90dd618d8ad3ba87405490e903259ce)':
'@welshman/app@0.8.9(56a9569377ccbc308c0adef0d87b4892)':
dependencies:
'@pomade/core': 0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/feeds': 0.8.8(827c582d718d0d373e9315813bab1085)
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/store': 0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@pomade/core': 0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/feeds': 0.8.9(e7b1650516a86ec271bd2c0f047c2e03)
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/store': 0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
fuse.js: 7.1.0
svelte: 5.48.0
throttle-debounce: 5.0.2
'@welshman/content@0.8.8(nostr-tools@2.20.0(typescript@5.9.3))':
'@welshman/content@0.8.9(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies:
'@braintree/sanitize-url': 7.1.1
nostr-tools: 2.20.0(typescript@5.9.3)
'@welshman/editor@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))':
'@welshman/editor@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
'@tiptap/extension-code': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
@@ -7065,64 +7119,64 @@ snapshots:
'@tiptap/extension-text': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
'@tiptap/pm': 2.27.2
'@tiptap/suggestion': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)
'@welshman/lib': 0.8.8
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
nostr-editor: 1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))
nostr-tools: 2.20.0(typescript@5.9.3)
tippy.js: 6.3.7
'@welshman/feeds@0.8.8(827c582d718d0d373e9315813bab1085)':
'@welshman/feeds@0.8.9(e7b1650516a86ec271bd2c0f047c2e03)':
dependencies:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
trava: 1.2.1
'@welshman/lib@0.8.8':
'@welshman/lib@0.8.9':
dependencies:
'@scure/base': 1.2.6
'@types/events': 3.0.3
events: 3.3.0
'@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)':
'@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)':
dependencies:
'@welshman/lib': 0.8.8
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
events: 3.3.0
isomorphic-ws: 5.0.0(ws@8.18.3)
transitivePeerDependencies:
- ws
'@welshman/router@0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))':
'@welshman/router@0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))':
dependencies:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/signer@0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))':
'@welshman/signer@0.8.9(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies:
'@noble/curves': 1.9.7
'@noble/hashes': 2.0.1
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
nostr-signer-capacitor-plugin: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1)
nostr-tools: 2.20.0(typescript@5.9.3)
'@welshman/store@0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)':
'@welshman/store@0.8.9(@welshman/lib@0.8.9)(@welshman/net@0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)':
dependencies:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.9
'@welshman/net': 0.8.9(@welshman/lib@0.8.9)(@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))
svelte: 5.48.0
'@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))':
'@welshman/util@0.8.9(@noble/curves@1.9.7)(@welshman/lib@0.8.9)(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies:
'@noble/curves': 1.9.7
'@types/ws': 8.18.1
'@welshman/lib': 0.8.8
'@welshman/lib': 0.8.9
js-base64: 3.7.8
nostr-tools: 2.20.0(typescript@5.9.3)
nostr-wasm: 0.1.0
@@ -8530,6 +8584,8 @@ snapshots:
jiti@1.21.7: {}
jose@6.2.1: {}
js-base64@3.7.8: {}
js-tokens@4.0.0: {}
@@ -8605,6 +8661,19 @@ snapshots:
linkifyjs@4.3.2: {}
livekit-client@2.17.3(@types/dom-mediacapture-record@1.0.22):
dependencies:
'@livekit/mutex': 1.1.1
'@livekit/protocol': 1.44.0
'@types/dom-mediacapture-record': 1.0.22
events: 3.3.0
jose: 6.2.1
loglevel: 1.9.2
sdp-transform: 2.15.0
tslib: 2.8.1
typed-emitter: 2.1.0
webrtc-adapter: 9.0.4
load-json-file@4.0.0:
dependencies:
graceful-fs: 4.2.11
@@ -8637,6 +8706,8 @@ snapshots:
lodash@4.17.23: {}
loglevel@1.9.2: {}
lru-cache@10.4.3: {}
lru-cache@11.2.4: {}
@@ -9427,6 +9498,11 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
optional: true
sade@1.8.1:
dependencies:
mri: 1.2.0
@@ -9458,6 +9534,10 @@ snapshots:
sax@1.4.4: {}
sdp-transform@2.15.0: {}
sdp@3.2.1: {}
semver@5.7.2: {}
semver@6.3.1: {}
@@ -9982,6 +10062,10 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typed-emitter@2.1.0:
optionalDependencies:
rxjs: 7.8.2
typescript-eslint@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
@@ -10090,6 +10174,10 @@ snapshots:
webidl-conversions@4.0.2: {}
webrtc-adapter@9.0.4:
dependencies:
sdp: 3.2.1
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
+50
View File
@@ -394,6 +394,35 @@ progress[value]::-webkit-progress-value {
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cw-video-call-content {
@apply w-full md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
}
/* Voice: desktop split — plain CSS so / in calc is not parsed as Tailwind slash syntax */
.cw-split-video {
width: 100%;
}
.cw-split-chat {
width: 100%;
}
@media (min-width: 768px) {
.cw-split-video {
left: 18.5rem;
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
.cw-split-chat {
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
}
.cw-full {
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
}
@@ -422,6 +451,27 @@ body.keyboard-open .hide-on-keyboard {
@apply cb cw fixed z-compose;
}
.chat__compose-zone {
@apply cb cw fixed z-compose;
}
.chat__compose-zone .chat__compose-inner {
@apply min-w-0;
}
.chat__compose-zone.cw-video-call-content {
@apply md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
}
@media (min-width: 768px) {
.chat__compose-zone.cw-split-chat {
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
}
.chat__scroll-down {
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
}
+2 -3
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {page} from "$app/stores"
import {pubkey} from "@welshman/app"
import Dialog from "@lib/components/Dialog.svelte"
import Landing from "@app/components/Landing.svelte"
import Toast from "@app/components/Toast.svelte"
import PrimaryNav from "@app/components/PrimaryNav.svelte"
import {modals} from "@app/util/modal"
import {modal} from "@app/util/modal"
interface Props {
children: Snippet
@@ -20,7 +19,7 @@
<PrimaryNav>
{@render children?.()}
</PrimaryNav>
{:else if !$modals[$page.url.hash.slice(1)]}
{:else if !$modal}
<Dialog children={{component: Landing, props: {}}} />
{/if}
</div>
+152 -95
View File
@@ -2,9 +2,11 @@
import type {Snippet} from "svelte"
import {onMount} from "svelte"
import {
ago,
int,
ms,
partition,
ifLet,
spec,
nthEq,
nthNe,
@@ -46,11 +48,12 @@
import ChatMembers from "@app/components/ChatMembers.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {prependParent} from "@app/core/commands"
import {makeDelete, prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
type Props = {
@@ -78,73 +81,115 @@
parent = undefined
}
const onSubmit = async (params: EventContent) => {
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
// 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 = await Promise.all(
Array.from(enumerate(templates)).map(([i, event]) =>
sendWrapped({
event,
recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i),
}),
),
)
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk: mergeThunks(thunks)},
},
})
clearParent()
const clearEventToEdit = () => {
eventToEdit = undefined
}
const onSubmit = async (params: EventContent) => {
try {
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
// 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)
if (eventToEdit) {
if (eventToEdit.content === params.content) {
return
}
await sendWrapped({
event: makeDelete({event: eventToEdit, protect: false}),
recipients: pubkeys,
})
}
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],
created_at: eventToEdit?.created_at,
}),
)
}
}
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 = await Promise.all(
Array.from(enumerate(templates)).map(([i, event]) =>
sendWrapped({
event,
recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i),
}),
),
)
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk: mergeThunks(thunks)},
},
})
} finally {
clearParent()
clearEventToEdit()
}
}
const onEscape = () => {
clearParent()
clearEventToEdit()
}
const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $pubkey &&
event.kind === DIRECT_MESSAGE &&
event.created_at >= ago(500, MINUTE)
const onEditEvent = (event: TrustedEvent) => {
clearParent()
eventToEdit = event
}
const onEditPrevious = () => ifLet($chat?.messages.toReversed().find(canEditEvent), onEditEvent)
let loading = $state(true)
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
@@ -204,36 +249,36 @@
</script>
<PageBar>
{#snippet title()}
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
{#if others.length === 0}
<div class="row-2">
<ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$pubkey!} />
</div>
{:else if others.length === 1}
<div class="row-2">
<ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} />
</div>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<div class="flex items-center justify-between gap-4">
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
{#if others.length === 0}
<div class="row-2">
<ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$pubkey!} />
</div>
{:else if others.length === 1}
<div class="row-2">
<ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{/if}
</Button>
{/snippet}
{#snippet action()}
</div>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{/if}
</Button>
</div>
{#if remove($pubkey, missingRelayLists).length > 0}
{@const count = remove($pubkey, missingRelayLists).length}
{@const label = count > 1 ? "lists are" : "list is"}
@@ -244,7 +289,7 @@
{count}
</div>
{/if}
{/snippet}
</div>
</PageBar>
<PageContent class="flex flex-col-reverse gap-2 pt-4">
@@ -285,7 +330,9 @@
event={$state.snapshot(value as TrustedEvent)}
{pubkeys}
{showPubkey}
{replyTo} />
{replyTo}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
{/each}
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
@@ -305,6 +352,16 @@
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if eventToEdit}
<ChatComposeEdit clear={clearEventToEdit} />
{/if}
</div>
<ChatCompose bind:this={compose} {onSubmit} />
{#key eventToEdit}
<ChatCompose
bind:this={compose}
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content} />
{/key}
</div>
+29 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {onDestroy, onMount} from "svelte"
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
@@ -10,10 +11,13 @@
import {makeEditor} from "@app/editor"
type Props = {
content?: string
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
}
const {onSubmit}: Props = $props()
const {content, onEscape, onEditPrevious, onSubmit}: Props = $props()
const autofocus = !isMobile
@@ -21,6 +25,19 @@
export const focus = () => editor.then(ed => ed.chain().focus().run())
export const canEnterEditPrevious = () =>
editor.then(ed => ed.getText({blockSeparator: "\n"}) === "")
const handleKeyDown = async (event: KeyboardEvent) => {
if (event.key === "Escape") {
onEscape?.()
}
if (event.key === "ArrowUp" && (await canEnterEditPrevious())) {
onEditPrevious?.()
}
}
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
@@ -38,12 +55,23 @@
}
const editor = makeEditor({
content,
autofocus,
submit,
uploading,
aggressive: true,
encryptFiles: true,
})
onMount(async () => {
const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown)
})
onDestroy(async () => {
const ed = await editor
ed?.view?.dom.removeEventListener("keydown", handleKeyDown)
})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
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"
const {
clear,
}: {
clear: () => void
} = $props()
</script>
<div
class="relative flex h-8 items-center justify-between border-l-2 border-solid border-primary bg-base-300 px-2 pr-7 text-xs"
transition:slide>
<p class="text-primary">Editing message</p>
<Button onclick={clear} class="flex items-center">
<Icon icon={CloseCircle} />
</Button>
</div>
+7 -4
View File
@@ -23,11 +23,13 @@
interface Props {
event: TrustedEvent
replyTo: (event: TrustedEvent) => void
canEdit?: (event: TrustedEvent) => boolean
onEdit?: (event: TrustedEvent) => void
pubkeys: string[]
showPubkey?: boolean
}
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props()
const isOwn = event.pubkey === $pubkey
const profileDisplay = deriveProfileDisplay(event.pubkey)
@@ -35,6 +37,7 @@
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
const reply = () => replyTo(event)
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
const deleteReaction = (event: TrustedEvent) =>
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys})
@@ -44,7 +47,7 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply, edit})
const togglePopover = () => {
if (popoverIsVisible) {
@@ -71,7 +74,7 @@
<Tippy
bind:popover
component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo}}
props={{event, pubkeys, popover, replyTo, edit}}
params={{
interactive: true,
trigger: "manual",
@@ -93,7 +96,7 @@
{/if}
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
<TapTarget
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl min-w-[100px]"
onTap={showMobileMenu}>
{#if showPubkey}
<div class="flex items-center gap-2">
+8 -1
View File
@@ -4,12 +4,14 @@
import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {pushModal} from "@app/util/modal"
import Pen from "@assets/icons/pen.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
const {event, pubkeys, popover, replyTo} = $props()
const {event, pubkeys, popover, replyTo, edit} = $props()
const reply = () => replyTo(event)
const onEdit = () => edit?.()
const showInfo = () => {
popover.hide()
@@ -24,6 +26,11 @@
<Icon size={4} icon={Reply} />
</Button>
{/if}
{#if edit}
<Button class="btn join-item btn-xs" onclick={onEdit}>
<Icon size={4} icon={Pen} />
</Button>
{/if}
<Button class="btn join-item btn-xs" onclick={showInfo}>
<Icon size={4} icon={Code2} />
</Button>
@@ -3,6 +3,7 @@
import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Pen from "@assets/icons/pen.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"
@@ -20,9 +21,10 @@
pubkeys: string[]
event: TrustedEvent
reply: () => void
edit?: () => void
}
const {event, pubkeys, reply}: Props = $props()
const {event, pubkeys, reply, edit}: Props = $props()
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back()
@@ -39,6 +41,11 @@
reply()
}
const sendEdit = () => {
history.back()
edit?.()
}
const copyText = () => {
history.back()
clip(event.content)
@@ -62,6 +69,12 @@
<Icon size={4} icon={Reply} />
Send Reply
</Button>
{#if edit}
<Button class="btn btn-neutral w-full" onclick={sendEdit}>
<Icon size={4} icon={Pen} />
Edit Message
</Button>
{/if}
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} />
Send Reaction
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import IconPicker from "@app/components/IconPicker.svelte"
type Props = {
onSelect: (iconUrl: string) => void
}
const {onSelect}: Props = $props()
</script>
<Modal>
<ModalBody>
<IconPicker {onSelect} />
</ModalBody>
</Modal>
+1 -1
View File
@@ -82,7 +82,7 @@
<p>Your recovery codes have been sent!</p>
<p>
For security reasons, you may receive three or more emails with recovery codes in them. Please
paste <strong>all</strong> recovery codes into the text box below, on separate lines.
paste <strong>all</strong> recovery codes into the text box below.
</p>
<StringMultiInput bind:value={otps} placeholder="Enter your recovery codes..." />
</ModalBody>
+1 -1
View File
@@ -86,7 +86,7 @@
<p>Your login codes have been sent!</p>
<p>
For security reasons, you may receive three or more emails with login codes in them. Please
paste <strong>all</strong> login codes into the text box below, on separate lines.
paste <strong>all</strong> login codes into the text box below.
</p>
<StringMultiInput bind:value={otps} placeholder="Enter your login codes..." />
</ModalBody>
+54 -108
View File
@@ -1,12 +1,9 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import {pubkey} from "@welshman/app"
import Server from "@assets/icons/server.svg?dataurl"
import Moon from "@assets/icons/moon.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 GalleryMinimalistic from "@assets/icons/gallery-minimalistic.svg?dataurl"
import Shield from "@assets/icons/shield-minimalistic.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"
@@ -14,120 +11,69 @@
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import Profile from "@app/components/Profile.svelte"
import LogOut from "@app/components/LogOut.svelte"
import {PLATFORM_NAME} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {theme} from "@app/util/theme"
const back = () => history.back()
const logout = () => pushModal(LogOut)
const toggleTheme = () => theme.set($theme === "dark" ? "light" : "dark")
</script>
<Modal>
<ModalBody>
<div class="flex flex-col gap-2">
<Link replaceState href="/settings/profile">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={UserRounded} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Profile</div>
{/snippet}
{#snippet info()}
<div>Customize your user profile</div>
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/alerts">
<CardButton class="btn-neutral">
{#snippet icon()}
<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>
{#if Capacitor.getPlatform() !== "ios"}
<Link replaceState href="/settings/wallet">
<CardButton class="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>
<div class="flex flex-col gap-8 items-center py-12 max-w-[16rem] m-auto w-full">
{#if $pubkey}
<Link replaceState href="/settings/profile">
<Profile inert pubkey={$pubkey} />
</Link>
{/if}
<Link replaceState href="/settings/relays">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Server} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Relays</div>
{/snippet}
{#snippet info()}
<div>Control how {PLATFORM_NAME} talks to the network</div>
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/content">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Settings} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Settings</div>
{/snippet}
{#snippet info()}
<div>Get into the details about how {PLATFORM_NAME} works</div>
{/snippet}
</CardButton>
</Link>
<Button onclick={toggleTheme}>
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Moon} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Theme</div>
{/snippet}
{#snippet info()}
<div>Switch between light and dark mode</div>
{/snippet}
</CardButton>
</Button>
<Link replaceState href="/settings/about">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Code2} size={7} /></div>
{/snippet}
{#snippet title()}
<div>About</div>
{/snippet}
{#snippet info()}
<div>Learn about {PLATFORM_NAME} and support the developer</div>
{/snippet}
</CardButton>
</Link>
<Button onclick={logout} class="btn btn-neutral">
<Icon icon={Exit} /> Log Out
</Button>
<Button class="btn btn-link w-full md:hidden" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<div class="grid grid-cols-2 gap-3 w-full">
<Link
replaceState
href="/settings/alerts"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Bell} size={7} />
Alerts
</Link>
{#if Capacitor.getPlatform() !== "ios"}
<Link
replaceState
href="/settings/wallet"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Wallet} size={7} />
Wallet
</Link>
{/if}
<Link
replaceState
href="/settings/relays"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Server} size={7} />
Relays
</Link>
<Link
replaceState
href="/settings/content"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={GalleryMinimalistic} size={7} />
Content
</Link>
<Link
replaceState
href="/settings/privacy"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-4 text-center">
<Icon icon={Shield} size={7} />
Privacy
</Link>
</div>
<div class="flex gap-3 items-center opacity-75 text-sm">
<Button onclick={toggleTheme}>Theme</Button>
/
<Link replaceState href="/settings/about">About</Link>
/
<Button onclick={logout}>Log Out</Button>
</div>
</div>
</ModalBody>
</Modal>
+21 -13
View File
@@ -2,38 +2,46 @@
import {onMount, mount, unmount} from "svelte"
import Drawer from "@lib/components/Drawer.svelte"
import Dialog from "@lib/components/Dialog.svelte"
import {modal, clearModals} from "@app/util/modal"
import {modal, modalStack, popModal} from "@app/util/modal"
const closeModals = () => {
const closeModal = () => {
if ($modal && !$modal.options.noEscape) {
clearModals()
popModal()
}
}
const onKeyDown = (e: any) => {
if (e.code === "Escape" && e.target === document.body) {
closeModals()
closeModal()
}
}
let element: HTMLElement
let instance: any | undefined
const instances: Record<string, any> = {}
onMount(() => {
return modal.subscribe($modal => {
if (instance) {
unmount(instance, {outro: true})
instance = undefined
return modalStack.subscribe($modalStack => {
const ids = $modalStack.map(({id}) => id)
for (const [id, instance] of Object.entries(instances)) {
if (!ids.includes(id)) {
unmount(instance, {outro: true})
delete instances[id]
}
}
if ($modal) {
const {options, component, props} = $modal
for (const item of $modalStack) {
if (instances[item.id]) {
continue
}
const {options, component, props} = item
const wrapper = options.drawer ? Drawer : Dialog
instance = mount(wrapper as any, {
instances[item.id] = mount(wrapper as any, {
target: element,
props: {
onClose: closeModals,
onClose: closeModal,
fullscreen: options.fullscreen,
children: {component, props},
},
+1 -1
View File
@@ -89,7 +89,7 @@
<p>Let's start by confirming your email.</p>
<p>
For security reasons, you may receive three or more emails with confirmation codes in them.
Please paste <strong>all</strong> confirmation codes into the text box below, on separate lines.
Please paste <strong>all</strong> confirmation codes into the text box below.
</p>
<StringMultiInput bind:value={otps} placeholder="Enter your confirmation codes..." />
</ModalBody>
+6 -40
View File
@@ -1,21 +1,18 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {splitAt} from "@welshman/lib"
import {userProfile} from "@welshman/app"
import Widget from "@assets/icons/widget.svg?dataurl"
import Compass from "@assets/icons/compass.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
import Planet from "@assets/icons/planet-3.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import {userSpaceUrls, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications"
import {goToLastChat} from "@app/util/routes"
@@ -28,44 +25,13 @@
const showSettingsMenu = () => pushModal(MenuSettings)
let windowHeight = $state(0)
const itemHeight = 56
const navPadding = 8 * itemHeight
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
</script>
<svelte:window bind:innerHeight={windowHeight} />
<div
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
<div>
{#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} />
{:else}
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
</PrimaryNavItem>
<Divider />
{#each primarySpaceUrls as url (url)}
<PrimaryNavItemSpace {url} />
{/each}
<PrimaryNavItem
href="/spaces"
title="All Spaces"
class="tooltip-right"
notification={otherSpaceNotifications}>
<ImageIcon alt="All Spaces" src={Widget} size={8} />
</PrimaryNavItem>
<PrimaryNavItem title="Add a Space" href="/discover" class="tooltip-right">
<ImageIcon alt="Add a Space" src={Compass} size={8} />
</PrimaryNavItem>
{/each}
</div>
<PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
<Divider />
{/if}
@@ -116,7 +82,7 @@
</PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
<ImageIcon alt="Spaces" src={SettingsMinimalistic} size={8} />
<ImageIcon alt="Spaces" src={Planet} size={8} />
</PrimaryNavItem>
{/if}
</div>
@@ -0,0 +1,45 @@
<script lang="ts">
import {splitAt} from "@welshman/lib"
import Widget from "@assets/icons/widget.svg?dataurl"
import Compass from "@assets/icons/compass.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import {userSpaceUrls, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state"
import {notifications} from "@app/util/notifications"
let windowHeight = $state(0)
const itemHeight = 56
const navPadding = 8 * itemHeight
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
</script>
<svelte:window bind:innerHeight={windowHeight} />
<div>
{#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} />
{:else}
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
</PrimaryNavItem>
<Divider />
{#each primarySpaceUrls as url (url)}
<PrimaryNavItemSpace {url} />
{/each}
<PrimaryNavItem
href="/spaces"
title="All Spaces"
class="tooltip-right"
notification={otherSpaceNotifications}>
<ImageIcon alt="All Spaces" src={Widget} size={8} />
</PrimaryNavItem>
<PrimaryNavItem title="Add a Space" href="/discover" class="tooltip-right">
<ImageIcon alt="Add a Space" src={Compass} size={8} />
</PrimaryNavItem>
{/each}
</div>
+7 -2
View File
@@ -17,15 +17,20 @@
url?: string
showPubkey?: boolean
avatarSize?: number
inert?: boolean
}
const {pubkey, url, showPubkey, avatarSize = 10}: Props = $props()
const {pubkey, url, showPubkey, inert, avatarSize = 10}: Props = $props()
const relays = removeUndefined([url])
const profileDisplay = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
const openProfile = () => {
if (!inert) {
pushModal(ProfileDetail, {pubkey, url})
}
}
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
</script>
+1 -1
View File
@@ -6,7 +6,7 @@
import ImageIcon from "@lib/components/ImageIcon.svelte"
type Props = {
pubkey: string
pubkey?: string
class?: string
size?: number
url?: string
+65 -13
View File
@@ -1,30 +1,70 @@
<script lang="ts">
import {onMount} from "svelte"
import {SvelteSet} from "svelte/reactivity"
import type {Readable} from "svelte/store"
import {tryCatch} from "@welshman/lib"
import {isShareableRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {relaySearch} from "@welshman/app"
import {isShareableRelayUrl, isIPAddress, normalizeRelayUrl} from "@welshman/util"
import type {Thunk} from "@welshman/app"
import {waitForThunkError, relaySearch} from "@welshman/app"
import {createScroller} from "@lib/html"
import {errorMessage} from "@lib/util"
import Magnifier from "@assets/icons/magnifier.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 Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RelayItem from "@app/components/RelayItem.svelte"
import {pushToast} from "@app/util/toast"
interface Props {
relays: Readable<string[]>
addRelay: (url: string) => void
addRelay: (url: string) => Promise<Thunk>
matchRelay?: (url: string) => boolean
}
const {relays, addRelay}: Props = $props()
const {relays, addRelay, matchRelay}: Props = $props()
const back = () => history.back()
const customUrl = $derived(tryCatch(() => normalizeRelayUrl(term)))
const add = async (url: string) => {
loading.add(url)
try {
const error = await waitForThunkError(await addRelay(url))
if (error) {
pushToast({
theme: "error",
message: `Failed to add relay: ${errorMessage(error)}`,
})
}
} finally {
loading.delete(url)
}
}
let term = $state("")
let limit = $state(20)
let element: Element | undefined = $state()
const customUrl = $derived(tryCatch(() => normalizeRelayUrl(term)))
const loading = $state(new SvelteSet<string>())
const searchResults = $derived(
$relaySearch
.searchValues(term)
.filter(url => {
if (matchRelay?.(url) === false) return false
if ($relays.includes(url)) return false
if (isIPAddress(url)) return false
return true
})
.slice(0, limit),
)
onMount(() => {
const scroller = createScroller({
@@ -52,23 +92,35 @@
<RelayItem url={term}>
<Button
class="btn btn-outline btn-sm flex items-center"
onclick={() => addRelay(customUrl)}>
<Icon icon={AddCircle} />
disabled={loading.has(customUrl)}
onclick={() => add(customUrl)}>
{#if loading.has(customUrl)}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={AddCircle} />
{/if}
Add Relay
</Button>
</RelayItem>
{/if}
{#each $relaySearch
.searchValues(term)
.filter(url => !$relays.includes(url))
.slice(0, limit) as url (url)}
{#each searchResults as url (url)}
<RelayItem {url}>
<Button class="btn btn-outline btn-sm flex items-center" onclick={() => addRelay(url)}>
<Icon icon={AddCircle} />
<Button
class="btn btn-outline btn-sm flex items-center"
disabled={loading.has(url)}
onclick={() => add(url)}>
{#if loading.has(url)}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={AddCircle} />
{/if}
Add Relay
</Button>
</RelayItem>
{/each}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
</ModalFooter>
</Modal>
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
import type {Readable} from "svelte/store"
import {SvelteSet} from "svelte/reactivity"
import {waitForThunkError} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import {errorMessage} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RelayAdd from "@app/components/RelayAdd.svelte"
import RelayItem from "@app/components/RelayItem.svelte"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
interface Props {
title: string
subtitle: string
relays: Readable<string[]>
addRelay: (url: string) => Promise<Thunk>
removeRelay: (url: string) => Promise<Thunk>
matchRelay?: (url: string) => boolean
}
const {title, subtitle, relays, addRelay, removeRelay, matchRelay}: Props = $props()
const back = () => history.back()
const add = () => pushModal(RelayAdd, {relays, addRelay, matchRelay})
const remove = async (url: string) => {
loading.add(url)
try {
const error = await waitForThunkError(await removeRelay(url))
if (error) {
pushToast({
theme: "error",
message: `Failed to remove relay: ${errorMessage(error)}`,
})
}
} finally {
loading.delete(url)
}
}
const loading = $state(new SvelteSet<string>())
</script>
<Modal>
<ModalBody>
<h2 class="text-xl">{title}</h2>
<p class="text-sm">{subtitle}</p>
{#each $relays.toSorted() as url (url)}
<RelayItem {url}>
<Button
class="btn btn-sm btn-neutral"
disabled={loading.has(url)}
onclick={() => remove(url)}>
{#if loading.has(url)}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={CloseCircle} />
{/if}
Remove
</Button>
</RelayItem>
{/each}
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={add}>
Add Relays
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</Modal>
@@ -0,0 +1,27 @@
<script lang="ts" module>
export type ActionItem = {
title: string
subtitle: string
action: string
apply: () => unknown
}
</script>
<script lang="ts">
import Stars from "@assets/icons/stars.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
const {title, action, subtitle, apply}: ActionItem = $props()
</script>
<div class="card2 card2-sm bg-alt flex justify-between">
<div class="flex flex-col gap-1">
<strong>{title}</strong>
<p class="text-sm">{subtitle}</p>
</div>
<Button class="btn btn-neutral btn-sm" onclick={apply}>
<Icon icon={Stars} />
{action}
</Button>
</div>
@@ -0,0 +1,58 @@
<script lang="ts">
import type {Readable} from "svelte/store"
import Stars from "@assets/icons/stars.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import DangerTriangle from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import type {ActionItem} from "@app/components/RelaySettingsActionItem.svelte"
import RelaySettingsActionItem from "@app/components/RelaySettingsActionItem.svelte"
interface Props {
actionItems: Readable<ActionItem[]>
}
const {actionItems}: Props = $props()
const back = () => history.back()
const applyAll = () => {
for (const actionItem of $actionItems) {
actionItem.apply()
}
}
$effect(() => {
if ($actionItems.length === 0) {
back()
}
})
</script>
<Modal>
<ModalBody>
<div class="flex gap-2 items-center">
<Icon icon={DangerTriangle} />
<strong class="text-lg">Action Items</strong>
</div>
<p class="text-sm">
Below are recommendations for adjustments to your relay selections that you might consider.
</p>
{#each $actionItems as actionItem}
<RelaySettingsActionItem {...actionItem} />
{/each}
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go Back
</Button>
<Button class="btn btn-primary" onclick={applyAll}>
<Icon icon={Stars} />
Apply All Recommendations
</Button>
</ModalFooter>
</Modal>
@@ -0,0 +1,50 @@
<script lang="ts">
import type {Readable} from "svelte/store"
import Check from "@assets/icons/check.svg?dataurl"
import DangerTriangle from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import RelayList from "@app/components/RelayList.svelte"
import {pushModal} from "@app/util/modal"
interface Props {
icon: string
title: string
subtitle: string
relays: Readable<string[]>
addRelay: (url: string) => unknown
removeRelay: (url: string) => unknown
matchRelay?: (url: string) => boolean
}
const {icon, title, relays, subtitle, addRelay, removeRelay, matchRelay}: Props = $props()
const onclick = () =>
pushModal(RelayList, {title, subtitle, relays, addRelay, removeRelay, matchRelay})
</script>
<button
type="button"
class="btn font-normal flex h-[unset] w-full flex-nowrap py-4 text-left items-start justify-between"
{onclick}>
<div class="flex flex-grow flex-row items-start gap-4">
<div class="flex h-7 w-7 flex-shrink-0 items-center justify-center">
<Icon {icon} />
</div>
<div class="flex flex-col gap-1">
<p class="text-lg">
{title}
</p>
<p class="text-sm">
{subtitle}
</p>
</div>
</div>
<div class="flex items-center justify-end gap-1">
{#if $relays.length <= 1}
<Icon icon={DangerTriangle} />
{:else}
<Icon icon={Check} />
{/if}
{$relays.length}
</div>
</button>
+2 -2
View File
@@ -16,7 +16,7 @@
import Lock from "@assets/icons/lock.svg?dataurl"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -255,7 +255,7 @@
<strong class="text-lg">Room Settings</strong>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon={VolumeLoud} />
<Icon icon={Bell} />
<span>Notifications</span>
</div>
<input
+34 -2
View File
@@ -5,6 +5,7 @@
import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Volume from "@assets/icons/volume.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import {preventDefault} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte"
@@ -15,6 +16,7 @@
import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast"
import {uploadFile} from "@app/core/commands"
import {deriveHasLivekit, getRoomType, RoomType} from "@app/core/state"
type Props = {
url: string
@@ -27,12 +29,25 @@
const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props()
const values = $state(initialValues)
const relayHasLivekit = deriveHasLivekit(url)
const submit = async () => {
const room = $state.snapshot(values)
if (roomType === RoomType.Voice && !$relayHasLivekit) {
return pushToast({
theme: "error",
message: "This relay does not support voice rooms.",
})
}
room.livekit = roomType === RoomType.Voice
if (imageFile) {
const {error, result} = await uploadFile(imageFile, {maxWidth: 256, maxHeight: 256})
const {error, result} = await uploadFile(imageFile, {
maxWidth: 256,
maxHeight: 256,
})
if (error) {
return pushToast({theme: "error", message: error})
@@ -76,6 +91,7 @@
let loading = $state(false)
let imageFile = $state<File | undefined>()
let imagePreview = $state(initialValues.picture)
let roomType = $state(getRoomType(initialValues))
const handleImageUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
@@ -145,7 +161,7 @@
{#if imagePreview}
<ImageIcon src={imagePreview} alt="" class="rounded-lg" />
{:else}
<Icon icon={Hashtag} />
<Icon icon={roomType === RoomType.Voice ? Volume : Hashtag} />
{/if}
<input bind:value={values.name} class="grow" type="text" />
</label>
@@ -161,6 +177,22 @@
</label>
{/snippet}
</FieldInline>
{#if $relayHasLivekit}
<FieldInline>
{#snippet label()}
<p>Room type</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={roomType}
aria-label="Room type">
<option value={RoomType.Text}>Text</option>
<option value={RoomType.Voice}>Voice</option>
</select>
{/snippet}
</FieldInline>
{/if}
<strong class="md:hidden">Permissions</strong>
<div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isRestricted} />
+22 -3
View File
@@ -1,22 +1,41 @@
<script lang="ts">
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Volume from "@assets/icons/volume.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import {deriveRoom} from "@app/core/state"
import {currentVoiceSession} from "@app/voice"
interface Props {
h: string
url: string
size?: number
fallbackIcon?: string
}
const {url, h, size = 5}: Props = $props()
const {url, h, size = 5, fallbackIcon = Hashtag}: Props = $props()
const room = deriveRoom(url, h)
const isVoiceRoom = $derived($room.livekit)
const isVoiceRoomActive = $derived(
$currentVoiceSession?.url === url && $currentVoiceSession?.h === h,
)
</script>
{#if $room.picture}
{#if isVoiceRoom}
<div class="flex shrink-0 items-center gap-1.5">
<Icon
size={size + 1}
icon={isVoiceRoomActive ? VolumeLoud : Volume}
class={isVoiceRoomActive ? "text-primary -translate-x-0.5" : ""} />
{#if $room.picture}
<span class="text-base">/</span>
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
{/if}
</div>
{:else if $room.picture}
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
{:else}
<Icon icon={Hashtag} {size} />
<Icon icon={fallbackIcon} {size} />
{/if}
+1 -1
View File
@@ -12,6 +12,6 @@
const room = deriveRoom(url, h)
</script>
<span class="ellipsize {props.class}">
<span class="ellipsize min-w-0 {props.class}">
{$room?.name || h}
</span>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {displayRelayUrl} from "@welshman/util"
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import {decodeRelay} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
interface Props {
back?: () => unknown
title?: Snippet
action?: Snippet
[key: string]: any
}
const {back = () => goto(makeSpacePath(url)), title, action, ...props}: Props = $props()
const url = decodeRelay($page.params.relay!)
</script>
<PageBar {...props}>
<div class="flex">
<Button onclick={back} class="place-self-start pr-3 md:hidden">
<Icon icon={ArrowLeft} size={7} />
</Button>
<div class="ellipsize whitespace-nowrap flex flex-grow items-center justify-between gap-4">
<div class="flex flex-col">
<div class="flex gap-2 items-center">
{@render title?.()}
</div>
<div class="text-xs text-primary md:hidden">
{displayRelayUrl(url)}
</div>
</div>
<div class="flex gap-2 items-start">
{@render action?.()}
</div>
</div>
</div>
</PageBar>
+2 -2
View File
@@ -3,7 +3,7 @@
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
import {manageRelay, forceLoadRelay} from "@welshman/app"
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
import Planet from "@assets/icons/planet-3.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import {preventDefault} from "@lib/html"
@@ -164,7 +164,7 @@
{#if imagePreview}
<ImageIcon src={imagePreview} alt="" />
{:else}
<Icon icon={SettingsMinimalistic} />
<Icon icon={Planet} />
{/if}
<input bind:value={values.name} class="grow" type="text" />
</label>
+6 -4
View File
@@ -159,9 +159,11 @@
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} />
Add members
</Button>
{#if $userIsAdmin}
<Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} />
Add members
</Button>
{/if}
</ModalFooter>
</Modal>
+27 -15
View File
@@ -20,8 +20,8 @@
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
import VolumeCross from "@assets/icons/volume-cross.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 Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
@@ -38,6 +38,7 @@
import SpaceReports from "@app/components/SpaceReports.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {
ENABLE_ZAPS,
@@ -45,6 +46,7 @@
deriveSpaceMembers,
deriveUserRooms,
deriveOtherRooms,
deriveOtherVoiceRooms,
userSpaceUrls,
hasNip29,
deriveUserCanCreateRoom,
@@ -68,6 +70,7 @@
const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const otherVoiceRooms = deriveOtherVoiceRooms(url)
const members = deriveSpaceMembers(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
@@ -133,9 +136,9 @@
})
</script>
<div bind:this={element} class="flex h-full flex-col justify-between">
<SecondaryNavSection class="pb-0">
<div>
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
<div class="flex-shrink-0">
<Button
class="flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
onclick={openMenu}>
@@ -143,7 +146,7 @@
<strong class="ellipsize flex items-center gap-1">
<RelayName {url} />
{#if $notificationSettings.push && !$shouldNotify}
<Icon icon={VolumeCross} size={3} class="opacity-50" />
<Icon icon={BellOff} size={3} class="opacity-50" />
{/if}
</strong>
<Icon icon={AltArrowDown} />
@@ -192,12 +195,12 @@
<li>
{#if $notificationSettings.push}
<Button onclick={toggleSpaceNotifications}>
<Icon icon={$shouldNotify ? VolumeLoud : VolumeCross} />
<Icon icon={$shouldNotify ? Bell : BellOff} />
{$shouldNotify ? "Turn off" : "Turn on"} notifications
</Button>
{:else}
<Link href="/settings/alerts">
<Icon icon={VolumeLoud} />
<Icon icon={Bell} />
Enable notifications
</Link>
{/if}
@@ -219,8 +222,7 @@
</Popover>
{/if}
</div>
<div
class="flex max-h-[calc(100vh-150px)] min-h-0 flex-col gap-1 overflow-auto overflow-x-hidden">
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden">
{#if hasNip29($relay)}
<SecondaryNavItem {replaceState} href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity
@@ -252,14 +254,14 @@
{/if}
{#if hasNip29($relay)}
{#if $userRooms.length > 0}
<div class="h-2"></div>
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h, i (h)}
{#each $userRooms as h (h)}
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
@@ -274,9 +276,16 @@
<input bind:value={term} onblur={clearTerm} class="grow" />
</label>
{/if}
{#each $roomSearch.searchValues(term) as h, i (h)}
{#each $roomSearch.searchValues(term) as h (h)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
{/each}
{#if $otherVoiceRooms.length > 0}
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
{/each}
{/if}
{#if $canCreateRoom}
<SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon={AddCircle} />
@@ -284,9 +293,12 @@
</SecondaryNavItem>
{/if}
{/if}
<div class="h-5 flex-shrink-0"></div>
</div>
</SecondaryNavSection>
<div class="flex flex-col gap-2 pb-2 p-4 pt-0">
<div
class="flex flex-shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+3rem)] sm:pb-2 z-nav">
<VoiceWidget />
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
<SocketStatusIndicator {url} />
</Button>
-27
View File
@@ -1,27 +0,0 @@
<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 SpaceMenu from "@app/components/SpaceMenu.svelte"
import {notifications} from "@app/util/notifications"
import {makeSpacePath} from "@app/util/routes"
import {pushDrawer} from "@app/util/modal"
import {deriveSocketStatus} from "@app/core/state"
const {url} = $props()
const path = makeSpacePath(url) + ":mobile"
const status = deriveSocketStatus(url)
const openMenu = () => pushDrawer(SpaceMenu, {url})
</script>
<Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden btn-square">
<Icon icon={MenuDots} />
{#if $status.theme !== "success"}
<div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-{$status.theme}"></div>
{:else 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}
</Button>
+19 -12
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.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 SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import VoiceRoomItem from "@app/components/VoiceRoomItem.svelte"
import {deriveRoom, deriveShouldNotify, getRoomType, RoomType} from "@app/core/state"
import {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes"
import {deriveShouldNotify} from "@app/core/state"
interface Props {
url: any
@@ -17,18 +18,24 @@
const {url, h, notify = false, replaceState = false}: Props = $props()
const room = deriveRoom(url, h)
const roomType = $derived(getRoomType($room))
const path = makeRoomPath(url, h)
const shouldNotifyForSpace = deriveShouldNotify(url)
const shouldNotifyForRoom = deriveShouldNotify(url, h)
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
</script>
<SecondaryNavItem
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
<RoomNameWithImage {url} {h} />
{#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? VolumeLoud : VolumeCross} size={4} class="opacity-50" />
{/if}
</SecondaryNavItem>
{#if roomType === RoomType.Voice}
<VoiceRoomItem {url} {h} {replaceState} />
{:else}
<SecondaryNavItem
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
<RoomNameWithImage {url} {h} />
{#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
{/if}
</SecondaryNavItem>
{/if}
+247
View File
@@ -0,0 +1,247 @@
<script lang="ts">
import cx from "classnames"
import {Track} from "livekit-client"
import {displayProfileByPubkey, loadProfile} from "@welshman/app"
import Pin from "@assets/icons/pin.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import VideoCallVideo from "@app/components/VideoCallVideo.svelte"
import {
currentVoiceSession,
currentVoiceRoom,
videoCallContentActive,
videoCallLayoutRevision,
videoPrimaryTileKey,
toggleVideoPrimaryTile,
pubkeyFromLiveKitIdentity,
} from "@app/voice"
type Variant = "mobile" | "desktop-split" | "desktop-full"
type Props = {
variant: Variant
url: string
h: string
visible?: boolean
class?: string
}
type Tile = {
identity: string
isLocal: boolean
trackSid: string
attachable: Track | undefined
source: Track.Source.Camera | Track.Source.ScreenShare
}
type TileLayout = "spotlight" | "default" | "strip"
const {variant, url, h, visible = true, class: className = ""}: Props = $props()
const roomMatches = $derived($currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h)
const allowEmptyPanel = $derived(variant === "desktop-split" || variant === "desktop-full")
const showPanel = $derived(
visible &&
roomMatches &&
(variant === "mobile" ? $videoCallContentActive : $videoCallContentActive || allowEmptyPanel),
)
const tiles = $derived.by(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes
$videoCallLayoutRevision
const session = $currentVoiceSession
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
return []
}
const room = session.room
const out: Tile[] = []
const lp = room.localParticipant
if (session.cameraOn) {
const localPub = lp.getTrackPublication(Track.Source.Camera)
out.push({
identity: lp.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-camera",
attachable: localPub?.track,
source: Track.Source.Camera,
})
}
if (session.screenShareOn) {
const localPub = lp.getTrackPublication(Track.Source.ScreenShare)
out.push({
identity: lp.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-screen",
attachable: localPub?.track,
source: Track.Source.ScreenShare,
})
}
for (const rp of room.remoteParticipants.values()) {
const camPub = rp.getTrackPublication(Track.Source.Camera)
if (camPub?.isSubscribed && camPub.track) {
out.push({
identity: rp.identity,
isLocal: false,
trackSid: camPub.trackSid,
attachable: camPub.track,
source: Track.Source.Camera,
})
}
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
if (screenPub?.isSubscribed && screenPub.track) {
out.push({
identity: rp.identity,
isLocal: false,
trackSid: screenPub.trackSid,
attachable: screenPub.track,
source: Track.Source.ScreenShare,
})
}
}
return out
})
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
const tileKey = (t: Tile) => `${t.identity}\x1f${t.source}`
const primaryTile = $derived.by(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return undefined
return tiles.find(t => tileKey(t) === k)
})
const secondaryTiles = $derived.by(() => {
const p = primaryTile
if (p === undefined) return tiles
const pk = tileKey(p)
return tiles.filter(t => tileKey(t) !== pk)
})
const useSpotlightLayout = $derived(primaryTile !== undefined)
const useMultiGrid = $derived(!useSpotlightLayout && tiles.length > 2)
$effect(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return
if (!tiles.some(t => tileKey(t) === k)) {
videoPrimaryTileKey.set(undefined)
}
})
$effect(() => {
for (const t of tiles) {
const pk = pubkeyFromLiveKitIdentity(t.identity)
if (pk) loadProfile(pk)
}
})
const labelFor = (identity: string, source: Tile["source"]) => {
const pk = pubkeyFromLiveKitIdentity(identity)
const name = pk ? displayProfileByPubkey(pk) : "Unknown"
return source === Track.Source.ScreenShare ? `${name} · screen` : name
}
const showTileGrid = $derived(tiles.length > 0)
const spotlightHandlerFor = (key: string) => () => {
toggleVideoPrimaryTile(key)
}
const panelChrome = $derived(
cx(
variant === "mobile" &&
"cb ct cw z-compose bg-base-300/95 fixed inset-x-0 flex min-h-0 flex-col gap-2 overflow-hidden p-2 md:hidden",
variant === "desktop-split" &&
"cb ct cw-split-video z-compose bg-base-300/95 fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
variant === "desktop-full" &&
"cb ct cw z-compose bg-base-300/95 fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
className,
),
)
</script>
{#snippet videoTile(tile: Tile, layout: TileLayout)}
<div
class={cx(
"relative isolate overflow-hidden rounded-box shadow-sm",
layout === "spotlight" && "min-h-0 flex-1",
layout === "default" && "aspect-video w-full min-h-0",
layout === "strip" && "aspect-video w-44 shrink-0",
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-base-100",
$videoPrimaryTileKey === tileKey(tile) &&
"ring-2 ring-primary ring-offset-2 ring-offset-base-300",
)}>
{#if tile.attachable}
<VideoCallVideo
track={tile.attachable}
muted={tile.isLocal}
fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"}
class="pointer-events-none absolute inset-0" />
{:else}
<div class="absolute inset-0 flex items-center justify-center">
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
</div>
{/if}
<span
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
</span>
{#if tiles.length > 1}
<Button
data-tip={$videoPrimaryTileKey === tileKey(tile) ? "Exit spotlight" : "Spotlight"}
class="absolute right-1 top-1 z-20 btn btn-xs btn-circle btn-ghost bg-base-100/70"
onclick={spotlightHandlerFor(tileKey(tile))}>
<Icon icon={Pin} size={3} />
</Button>
{/if}
</div>
{/snippet}
{#if showPanel && (showTileGrid || allowEmptyPanel)}
<div class={panelChrome}>
{#if showTileGrid}
{#if useSpotlightLayout && primaryTile}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{@render videoTile(primaryTile, "spotlight")}
{#if secondaryTiles.length > 0}
<div
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
{#each secondaryTiles as tile (tileKey(tile))}
{@render videoTile(tile, "strip")}
{/each}
</div>
{/if}
</div>
{:else if useMultiGrid}
<div
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{:else}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{/if}
{:else}
<div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-100/50 p-4 text-center text-sm opacity-80">
<p>No camera or screen share yet.</p>
<p class="text-xs">
Use the camera or screen share control in the voice widget to share video.
</p>
</div>
{/if}
</div>
{/if}
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import type {Track} from "livekit-client"
import cx from "classnames"
type Props = {
track: Track
muted?: boolean
fit?: "cover" | "contain"
class?: string
}
const {track, muted = true, fit = "cover", class: className = ""}: Props = $props()
let el = $state<HTMLVideoElement | undefined>()
$effect(() => {
const v = el
const t = track
if (!v) return
t.attach(v)
return () => {
t.detach(v)
}
})
</script>
<video
bind:this={el}
class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)}
playsinline
{muted}></video>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import cx from "classnames"
import {loadProfile, displayProfileByPubkey} from "@welshman/app"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {pushToast} from "@app/util/toast"
import {makeRoomPath} from "@app/util/routes"
import {
deriveVoiceParticipants,
joinVoiceRoom,
cancelJoinVoiceRoom,
currentVoiceRoom,
voiceState,
isParticipantSpeaking,
participantKey,
type VoiceParticipant,
} from "@app/voice"
interface Props {
url: string
h: string
replaceState?: boolean
}
const {url, h, replaceState = false}: Props = $props()
const participants = deriveVoiceParticipants(url, h)
const isActive = $derived(
$voiceState === "connected" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
)
const isJoining = $derived(
$voiceState === "joining" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
)
const handleClick = async () => {
if (isActive) return
if (isJoining) {
cancelJoinVoiceRoom()
return
}
try {
await joinVoiceRoom(url, h)
} catch (e) {
console.error("Failed to join voice room", e)
pushToast({theme: "error", message: "Failed to join voice room"})
}
}
$effect(() => {
for (const p of $participants) {
if (p.pubkey) loadProfile(p.pubkey)
}
})
</script>
<SecondaryNavItem
href={makeRoomPath(url, h)}
{replaceState}
onclick={handleClick}
class={cx("!items-start", isActive && "!bg-base-100 !text-base-content")}>
<div class="flex w-full min-w-0 flex-col gap-2">
<div class="flex gap-2 items-center">
{#if isJoining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<RoomImage {url} {h} size={4} />
{/if}
<RoomName {url} {h} />
</div>
{#if $participants.length > 0}
{#each $participants as p (participantKey(p as VoiceParticipant))}
<div class="flex items-center gap-2 ml-6">
<div
class={cx(
"inline-flex shrink-0 items-center justify-center rounded-full transition-shadow",
isActive && $isParticipantSpeaking(p) && "ring-2 ring-success",
)}>
<ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" />
</div>
<span class="ellipsize text-xs opacity-70">
{p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"}
</span>
</div>
{/each}
{/if}
</div>
</SecondaryNavItem>
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import {fly} from "svelte/transition"
import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
import Videocamera from "@assets/icons/videocamera.svg?dataurl"
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
import Monitor from "@assets/icons/monitor.svg?dataurl"
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {displayRoom} from "@app/core/state"
import {
currentVoiceSession,
currentVoiceRoom,
voiceState,
leaveVoiceRoom,
toggleMute,
toggleCamera,
toggleScreenShare,
rejoinVoiceRoom,
cancelJoinVoiceRoom,
} from "@app/voice"
const roomName = $derived(
$currentVoiceRoom ? displayRoom($currentVoiceRoom.url, $currentVoiceRoom.h) : "",
)
const spaceName = $derived($currentVoiceRoom ? displayRelayUrl($currentVoiceRoom.url) : "")
</script>
{#if $currentVoiceRoom}
<div
in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}}
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex flex-col gap-0.5">
{#if $voiceState === "joining"}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === "connected"}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
{/if}
<span class="ellipsize text-xs opacity-70">
{roomName} / {spaceName}
</span>
</div>
<div class="flex items-center gap-1">
{#if $voiceState === "joining"}
<span class="loading loading-spinner loading-sm"></span>
<Button
data-tip="Cancel"
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={4} />
</Button>
{:else if $voiceState === "connected" && $currentVoiceSession}
<Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
? 'btn-error'
: 'btn-ghost'}"
onclick={toggleMute}>
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
</Button>
<Button
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.cameraOn
? 'btn-ghost'
: 'btn-error'}"
onclick={toggleCamera}>
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
</Button>
<Button
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.screenShareOn
? 'btn-ghost'
: 'btn-error'}"
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4} />
</Button>
<Button
data-tip="Leave room"
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
onclick={leaveVoiceRoom}>
<Icon icon={PhoneRounded} size={4} />
</Button>
{:else}
<Button
data-tip="Join Voice"
class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
onclick={rejoinVoiceRoom}>
<Icon icon={PhoneCallingRounded} size={4} />
</Button>
{/if}
</div>
</div>
{/if}
+3 -3
View File
@@ -4,7 +4,7 @@ import {
uniq,
int,
YEAR,
DAY,
WEEK,
insertAt,
sortBy,
now,
@@ -47,7 +47,7 @@ export const makeFeed = ({
onForwardExhausted?: () => void
at?: number
}) => {
const interval = int(DAY)
const interval = int(WEEK)
const controller = new AbortController()
const events = writable<TrustedEvent[]>([])
@@ -191,7 +191,7 @@ export const makeCalendarFeed = ({
element: HTMLElement
onExhausted?: () => void
}) => {
const interval = int(5, DAY)
const interval = int(5, WEEK)
const controller = new AbortController()
let exhaustedScrollers = 0
+101 -22
View File
@@ -14,6 +14,7 @@ import {
uniqBy,
sortBy,
append,
reject,
sort,
uniq,
indexBy,
@@ -124,6 +125,7 @@ import type {
RelayProfile,
PublishedList,
PublishedRoomMeta,
RoomMeta,
List,
Filter,
} from "@welshman/util"
@@ -145,6 +147,7 @@ import {
displayProfileByPubkey,
getProfile,
} from "@welshman/app"
import {checkRelayHasLivekit} from "$lib/livekit"
import {readFeed} from "@lib/feeds"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
@@ -457,7 +460,7 @@ export const splitChatId = (id: string) => getChatPubkeys(id.split(","))
export const chatsById = call(() => {
const chatsById = new Map<string, Chat>()
const chatsByPubkey = new Map<string, Chat[]>()
const chatsByPubkey = new Map<string, string[]>()
const addSearchText = (chat: Override<Chat, {search_text?: string}>) => {
chat.search_text =
@@ -469,6 +472,12 @@ export const chatsById = call(() => {
}
return readable(chatsById, set => {
const indexChatByPubkeys = (chat: Chat) => {
for (const pubkey of chat.pubkeys) {
chatsByPubkey.set(pubkey, uniq(append(chat.id, chatsByPubkey.get(pubkey) || [])))
}
}
const addEvents = (events: TrustedEvent[]) => {
let dirty = false
for (const event of events) {
@@ -484,21 +493,19 @@ export const chatsById = call(() => {
const updatedChat = addSearchText({id, pubkeys, messages, last_activity})
chatsById.set(id, updatedChat)
for (const pubkey of pubkeys) {
const pubkeyChats = chatsByPubkey.get(pubkey) || []
const uniqueChats = uniqBy(chat => chat.id, append(updatedChat, pubkeyChats))
chatsByPubkey.set(pubkey, uniqueChats)
}
indexChatByPubkeys(updatedChat)
dirty = true
}
if (event.kind === PROFILE) {
for (const chat of chatsByPubkey.get(event.pubkey) || []) {
addSearchText(chat)
dirty = true
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
const chat = chatsById.get(chatId)
if (chat) {
addSearchText(chat)
dirty = true
}
}
}
}
@@ -508,10 +515,36 @@ export const chatsById = call(() => {
}
}
addEvents(repository.query([{kinds: [...DM_KINDS, PROFILE]}]))
const removeEvents = (removed: Set<string>) => {
let dirty = false
for (const id of removed) {
const event = repository.getEvent(id)
if (event && DM_KINDS.includes(event.kind)) {
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
const chat = chatsById.get(chatId)
if (chat) {
chat.messages = reject(spec({id: event.id}), chat.messages)
dirty = true
}
}
}
}
if (dirty) {
set(chatsById)
}
}
addEvents(repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
const unsubscribers = [
on(repository, "update", ({added}: RepositoryUpdate) => addEvents(added)),
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
addEvents(added)
removeEvents(removed)
}),
]
return () => unsubscribers.forEach(call)
@@ -536,17 +569,25 @@ export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => {
// Rooms
export enum RoomType {
Text = "text",
Voice = "voice",
}
export type Room = PublishedRoomMeta & {
id: string
url: string
}
export const getRoomType = (room: RoomMeta): RoomType =>
room.livekit ? RoomType.Voice : RoomType.Text
export const makeRoomId = (url: string, h: string) => `${url}'${h}`
export const splitRoomId = (id: string) => id.split("'")
export const hasNip29 = (relay?: RelayProfile) =>
relay?.supported_nips?.map?.(String)?.includes?.("29")
Boolean(relay?.supported_nips?.map?.(String)?.includes?.("29"))
export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({
tracker,
@@ -632,6 +673,30 @@ export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
export const deriveVoiceRooms = (url: string) =>
derived(roomsById, $roomsById => {
const set = new Set<string>()
for (const room of $roomsById.values()) {
if (room.url === url && room.livekit) {
set.add(room.h)
}
}
return set
})
export const deriveOtherVoiceRooms = (url: string) =>
derived([deriveVoiceRooms(url), deriveUserRooms(url)], ([$roomsWithLivekit, $userRooms]) => {
const rooms: string[] = []
for (const h of $roomsWithLivekit) {
if (!$userRooms.includes(h)) {
rooms.push(h)
}
}
return sortBy(roomComparator(url), uniq(rooms))
})
// User space/room lists
export const groupListsByPubkey = deriveItemsByKey({
@@ -721,17 +786,20 @@ export const deriveUserRooms = (url: string) =>
})
export const deriveOtherRooms = (url: string) =>
derived([deriveUserRooms(url), roomsByUrl], ([$userRooms, $roomsByUrl]) => {
const rooms: string[] = []
derived(
[deriveUserRooms(url), deriveVoiceRooms(url), roomsByUrl],
([$userRooms, voiceRooms, $roomsByUrl]) => {
const rooms: string[] = []
for (const {h} of $roomsByUrl.get(url) || []) {
if (!$userRooms.includes(h)) {
rooms.push(h)
for (const {h} of $roomsByUrl.get(url) || []) {
if (!$userRooms.includes(h) && !voiceRooms.has(h)) {
rooms.push(h)
}
}
}
return sortBy(roomComparator(url), uniq(rooms))
})
return sortBy(roomComparator(url), uniq(rooms))
},
)
// Space/room memberships
@@ -1134,6 +1202,12 @@ export const deriveSupportedMethods = simpleCache(([url]: [string]) => {
})
})
export const deriveHasLivekit = simpleCache(([url]: [string]) =>
readable<boolean | undefined>(undefined, set => {
checkRelayHasLivekit(url).then(has => set(has))
}),
)
export const deriveTimeout = (timeout: number) => {
const store = writable<boolean>(false)
@@ -1193,3 +1267,8 @@ export const shouldNotify = (url: string, h?: string) => getShouldNotify(getSett
export const deriveShouldNotify = (url: string, h?: string) =>
derived(userSettingsValues, $settings => getShouldNotify($settings, url, h))
// Whatever who cares
export const hasNip50 = (relay?: RelayProfile) =>
Boolean(relay?.supported_nips?.map?.(String)?.includes?.("50"))
+13 -19
View File
@@ -1,6 +1,5 @@
import {call} from "@welshman/lib"
import {Preferences} from "@capacitor/preferences"
import {Filesystem, Directory} from "@capacitor/filesystem"
import {IDB} from "@lib/indexeddb"
export const kv = call(() => {
@@ -31,22 +30,17 @@ export const kv = call(() => {
return {get, set, clear}
})
export const db = new IDB({name: "flotilla-9gl", version: 1})
// Migration - we used to use capacitor's filesystem for storage, clear it out since we're
// going back to indexeddb
call(async () => {
const res = await Filesystem.readdir({
path: "",
directory: Directory.Data,
})
await Promise.all(
res.files.map(file =>
Filesystem.deleteFile({
path: file.name,
directory: Directory.Data,
}),
),
)
export const db = new IDB({
name: "flotilla-9gl",
version: 1,
stores: [
{name: "events", keyPath: "id"},
{name: "tracker", keyPath: "id"},
{name: "relays", keyPath: "url"},
{name: "relayStats", keyPath: "url"},
{name: "handles", keyPath: "nip05"},
{name: "zappers", keyPath: "lnurl"},
{name: "plaintext", keyPath: "key"},
{name: "wrapManager", keyPath: "id"},
],
})
+7
View File
@@ -55,6 +55,7 @@ import {
loadFeedsForPubkey,
} from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands"
import {LIVEKIT_PARTICIPANTS} from "@app/voice"
// Utils
@@ -316,6 +317,12 @@ const syncSpace = (url: string, rooms: string[]) => {
})
}
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [LIVEKIT_PARTICIPANTS]}],
})
return () => controller.abort()
}
+1
View File
@@ -8,6 +8,7 @@ export const setupHistory = () =>
if ($page.params.relay) {
lastPageBySpaceUrl.set($page.params.relay, $page.url.pathname)
}
if ($page.params.chat) {
lastChatUrl = $page.url.pathname
}
+33 -3
View File
@@ -1,5 +1,5 @@
import type {Component} from "svelte"
import {writable} from "svelte/store"
import {get, writable} from "svelte/store"
import {randomId, always, assoc, Emitter} from "@welshman/lib"
import {deriveDeduplicated} from "@welshman/store"
import {goto} from "$app/navigation"
@@ -7,6 +7,7 @@ import {page} from "$app/stores"
export type ModalOptions = {
drawer?: boolean
nested?: boolean
noEscape?: boolean
fullscreen?: boolean
replaceState?: boolean
@@ -24,8 +25,18 @@ export const emitter = new Emitter()
export const modals = writable<Record<string, Modal>>({})
const getIdsFromHash = (hash: string) => hash.slice(1).split(",").filter(Boolean)
export const modalStack = deriveDeduplicated([page, modals], ([$page, $modals]) => {
return getIdsFromHash($page.url.hash)
.map(id => $modals[id])
.filter(Boolean)
})
export const modal = deriveDeduplicated([page, modals], ([$page, $modals]) => {
return $modals[$page.url.hash.slice(1)]
const ids = getIdsFromHash($page.url.hash)
return $modals[ids.at(-1) || ""]
})
export const pushModal = (
@@ -35,10 +46,12 @@ export const pushModal = (
) => {
const id = randomId()
const path = options.path || ""
const existingIds = getIdsFromHash(get(page).url.hash)
const ids = options.nested ? [...existingIds, id] : [id]
modals.update(assoc(id, {id, component, props, options}))
goto(path + "#" + id, {replaceState: options.replaceState})
goto(path + "#" + ids.join(","), {replaceState: options.replaceState})
return id
}
@@ -49,7 +62,24 @@ export const pushDrawer = (
options: ModalOptions = {},
) => pushModal(component, props, {...options, drawer: true})
export const popModal = () => {
const url = get(page).url
const ids = getIdsFromHash(url.hash)
if (ids.length === 0) {
return
}
const next = ids.slice(0, -1).join(",")
const hash = next ? `#${next}` : ""
goto(url.pathname + url.search + hash, {replaceState: true})
}
export const clearModals = () => {
const url = get(page).url
goto(url.pathname + url.search, {replaceState: true})
modals.update(always({}))
emitter.emit("close")
}
+4 -6
View File
@@ -180,7 +180,6 @@ export const allNotifications = derived(
for (const url of getSpaceUrlsFromGroupList($userGroupList)) {
const spacePath = makeSpacePath(url)
const spacePathMobile = spacePath + ":mobile"
const eventsById = eventsByIdByUrl.get(url) || new Map()
const latestEvent = first(sortEventsDesc(eventsById.values()))
@@ -194,7 +193,6 @@ export const allNotifications = derived(
const latestEvent = find(e => e.tags.some(spec(["h", h])), eventsById.values())
if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePathMobile)
paths.add(spacePath)
paths.add(roomPath)
}
@@ -203,7 +201,6 @@ export const allNotifications = derived(
const messagesPath = makeSpaceChatPath(url)
if (hasNotification(messagesPath, first(eventsById.values()))) {
paths.add(spacePathMobile)
paths.add(spacePath)
paths.add(messagesPath)
}
@@ -311,14 +308,15 @@ class CapacitorNotifications implements IPushAdapter {
}
let {token} = notificationState.get()
let error = "failed to retrieve token"
if (!token) {
const listeners = [
PushNotifications.addListener("registration", ({value}: Token) => {
token = value
}),
PushNotifications.addListener("registrationError", (error: RegistrationError) => {
console.error(error)
PushNotifications.addListener("registrationError", (err: RegistrationError) => {
error = err.error
}),
]
@@ -334,7 +332,7 @@ class CapacitorNotifications implements IPushAdapter {
notificationState.update(assoc("token", token))
}
return token ? "granted" : "denied"
return token ? status.receive : error
}
async _syncServer(signal: AbortSignal) {
+5 -5
View File
@@ -1,4 +1,5 @@
import type {Page} from "@sveltejs/kit"
import theme from "tailwindcss/defaultTheme"
import {get} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
@@ -6,7 +7,7 @@ import {page} from "$app/stores"
import {nthEq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {tracker, loadRelay} from "@welshman/app"
import {tracker} from "@welshman/app"
import {identity} from "@welshman/lib"
import {
getTagValue,
@@ -23,7 +24,6 @@ import {
decodeRelay,
encodeRelay,
userSpaceUrls,
hasNip29,
DM_KINDS,
ROOM,
} from "@app/core/state"
@@ -47,12 +47,12 @@ export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) =>
export const goToSpace = async (url: string) => {
const prevPath = lastPageBySpaceUrl.get(encodeRelay(url))
if (prevPath) {
if (prevPath && prevPath !== makeSpacePath(url)) {
goto(prevPath)
} else if (hasNip29(await loadRelay(url))) {
} else if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) {
goto(makeSpacePath(url, "recent"))
} else {
goto(makeSpacePath(url, "chat"))
goto(makeSpacePath(url))
}
}
+254 -193
View File
@@ -45,9 +45,8 @@ import {
wrapManager,
onRelay,
} from "@welshman/app"
import {isMobile} from "@lib/html"
import type {IDBTable} from "@lib/indexeddb"
import {MESSAGE_KINDS, DM_KINDS} from "@app/core/state"
import type {Unsubscriber} from "svelte/store"
import {db} from "@app/core/storage"
const kinds = {
meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS],
@@ -62,204 +61,266 @@ const kinds = {
ROOM_REMOVE_MEMBER,
ROOM_CREATE_PERMISSION,
],
content: [...MESSAGE_KINDS, ...DM_KINDS],
}
const rankEvent = (event: TrustedEvent) => {
if (kinds.meta.includes(event.kind)) return 9
if (kinds.alert.includes(event.kind)) return 8
if (kinds.space.includes(event.kind)) return 7
if (kinds.room.includes(event.kind)) return 6
if (!isMobile && kinds.content.includes(event.kind)) return 5
return 0
}
const eventsAdapter = {
name: "events",
keyPath: "id",
init: async (table: IDBTable<TrustedEvent>) => {
const initialEvents = await table.getAll()
// Mark events verified to avoid re-verification of signatures
for (const event of initialEvents) {
event[verifiedSymbol] = true
}
repository.load(initialEvents)
return on(
repository,
"update",
batch(3000, async (updates: RepositoryUpdate[]) => {
const add: TrustedEvent[] = []
const remove = new Set<string>()
for (const update of updates) {
for (const event of update.added) {
if (rankEvent(event) > 0) {
add.push(event)
remove.delete(event.id)
}
}
for (const id of update.removed) {
remove.add(id)
}
}
if (add.length > 0) {
await table.bulkPut(add)
}
if (remove.size > 0) {
await table.bulkDelete(remove)
}
}),
)
},
}
const shouldPersistEvent = (event: TrustedEvent) =>
kinds.meta.includes(event.kind) ||
kinds.alert.includes(event.kind) ||
kinds.space.includes(event.kind) ||
kinds.room.includes(event.kind)
type TrackerItem = {id: string; relays: string[]}
const trackerAdapter = {
name: "tracker",
keyPath: "id",
init: async (table: IDBTable<TrackerItem>) => {
const relaysById = new Map<string, Set<string>>()
for (const {id, relays} of await table.getAll()) {
relaysById.set(id, new Set(relays))
}
tracker.load(relaysById)
const _onAdd = async (ids: Iterable<string>) => {
const items: TrackerItem[] = []
for (const id of ids) {
const event = repository.getEvent(id)
if (!event || rankEvent(event) === 0) continue
const relays = Array.from(tracker.getRelays(id))
if (relays.length === 0) continue
items.push({id, relays})
}
await table.bulkPut(items)
}
const _onRemove = async (ids: Iterable<string>) => {
await table.bulkDelete(Array.from(ids))
}
const onAdd = batch(3000, _onAdd)
const onRemove = batch(3000, _onRemove)
const onLoad = () => _onAdd(tracker.relaysById.keys())
const onClear = () => _onRemove(tracker.relaysById.keys())
tracker.on("add", onAdd)
tracker.on("remove", onRemove)
tracker.on("load", onLoad)
tracker.on("clear", onClear)
return () => {
tracker.off("add", onAdd)
tracker.off("remove", onRemove)
tracker.off("load", onLoad)
tracker.off("clear", onClear)
}
},
}
const relaysAdapter = {
name: "relays",
keyPath: "url",
init: async (table: IDBTable<RelayProfile>) => {
relaysByUrl.set(indexBy(r => r.url, await table.getAll()))
return onRelay(batch(1000, table.bulkPut))
},
}
const relayStatsAdapter = {
name: "relayStats",
keyPath: "url",
init: async (table: IDBTable<RelayStats>) => {
relayStatsByUrl.set(indexBy(r => r.url, await table.getAll()))
return onRelayStats(batch(1000, table.bulkPut))
},
}
const handlesAdapter = {
name: "handles",
keyPath: "nip05",
init: async (table: IDBTable<Handle>) => {
handlesByNip05.set(indexBy(r => r.nip05, await table.getAll()))
return onHandle(batch(1000, table.bulkPut))
},
}
const zappersAdapter = {
name: "zappers",
keyPath: "lnurl",
init: async (table: IDBTable<Zapper>) => {
zappersByLnurl.set(indexBy(z => z.lnurl, await table.getAll()))
return onZapper(batch(3000, table.bulkPut))
},
}
type PlaintextItem = {key: string; value: string}
const plaintextAdapter = {
name: "plaintext",
keyPath: "key",
init: async (table: IDBTable<PlaintextItem>) => {
const initialRecords = await table.getAll()
const loadCriticalEvents = async () => {
const table = db.table<TrustedEvent>("events")
const initialEvents = await table.getAll()
const keep: TrustedEvent[] = []
const drop: string[] = []
plaintext.set(fromPairs(initialRecords.map(({key, value}) => [key, value])))
return throttled(3000, plaintext).subscribe($plaintext => {
table.bulkPut(Object.entries($plaintext).map(([key, value]) => ({key, value})))
})
},
}
const wrapManagerAdapter = {
name: "wrapManager",
keyPath: "id",
init: async (table: IDBTable<WrapItem>) => {
wrapManager.load(await table.getAll())
const addOne = batch(3000, table.bulkPut)
const removeOne = throttle(3000, table.bulkDelete)
wrapManager.on("add", addOne)
wrapManager.on("remove", removeOne)
return () => {
wrapManager.off("add", addOne)
wrapManager.off("remove", removeOne)
for (const event of initialEvents) {
if (shouldPersistEvent(event)) {
event[verifiedSymbol] = true
keep.push(event)
} else {
drop.push(event.id)
}
},
}
repository.load(keep)
if (drop.length > 0) {
void table.bulkDelete(drop)
}
}
export const adapters = [
eventsAdapter,
trackerAdapter,
relaysAdapter,
relayStatsAdapter,
handlesAdapter,
zappersAdapter,
plaintextAdapter,
wrapManagerAdapter,
]
const syncEvents = () => {
const table = db.table<TrustedEvent>("events")
return on(
repository,
"update",
batch(3000, async (updates: RepositoryUpdate[]) => {
const add: TrustedEvent[] = []
const remove = new Set<string>()
for (const update of updates) {
for (const event of update.added) {
if (shouldPersistEvent(event)) {
add.push(event)
remove.delete(event.id)
}
}
for (const id of update.removed) {
remove.add(id)
}
}
if (add.length > 0) {
await table.bulkPut(add)
}
if (remove.size > 0) {
await table.bulkDelete(remove)
}
}),
)
}
const loadCriticalTracker = async () => {
const table = db.table<TrackerItem>("tracker")
const relaysById = new Map<string, Set<string>>()
const stale: string[] = []
for (const {id, relays} of await table.getAll()) {
if (!repository.getEvent(id)) {
stale.push(id)
continue
}
relaysById.set(id, new Set(relays))
}
tracker.load(relaysById)
if (stale.length > 0) {
void table.bulkDelete(stale)
}
}
const syncTracker = () => {
const table = db.table<TrackerItem>("tracker")
const _onAdd = async (ids: Iterable<string>) => {
const items: TrackerItem[] = []
for (const id of ids) {
const event = repository.getEvent(id)
if (!event || !shouldPersistEvent(event)) continue
const relays = Array.from(tracker.getRelays(id))
if (relays.length === 0) continue
items.push({id, relays})
}
await table.bulkPut(items)
}
const _onRemove = async (ids: Iterable<string>) => {
await table.bulkDelete(Array.from(ids))
}
const onAdd = batch(3000, _onAdd)
const onRemove = batch(3000, _onRemove)
const onLoad = () => _onAdd(tracker.relaysById.keys())
const onClear = () => _onRemove(tracker.relaysById.keys())
tracker.on("add", onAdd)
tracker.on("remove", onRemove)
tracker.on("load", onLoad)
tracker.on("clear", onClear)
return () => {
tracker.off("add", onAdd)
tracker.off("remove", onRemove)
tracker.off("load", onLoad)
tracker.off("clear", onClear)
}
}
const loadCriticalRelays = async () => {
const table = db.table<RelayProfile>("relays")
relaysByUrl.set(indexBy(r => r.url, await table.getAll()))
}
const syncRelays = () => onRelay(batch(1000, db.table<RelayProfile>("relays").bulkPut))
const initRelayStats = async () => {
const table = db.table<RelayStats>("relayStats")
relayStatsByUrl.set(indexBy(r => r.url, await table.getAll()))
return onRelayStats(batch(1000, table.bulkPut))
}
const initHandles = async () => {
const table = db.table<Handle>("handles")
handlesByNip05.set(indexBy(r => r.nip05, await table.getAll()))
return onHandle(batch(1000, table.bulkPut))
}
const initZappers = async () => {
const table = db.table<Zapper>("zappers")
zappersByLnurl.set(indexBy(z => z.lnurl, await table.getAll()))
return onZapper(batch(3000, table.bulkPut))
}
const initPlaintext = async () => {
const table = db.table<PlaintextItem>("plaintext")
const initialRecords = await table.getAll()
plaintext.set(fromPairs(initialRecords.map(({key, value}) => [key, value])))
return throttled(3000, plaintext).subscribe($plaintext => {
table.bulkPut(Object.entries($plaintext).map(([key, value]) => ({key, value})))
})
}
const initWrapManager = async () => {
const table = db.table<WrapItem>("wrapManager")
wrapManager.load(await table.getAll())
const addOne = batch(3000, table.bulkPut)
const removeOne = throttle(3000, table.bulkDelete)
wrapManager.on("add", addOne)
wrapManager.on("remove", removeOne)
return () => {
wrapManager.off("add", addOne)
wrapManager.off("remove", removeOne)
}
}
type StorageSync = {
unsubscribe: Unsubscriber
ready: Promise<void>
}
export const sync = (): StorageSync => {
const unsubscribers: Unsubscriber[] = []
const deferredTimers: ReturnType<typeof setTimeout>[] = []
let stopped = false
const addUnsubscriber = (unsubscriber: Unsubscriber) => {
if (stopped) {
unsubscriber()
} else {
unsubscribers.push(unsubscriber)
}
}
const scheduleDeferred = (task: () => Promise<void>) => {
const timeout = setTimeout(() => {
if (stopped) return
void task()
}, 0)
deferredTimers.push(timeout)
}
const ready = (async () => {
await db.connect()
await Promise.all([loadCriticalEvents(), loadCriticalRelays()])
await loadCriticalTracker()
addUnsubscriber(syncEvents())
addUnsubscriber(syncTracker())
addUnsubscriber(syncRelays())
scheduleDeferred(async () => {
addUnsubscriber(await initRelayStats())
})
scheduleDeferred(async () => {
addUnsubscriber(await initHandles())
})
scheduleDeferred(async () => {
addUnsubscriber(await initZappers())
})
scheduleDeferred(async () => {
addUnsubscriber(await initPlaintext())
})
scheduleDeferred(async () => {
addUnsubscriber(await initWrapManager())
})
})()
const unsubscribe = () => {
stopped = true
for (const timeout of deferredTimers) {
clearTimeout(timeout)
}
unsubscribers.forEach(unsubscriber => unsubscriber())
}
return {unsubscribe, ready}
}
+442
View File
@@ -0,0 +1,442 @@
/**
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
*/
import {
DisconnectReason,
LocalParticipant,
LocalTrackPublication,
Room,
RoomEvent,
Track,
} from "livekit-client"
import {derived, get, writable} from "svelte/store"
import {map, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
import {deriveLatestEventForUrl} from "@app/core/state"
import {pushToast} from "@app/util/toast"
export const LIVEKIT_PARTICIPANTS = 39004
export {checkRelayHasLivekit} from "$lib/livekit"
export type VoiceSession = {
url: string
h: string
room: Room
muted: boolean
cameraOn: boolean
screenShareOn: boolean
}
export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export type VoiceState = "joining" | "connected" | "disconnected"
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>("disconnected")
export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
/** Bumps when remote video is subscribed/unsubscribed so layout/video UI can react. */
export const videoCallLayoutRevision = writable(0)
/** Spotlight tile id — must match VideoCallContent `tileKey` (identity + source, not trackSid). */
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
const bumpVideoCallLayoutRevision = () => videoCallLayoutRevision.update(n => n + 1)
const addParticipant = (identity: string) => {
participantPubkeyMap.update(m => {
const next = new Map(m)
next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "")
return next
})
}
const deleteParticipant = (identity: string) => {
participantPubkeyMap.update(m => {
const next = new Map(m)
next.delete(identity)
return next
})
}
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
const pk = pubkeyFromLiveKitIdentity(identity)
return pk ? {pubkey: pk, identity} : {identity}
}
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([])
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
const fetchLivekitToken = async (
url: string,
groupId: string,
signal?: AbortSignal,
): Promise<{server_url: string; participant_token: string}> => {
const endpoint = getLivekitEndpoint(url, groupId)
const $signer = signer.get()
if (!$signer) throw new Error("No signer available")
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
const template = await makeHttpAuth(endpoint, "GET")
const signedEvent = await $signer.sign(template)
const authHeader = makeHttpAuthHeader(signedEvent)
const response = await fetch(endpoint, {
headers: {Authorization: authHeader},
signal,
})
if (!response.ok) {
const text = await response.text()
throw new Error(`Token request failed (${response.status}): ${text}`)
}
return response.json()
}
export const deriveVoiceParticipants = (url: string, h: string) =>
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
derived(
[
participantPubkeyMap,
currentVoiceRoom,
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
],
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
const inCall =
$participantPubkeyMap.size > 0 &&
$currentVoiceRoom?.url === url &&
$currentVoiceRoom?.h === h
if (inCall) {
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity)
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
} else {
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
if (!latestEvent) return []
const participants = removeUndefined(
map(
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
getTags("participant", latestEvent.tags),
),
)
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
}
},
)
const onRoomDisconnected = (reason?: DisconnectReason) => {
videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined)
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
currentVoiceSession.set(undefined)
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set("disconnected")
const message =
reason === DisconnectReason.JOIN_FAILURE
? "Could not connect to voice room. Please try again."
: "Voice connection lost."
pushToast({theme: "error", message})
}
}
const onTrackSubscribed = (track: Track) => {
if (track.kind === Track.Kind.Audio) {
const element = track.attach()
element.style.display = "none"
document.body.appendChild(element)
element.play().catch(() => {})
} else if (track.kind === Track.Kind.Video) {
bumpVideoCallLayoutRevision()
}
}
const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) {
bumpVideoCallLayoutRevision()
}
}
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
}
const playJoinSound = () => {
const audio = new Audio("/join-voice-room.mp3")
audio.play().catch(() => {})
}
const onParticipantConnected = (participant: {identity: string}) => {
addParticipant(participant.identity)
playJoinSound()
}
const onParticipantDisconnected = (participant: {identity: string}) => {
deleteParticipant(participant.identity)
}
const onLocalTrackUnpublished = (
publication: LocalTrackPublication,
participant: LocalParticipant,
) => {
if (publication.source !== Track.Source.ScreenShare) return
const session = get(currentVoiceSession)
if (!session || participant.identity !== session.room.localParticipant.identity) return
if (!session.screenShareOn) return
currentVoiceSession.set({...session, screenShareOn: false})
bumpVideoCallLayoutRevision()
}
let joinAbortController: AbortController | undefined
export const cancelJoinVoiceRoom = () => {
joinAbortController?.abort()
}
export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
cancelJoinVoiceRoom()
const session = get(currentVoiceSession)
if (session) await leaveVoiceRoom()
currentVoiceRoom.set({url, h})
voiceState.set("joining")
const controller = new AbortController()
joinAbortController = controller
const signal = controller.signal
const isActive = () => joinAbortController === controller
try {
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
if (signal.aborted) throw new AbortError()
const room = new Room({adaptiveStream: true, dynacast: true})
room.on(RoomEvent.Disconnected, onRoomDisconnected)
room.on(RoomEvent.ParticipantConnected, onParticipantConnected)
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
room.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
try {
await Promise.race([
room.connect(server_url, participant_token, {maxRetries: 0}),
whenTimeout(5_000, {
message: "Connection timed out. Please check your network and try again.",
}),
whenAborted(signal),
])
} catch (e) {
room.disconnect()
throw e
}
participantPubkeyMap.set(new Map())
addParticipant(room.localParticipant.identity)
for (const p of room.remoteParticipants.values()) {
addParticipant(p.identity)
}
let muted = false
try {
await room.localParticipant.setMicrophoneEnabled(true)
} catch (e) {
muted = true
pushToast({theme: "error", message: "Could not access microphone"})
}
currentVoiceSession.set({url, h, room, muted, cameraOn: false, screenShareOn: false})
voiceState.set("connected")
playJoinSound()
} catch (e) {
if (isActive()) voiceState.set("disconnected")
if (e instanceof AbortError) return
throw e
} finally {
if (isActive()) joinAbortController = undefined
}
}
export const leaveVoiceRoom = async () => {
const session = get(currentVoiceSession)
if (!session) return
const audio = new Audio("/leave-voice-room.mp3")
audio.play().catch(() => {})
if (session.cameraOn) {
try {
await session.room.localParticipant.setCameraEnabled(false)
} catch {
/* pass */
}
}
if (session.screenShareOn) {
try {
await session.room.localParticipant.setScreenShareEnabled(false)
} catch {
/* pass */
}
}
voiceState.set("disconnected")
videoCallLayoutRevision.set(0)
videoPrimaryTileKey.set(undefined)
currentVoiceSession.set(undefined)
session.room.disconnect()
speakingParticipants.set([])
participantPubkeyMap.set(new Map())
}
export const rejoinVoiceRoom = () => {
const target = get(currentVoiceRoom)
if (target) joinVoiceRoom(target.url, target.h)
}
export const toggleMute = async () => {
const session = get(currentVoiceSession)
if (!session) return
const muted = !session.muted
if (muted) {
// Disable and re-enable microphone to trigger permission prompt
session.room.localParticipant.setMicrophoneEnabled(false)
currentVoiceSession.set({...session, muted})
return
}
try {
await session.room.localParticipant.setMicrophoneEnabled(true)
currentVoiceSession.set({...session, muted})
} catch (e) {
pushToast({theme: "error", message: "Could not access microphone"})
}
}
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const roomHasSubscribedRemoteVisual = (room: Room): boolean => {
for (const p of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = p.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) return true
}
}
return false
}
/** True when local camera/screen share is on or any subscribed remote camera/screen track. */
export const videoCallContentActive = derived(
[currentVoiceSession, voiceState, videoCallLayoutRevision],
([$session, $state, _rev]) => {
if ($state !== "connected" || !$session) return false
if ($session.cameraOn || $session.screenShareOn) return true
return roomHasSubscribedRemoteVisual($session.room)
},
)
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
const lp = room.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) n += 1
}
}
return n
}
export const videoTileCount = derived(
[currentVoiceSession, voiceState, videoCallLayoutRevision],
([$session, $state, _rev]) => {
if ($state !== "connected" || !$session) return 0
return countLiveVisualFeeds($session)
},
)
export const toggleCamera = async () => {
const session = get(currentVoiceSession)
if (!session) return
const cameraOn = !session.cameraOn
if (!cameraOn) {
session.room.localParticipant.setCameraEnabled(false)
currentVoiceSession.set({...session, cameraOn})
bumpVideoCallLayoutRevision()
return
}
try {
await session.room.localParticipant.setCameraEnabled(true)
currentVoiceSession.set({...session, cameraOn})
bumpVideoCallLayoutRevision()
} catch (e) {
pushToast({theme: "error", message: "Could not access camera"})
}
}
export const toggleScreenShare = async () => {
const session = get(currentVoiceSession)
if (!session) return
const screenShareOn = !session.screenShareOn
if (!screenShareOn) {
session.room.localParticipant.setScreenShareEnabled(false)
currentVoiceSession.set({...session, screenShareOn})
bumpVideoCallLayoutRevision()
return
}
try {
await session.room.localParticipant.setScreenShareEnabled(true)
currentVoiceSession.set({...session, screenShareOn})
bumpVideoCallLayoutRevision()
} catch (e) {
pushToast({theme: "error", message: "Could not start screen sharing"})
}
}
+8
View File
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 8C7 5.23858 9.23858 3 12 3C14.7614 3 17 5.23858 17 8V11C17 13.7614 14.7614 16 12 16C9.23858 16 7 13.7614 7 11V8Z" stroke="#000000" stroke-width="1.5"/>
<path d="M13 8L17 8" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
<path d="M13 11L17 11" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
<path d="M20 10V11C20 15.4183 16.4183 19 12 19C7.58172 19 4 15.4183 4 11V10" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
<path d="M12 19V22" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
<path d="M22 2L2 22" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 734 B

-36
View File
@@ -1,36 +0,0 @@
<script lang="ts">
import {slide} from "@lib/transition"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
interface Props {
title?: import("svelte").Snippet
description?: import("svelte").Snippet
children?: import("svelte").Snippet
[key: string]: any
}
const {...props}: Props = $props()
const toggle = () => {
isOpen = !isOpen
}
let isOpen = $state(false)
</script>
<div class="relative flex flex-col gap-4 {props.class}">
<button
type="button"
class="absolute right-8 top-8 h-4 w-4 cursor-pointer transition-all"
class:rotate-90={!isOpen}
onclick={toggle}>
<Icon icon={AltArrowDown} />
</button>
{@render props.title?.()}
{@render props.description?.()}
{#if isOpen}
<div transition:slide>
{@render props.children?.()}
</div>
{/if}
</div>
+9
View File
@@ -3,6 +3,10 @@
import cx from "classnames"
import {noop} from "@welshman/lib"
import {fade, fly} from "@lib/transition"
import Close from "@assets/icons/close.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {clearModals} from "@app/util/modal"
type Props = {
onClose?: any
@@ -43,6 +47,11 @@
</button>
<div class={wrapperClass}>
<div class={innerClass} transition:fly>
<Button
class="absolute -top-4 right-3 btn btn-circle btn-neutral btn-sm"
onclick={clearModals}>
<Icon icon={Close} size={6} />
</Button>
<children.component {...children.props} />
</div>
</div>
+6 -18
View File
@@ -3,16 +3,16 @@
import {between, throttle} from "@welshman/lib"
import {isMobile} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import Dialog from "@lib/components/Dialog.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import IconPicker from "@app/components/IconPicker.svelte"
import IconPickerDialog from "@app/components/IconPickerDialog.svelte"
import IconPickerModal from "@app/components/IconPickerModal.svelte"
import IconPickerPopover from "@app/components/IconPickerPopover.svelte"
import {pushModal, popModal} from "@app/util/modal"
const {...props} = $props()
const open = () => {
if (isMobile) {
showIconPicker = true
pushModal(IconPickerModal, {onSelect: onClick}, {nested: true})
} else {
popover?.show()
}
@@ -20,7 +20,7 @@
const close = () => {
if (isMobile) {
showIconPicker = false
popModal()
} else {
popover?.hide()
}
@@ -41,7 +41,6 @@
}
})
let showIconPicker = $state(false)
let popover: Instance | undefined = $state()
</script>
@@ -49,21 +48,10 @@
<Tippy
bind:popover
component={IconPickerDialog}
component={IconPickerPopover}
props={{onSelect: onClick}}
params={{trigger: "manual", interactive: true, placement: "top-end"}}>
<Button onclick={open} class={props.class}>
{@render props.children?.()}
</Button>
</Tippy>
{#if showIconPicker}
<Dialog
onClose={close}
children={{
component: IconPicker,
props: {
onSelect: onClick,
},
}} />
{/if}
+4 -20
View File
@@ -1,32 +1,16 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {displayRelayUrl} from "@welshman/util"
import {page} from "$app/stores"
import {decodeRelay} from "@app/core/state"
interface Props {
icon?: Snippet
title?: Snippet
action?: Snippet
[key: string]: any
children?: Snippet
class?: string
}
const {...props}: Props = $props()
const {children, ...props}: Props = $props()
</script>
<div data-component="PageBar" class="cw top-sai fixed z-nav p-2 {props.class}">
<div class="rounded-xl bg-base-100 p-4 shadow-md h-20 md:h-12 flex flex-col justify-center">
<div class="flex items-center justify-between gap-4">
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
{@render props.icon?.()}
{@render props.title?.()}
</div>
{@render props.action?.()}
</div>
{#if $page.params.relay}
<div class="text-xs text-primary md:hidden">
{displayRelayUrl(decodeRelay($page.params.relay))}
</div>
{/if}
{@render children?.()}
</div>
</div>
+9 -4
View File
@@ -5,14 +5,19 @@
interface Props {
element?: Element
children?: Snippet
/** Desktop voice: chat occupies the right half in split view. */
contentFrame?: "default" | "split-right"
[key: string]: any
}
let {children, element = $bindable(), ...props}: Props = $props()
let {children, element = $bindable(), contentFrame = "default", ...props}: Props = $props()
const className = cx(
props.class,
"scroll-container cw cb ct fixed z-feature overflow-y-auto overflow-x-hidden",
const className = $derived(
cx(
props.class,
"scroll-container cb ct fixed z-feature overflow-y-auto overflow-x-hidden",
contentFrame === "split-right" ? "cw-split-chat" : "cw",
),
)
</script>
+10 -3
View File
@@ -1,12 +1,19 @@
<script lang="ts">
import cx from "classnames"
import type {Snippet} from "svelte"
interface Props {
children?: import("svelte").Snippet
class?: string
children?: Snippet
}
const {children}: Props = $props()
const {children, ...props}: Props = $props()
</script>
<div
class="ml-sai mt-sai mb-sai hidden max-h-screen w-60 flex-shrink-0 flex-col gap-1 bg-base-300 md:flex">
class={cx(
"ml-sai mt-sai mb-sai max-h-screen w-60 flex-shrink-0 flex-col gap-1 bg-base-300 z-nav hidden md:flex",
props.class,
)}>
{@render children?.()}
</div>
+2 -2
View File
@@ -34,7 +34,7 @@
{href}
{...restProps}
data-sveltekit-replacestate={replaceState}
class="{restProps.class} relative flex items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
class="{restProps.class} relative flex flex-shrink-0 items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
class:text-base-content={active}
class:bg-base-100={active}>
{@render children?.()}
@@ -45,7 +45,7 @@
{:else}
<button
{...restProps}
class="{restProps.class} relative flex w-full items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
class="{restProps.class} relative flex flex-shrink-0 w-full items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
class:text-base-content={active}
class:bg-base-100={active}>
{#if notification}
+12 -1
View File
@@ -115,7 +115,18 @@ export const compressFile = async (
maxHeight: 2048,
convertTypes: ["image/png"],
...options,
success: result => resolve(result as File),
success: result => {
// canvas.toBlob() returns a Blob, not a File. Capacitor's fetch interceptor
// checks instanceof File to handle binary uploads correctly, so we must ensure
// we always have a real File, not just a Blob with name/lastModified tacked on.
const f =
result instanceof File
? result
: new File([result], (result as any).name || (file as any).name || "upload", {
type: result.type,
})
resolve(f)
},
error: e => {
// Non-images break compressor, return the original file
if (e.toString().includes("File or Blob")) {
+6 -20
View File
@@ -1,39 +1,32 @@
import {openDB, deleteDB} from "idb"
import type {IDBPDatabase} from "idb"
import type {Unsubscriber} from "svelte/store"
import {call} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
export type IDBAdapter = {
export type IDBStore = {
name: string
keyPath: string
init: (table: IDBTable<any>) => Promise<Unsubscriber>
}
export type IDBAdapters = IDBAdapter[]
export type IDBOptions = {
name: string
version: number
stores: IDBStore[]
}
export class IDB {
adapters: IDBAdapters = []
connection: Maybe<Promise<IDBPDatabase>>
unsubscribers: Maybe<Unsubscriber[]>
failedToConnect = false
constructor(readonly options: IDBOptions) {}
async connect() {
if (!this.failedToConnect && !this.connection) {
const {name, version} = this.options
const adapters = this.adapters
const {name, version, stores} = this.options
try {
this.connection = openDB(name, version, {
upgrade(idbDb: IDBPDatabase) {
const names = new Set(adapters.map(a => a.name))
const names = new Set(stores.map(store => store.name))
for (const table of idbDb.objectStoreNames) {
if (!names.has(table)) {
@@ -41,7 +34,7 @@ export class IDB {
}
}
for (const {name, keyPath} of adapters) {
for (const {name, keyPath} of stores) {
try {
idbDb.createObjectStore(name, {keyPath})
} catch (e) {
@@ -52,10 +45,6 @@ export class IDB {
blocked() {},
blocking() {},
})
this.unsubscribers = await Promise.all(
adapters.map(({name, init}) => init(this.table(name))),
)
} catch (e) {
console.error("Failed to connect to indexeddb", e)
this.failedToConnect = true
@@ -72,7 +61,7 @@ export class IDB {
if (!connection) return []
const tx = connection.transaction(table, "readwrite")
const tx = connection.transaction(table, "readonly")
const store = tx.objectStore(table)
const result = await store.getAll()
@@ -115,9 +104,6 @@ export class IDB {
}
close = () => {
this.unsubscribers?.forEach(call)
this.unsubscribers = undefined
this.connection?.then(c => c.close())
this.connection = undefined
}
+23
View File
@@ -0,0 +1,23 @@
const toHttpUrl = (url: string) =>
url
.replace(/^wss:\/\//, "https://")
.replace(/^ws:\/\//, "http://")
.replace(/\/$/, "")
const livekitEndpoint = (url: string, groupId?: string) => {
const base = `${toHttpUrl(url)}/.well-known/nip29/livekit`
return groupId ? `${base}/${groupId}` : base
}
export const checkRelayHasLivekit = async (url: string): Promise<boolean> => {
const endpoint = livekitEndpoint(url)
try {
const response = await fetch(endpoint)
return response.status === 204
} catch {
return false
}
}
export const getLivekitEndpoint = (url: string, groupId: string) => livekitEndpoint(url, groupId)
+30
View File
@@ -19,6 +19,36 @@ export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1)
export const errorMessage = (err: unknown) => String(err).replace(/^.*Error: /, "")
export class AbortError extends Error {
constructor() {
super("Aborted")
this.name = "AbortError"
}
}
export class TimeoutError extends Error {
constructor(message = "Timed out") {
super(message)
this.name = "TimeoutError"
}
}
/** Returns a promise that rejects with AbortError when signal aborts. Use with Promise.race. */
export const whenAborted = (signal?: AbortSignal) => {
if (!signal) return new Promise<never>(() => {})
return new Promise<never>((_, reject) => {
const onAborted = () => reject(new AbortError())
if (signal.aborted) onAborted()
else signal.addEventListener("abort", onAborted, {once: true})
})
}
/** Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. */
export const whenTimeout = (ms: number, opts: {message?: string} = {}) => {
return new Promise<never>((_, reject) => setTimeout(() => reject(new TimeoutError()), ms))
}
export const buildUrl = (base: string | URL, ...pathname: string[]) => {
const url = new URL(base)
+5 -4
View File
@@ -126,11 +126,12 @@
}),
])
// Set up our storage adapters
db.adapters = storage.adapters
const storageSync = storage.sync()
// Wait until data storage is initialized before syncing other stuff
await db.connect()
unsubscribers.push(storageSync.unsubscribe)
// Wait for critical storage data only
await storageSync.ready
// Close the database connection on reload
unsubscribers.push(() => db.close())
+2 -2
View File
@@ -6,7 +6,7 @@
import Wallet from "@assets/icons/wallet.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import Moon from "@assets/icons/moon.svg?dataurl"
import InfoSquare from "@assets/icons/info-square.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
import GalleryMinimalistic from "@assets/icons/gallery-minimalistic.svg?dataurl"
import Shield from "@assets/icons/shield-minimalistic.svg?dataurl"
@@ -73,7 +73,7 @@
</div>
<div in:fly|local={{delay: 350}}>
<SecondaryNavItem href="/settings/about">
<Icon icon={InfoSquare} /> About
<Icon icon={Code2} /> About
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 400}}>
+7 -2
View File
@@ -3,8 +3,10 @@
import {sleep} from "@welshman/lib"
import {Capacitor} from "@capacitor/core"
import {Badge} from "@capawesome/capacitor-badge"
import Bell from "@assets/icons/bell.svg?dataurl"
import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {pushToast} from "@app/util/toast"
import {Push, clearBadges} from "@app/util/notifications"
@@ -32,7 +34,7 @@
return pushToast({
theme: "error",
message: "Failed to request notification permissions.",
message: `Failed to request notification permissions (${permissions}).`,
})
}
}
@@ -51,7 +53,10 @@
<form class="content column gap-4" {onsubmit}>
<div class="card2 bg-alt col-4 shadow-md">
<strong class="text-lg">Alert Settings</strong>
<strong class="flex items-center gap-3 text-lg">
<Icon icon={Bell} />
Alert Settings
</strong>
{#await Badge.isSupported()}
<!-- pass -->
{:then { isSupported }}
+6 -1
View File
@@ -10,10 +10,12 @@
} from "@welshman/util"
import {Router} from "@welshman/router"
import {userMuteList, tagPubkey, publishThunk, userBlossomServerList} from "@welshman/app"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import InputList from "@lib/components/InputList.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {pushToast} from "@app/util/toast"
@@ -49,7 +51,10 @@
<form class="content column gap-4" {onsubmit}>
<div class="card2 bg-alt col-4 shadow-md">
<strong class="text-lg">Content Settings</strong>
<strong class="flex items-center gap-3 text-lg">
<Icon icon={NotesMinimalistic} />
Content Settings
</strong>
<FieldInline>
{#snippet label()}
<p>Hide sensitive content?</p>
+6 -1
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import ShieldMinimalistic from "@assets/icons/shield-minimalistic.svg?dataurl"
import {preventDefault} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {pushToast} from "@app/util/toast"
import {PLATFORM_NAME, RelayAuthMode, userSettingsValues} from "@app/core/state"
@@ -24,7 +26,10 @@
<form class="content column gap-4" {onsubmit}>
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
<strong class="text-lg">Privacy Settings</strong>
<strong class="flex items-center gap-3 text-lg">
<Icon icon={ShieldMinimalistic} />
Privacy Settings
</strong>
<div class="grid grid-cols-2 gap-2">
<p>Authenticate with unknown relays?</p>
<input
+1 -1
View File
@@ -156,7 +156,7 @@
{/if}
<SignerStatus />
{#if $session?.method === SessionMethod.Pomade}
<div class="flex gap-2 justify-end">
<div class="flex flex-col lg:flex-row gap-4 lg:gap-2 justify-end">
<Button class="btn" onclick={startPasswordReset}>
<Spinner {loading}>Update your password</Spinner>
</Button>
+159 -157
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {derived} from "svelte/store"
import {shuffle, partition, ifLet} from "@welshman/lib"
import {
pubkey,
getRelayLists,
@@ -10,182 +12,182 @@
removeBlockedRelay,
addMessagingRelay,
removeMessagingRelay,
addSearchRelay,
removeSearchRelay,
getRelay,
setWriteRelays,
setReadRelays,
setSearchRelays,
setMessagingRelays,
} from "@welshman/app"
import {RelayMode} from "@welshman/util"
import Plane from "@assets/icons/plane.svg?dataurl"
import Inbox from "@assets/icons/inbox.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import Mailbox from "@assets/icons/mailbox.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import DangerTriangle from "@assets/icons/danger-triangle.svg?dataurl"
import ForbiddenCircle from "@assets/icons/forbidden-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Collapse from "@lib/components/Collapse.svelte"
import RelayItem from "@app/components/RelayItem.svelte"
import RelayAdd from "@app/components/RelayAdd.svelte"
import RelaySettingsItem from "@app/components/RelaySettingsItem.svelte"
import type {ActionItem} from "@app/components/RelaySettingsActionItem.svelte"
import RelaySettingsActionItems from "@app/components/RelaySettingsActionItems.svelte"
import {pushModal} from "@app/util/modal"
import {hasNip50, DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/core/state"
import {discoverRelays} from "@app/core/requests"
import Globus from "@assets/icons/globus.svg?dataurl"
import Inbox from "@assets/icons/inbox.svg?dataurl"
import Mailbox from "@assets/icons/mailbox.svg?dataurl"
import ForbiddenCircle from "@assets/icons/forbidden-circle.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
const readRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Read)
const writeRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Write)
const searchRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Search)
const blockedRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Blocked)
const messagingRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Messaging)
const addReadRelays = () =>
pushModal(RelayAdd, {
relays: readRelayUrls,
addRelay: (url: string) => addRelay(url, RelayMode.Read),
})
const addWriteRelays = () =>
pushModal(RelayAdd, {
relays: writeRelayUrls,
addRelay: (url: string) => addRelay(url, RelayMode.Write),
})
const addBlockedRelays = () =>
pushModal(RelayAdd, {relays: blockedRelayUrls, addRelay: addBlockedRelay})
const addMessagingRelays = () =>
pushModal(RelayAdd, {relays: messagingRelayUrls, addRelay: addMessagingRelay})
const addReadRelay = (url: string) => addRelay(url, RelayMode.Read)
const removeReadRelay = (url: string) => removeRelay(url, RelayMode.Read)
const addWriteRelay = (url: string) => addRelay(url, RelayMode.Write)
const removeWriteRelay = (url: string) => removeRelay(url, RelayMode.Write)
const showActionItems = () => pushModal(RelaySettingsActionItems, {actionItems})
const actionItems = derived(
[readRelayUrls, writeRelayUrls, messagingRelayUrls, searchRelayUrls],
([$readRelayUrls, $writeRelayUrls, $messagingRelayUrls, $searchRelayUrls]) => {
const $actionItems: ActionItem[] = []
if ($readRelayUrls.length <= 1) {
$actionItems.push({
title: "Missing Inbox Relays",
subtitle: "Other people aren't currently able to reliably tag you in public notes.",
action: "Update",
apply: () => setReadRelays(DEFAULT_RELAYS),
})
}
if ($writeRelayUrls.length <= 1) {
$actionItems.push({
title: "Missing Outbox Relays",
subtitle: "Other people aren't currently able to reliably find your public notes.",
action: "Update",
apply: () => setWriteRelays(DEFAULT_RELAYS),
})
}
if ($messagingRelayUrls.length <= 1) {
$actionItems.push({
title: "Missing DM Relays",
subtitle: "You aren't currently able to reliably send or receive direct messages.",
action: "Update",
apply: () => setMessagingRelays(DEFAULT_MESSAGING_RELAYS),
})
}
if ($readRelayUrls.length > 8) {
$actionItems.push({
title: "Too Many Inbox Relays",
subtitle:
"You have more inbox relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
apply: () => setReadRelays(shuffle($readRelayUrls).slice(0, 5)),
})
}
if ($writeRelayUrls.length > 8) {
$actionItems.push({
title: "Too Many Outbox Relays",
subtitle:
"You have more outbox relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
apply: () => setWriteRelays(shuffle($writeRelayUrls).slice(0, 5)),
})
}
if ($messagingRelayUrls.length > 8) {
$actionItems.push({
title: "Too Many DM Relays",
subtitle:
"You have more DM relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
apply: () => setMessagingRelays(shuffle($messagingRelayUrls).slice(0, 5)),
})
}
const [okSearchRelays, badSearchRelays] = partition(
url => Boolean(ifLet(getRelay(url), hasNip50)),
$searchRelayUrls,
)
if (badSearchRelays.length > 0) {
$actionItems.push({
title: "Invalid Search Relays",
subtitle: `Some of your search relays don't support search.`,
action: "Remove Invalid",
apply: () => setSearchRelays(okSearchRelays),
})
}
return $actionItems
},
)
onMount(() => {
discoverRelays(getRelayLists())
})
</script>
<div class="content column gap-4">
<Collapse class="card2 bg-alt column gap-4 shadow-md">
{#snippet title()}
<h2 class="flex items-center gap-3 text-xl">
<Icon icon={Globus} />
Outbox Relays
</h2>
{/snippet}
{#snippet description()}
<p class="text-sm">
These relays will be advertised on your profile as places where you send your public notes.
Be sure to select relays that will accept your notes, and which will let people who follow
you read them.
</p>
{/snippet}
<div class="column gap-2">
{#each $writeRelayUrls.sort() as url (url)}
<RelayItem {url}>
<Button
class="tooltip flex items-center"
data-tip="Stop using this relay"
onclick={() => removeWriteRelay(url)}>
<Icon icon={CloseCircle} />
</Button>
</RelayItem>
{:else}
<p class="text-center text-sm">No relays found</p>
{/each}
<Button class="btn btn-primary mt-2" onclick={addWriteRelays}>
<Icon icon={AddCircle} />
Add Relay
</Button>
<div class="content">
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
<div class="flex justify-between">
<strong class="flex items-center gap-3 text-lg">
<Icon icon={Server} />
Your Relays
</strong>
{#if $actionItems.length > 0}
<Button class="btn btn-neutral btn-sm" onclick={showActionItems}>
<Icon icon={DangerTriangle} />
{$actionItems.length} Issue{$actionItems.length === 1 ? "" : "s"} Detected
</Button>
{/if}
</div>
</Collapse>
<Collapse class="card2 bg-alt column gap-4 shadow-md">
{#snippet title()}
<h2 class="flex items-center gap-3 text-xl">
<Icon icon={Inbox} />
Inbox Relays
</h2>
{/snippet}
{#snippet description()}
<p class="text-sm">
These relays will be advertised on your profile as places where other people should send
notes intended for you. Be sure to select relays that will accept notes that tag you.
</p>
{/snippet}
<div class="column gap-2">
{#each $readRelayUrls.sort() as url (url)}
<RelayItem {url}>
<Button
class="tooltip flex items-center"
data-tip="Stop using this relay"
onclick={() => removeReadRelay(url)}>
<Icon icon={CloseCircle} />
</Button>
</RelayItem>
{:else}
<p class="text-center text-sm">No relays found</p>
{/each}
<Button class="btn btn-primary mt-2" onclick={addReadRelays}>
<Icon icon={AddCircle} />
Add Relay
</Button>
</div>
</Collapse>
<Collapse class="card2 bg-alt column gap-4 shadow-md">
{#snippet title()}
<h2 class="flex items-center gap-3 text-xl">
<Icon icon={Mailbox} />
Messaging Relays
</h2>
{/snippet}
{#snippet description()}
<p class="text-sm">
These relays will be advertised on your profile as places you use to send and receive direct
messages. Be sure to select relays that will accept your messages and messages from people
you'd like to be in contact with.
</p>
{/snippet}
<div class="column gap-2">
{#each $messagingRelayUrls.sort() as url (url)}
<RelayItem {url}>
<Button
class="tooltip flex items-center"
data-tip="Stop using this relay"
onclick={() => removeMessagingRelay(url)}>
<Icon icon={CloseCircle} />
</Button>
</RelayItem>
{:else}
<p class="text-center text-sm">No relays found</p>
{/each}
<Button class="btn btn-primary mt-2" onclick={addMessagingRelays}>
<Icon icon={AddCircle} />
Add Relay
</Button>
</div>
</Collapse>
<Collapse class="card2 bg-alt column gap-4 shadow-md">
{#snippet title()}
<h2 class="flex items-center gap-3 text-xl">
<Icon icon={ForbiddenCircle} />
Blocked Relays
</h2>
{/snippet}
{#snippet description()}
<p class="text-sm">
These relays will never be connected to by clients supporting this policy.
</p>
{/snippet}
<div class="column gap-2">
{#each $blockedRelayUrls.sort() as url (url)}
<RelayItem {url}>
<Button
class="tooltip flex items-center"
data-tip="Stop using this relay"
onclick={() => removeBlockedRelay(url)}>
<Icon icon={CloseCircle} />
</Button>
</RelayItem>
{:else}
<p class="text-center text-sm">No relays found</p>
{/each}
<Button class="btn btn-primary mt-2" onclick={addBlockedRelays}>
<Icon icon={AddCircle} />
Add Relay
</Button>
</div>
</Collapse>
<p class="text-sm mb-2">
Relays are servers which store your data, or allow you to find data from across the Nostr
network. We've set you up with some reasonable defaults, but if you're a power user, you can
customize your relay selections below.
</p>
<RelaySettingsItem
icon={Inbox}
title="Inbox Relays"
subtitle="Where you send your public notes. Be sure to select relays that will accept your notes, and which will let people who follow you read them."
relays={readRelayUrls}
addRelay={addReadRelay}
removeRelay={removeReadRelay} />
<RelaySettingsItem
icon={Plane}
title="Outbox Relays"
subtitle="Where other people should send notes intended for you. Be sure to select relays that will accept notes that tag you."
relays={writeRelayUrls}
addRelay={addWriteRelay}
removeRelay={removeWriteRelay} />
<RelaySettingsItem
icon={Mailbox}
title="DM Relays"
subtitle="Where you send and receive direct messages. Be sure to select relays that will accept your messages and messages from people you'd like to be in contact with."
relays={messagingRelayUrls}
addRelay={addMessagingRelay}
removeRelay={removeMessagingRelay} />
<RelaySettingsItem
icon={Magnifier}
title="Search Relays"
subtitle="Relays that support searching for profiles and public notes."
relays={searchRelayUrls}
addRelay={addSearchRelay}
removeRelay={removeSearchRelay}
matchRelay={url => hasNip50(getRelay(url))} />
<RelaySettingsItem
icon={ForbiddenCircle}
title="Blocked Relays"
subtitle="These relays won't be used unless explicitly requested."
relays={blockedRelayUrls}
addRelay={addBlockedRelay}
removeRelay={removeBlockedRelay} />
</div>
</div>
+2 -2
View File
@@ -83,9 +83,9 @@
<div class="content column gap-4">
<div class="card2 bg-alt flex flex-col gap-6 shadow-md">
<div class="flex items-center justify-between">
<strong class="flex items-center gap-3">
<strong class="flex items-center gap-3 text-lg">
<Icon icon={Wallet2} />
Wallet
Your Wallet
</strong>
{#if $session?.wallet}
<div class={statusClass}>
+6 -10
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {insertAt, removeAt} from "@welshman/lib"
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
import Planet from "@assets/icons/planet-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"
@@ -96,22 +96,18 @@
<Page class="cw-full">
<PageBar class="cw-full">
{#snippet icon()}
<div class="center">
<Icon icon={SettingsMinimalistic} />
<div class="flex items-center justify-between gap-4">
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
<Icon icon={Planet} />
<strong>Your Spaces</strong>
</div>
{/snippet}
{#snippet title()}
<strong>Your Spaces</strong>
{/snippet}
{#snippet action()}
{#if $userSpaceUrls.length > 0 && PLATFORM_RELAYS.length === 0}
<Button class="btn btn-primary btn-sm" onclick={addSpace}>
<Icon icon={AddCircle} />
Add Space
</Button>
{/if}
{/snippet}
</div>
</PageBar>
<PageContent class="cw-full flex flex-col gap-2 p-2 pt-4">
{#each PLATFORM_RELAYS as url (url)}
+13 -8
View File
@@ -8,6 +8,7 @@
import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte"
import {pushModal} from "@app/util/modal"
import {makeSpacePath} from "@app/util/routes"
import {decodeRelay, relaysPendingTrust} from "@app/core/state"
import {deriveRelayAuthError} from "@app/core/commands"
@@ -37,11 +38,15 @@
})
</script>
<SecondaryNav>
<SpaceMenu {url} />
</SecondaryNav>
<Page>
{#key $page.url.pathname}
{@render children?.()}
{/key}
</Page>
{#if $page.url.pathname === makeSpacePath(url)}
{@render children?.()}
{:else}
<SecondaryNav>
<SpaceMenu {url} />
</SecondaryNav>
<Page>
{#key $page.url.pathname}
{@render children?.()}
{/key}
</Page>
{/if}
+24 -3
View File
@@ -1,10 +1,31 @@
<script lang="ts">
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import theme from "tailwindcss/defaultTheme"
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import {decodeRelay} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
import {goToSpace} from "@app/util/routes"
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
import SpaceMenu from "@app/components/SpaceMenu.svelte"
const url = decodeRelay($page.params.relay!)
const md = parseInt(theme.screens.md, 10)
goto(makeSpacePath(url, "recent"))
let width = $state(0)
$effect(() => {
if (width > md) {
goToSpace(url)
}
})
</script>
<svelte:window bind:innerWidth={width} />
{#if width <= md}
<div class="ml-sai mt-sai mb-sai relative z-nav w-14 flex-shrink-0 bg-base-200 pt-4">
<PrimaryNavSpaces />
</div>
<SecondaryNav class="!flex !min-h-0 !w-auto flex-grow pb-4">
<SpaceMenu {url} />
</SecondaryNav>
{/if}
+9 -2
View File
@@ -1,7 +1,14 @@
<script>
<script lang="ts">
import type {Snippet} from "svelte"
import {page} from "$app/stores"
type Props = {
children?: Snippet
}
const {children}: Props = $props()
</script>
{#key $page.url.searchParams.get("at")}
<slot />
{@render children?.()}
{/key}
+174 -64
View File
@@ -5,7 +5,7 @@
import {goto} from "$app/navigation"
import type {Readable} from "svelte/store"
import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app"
import {now, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
import {now, ifLet, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
@@ -19,13 +19,14 @@
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import cx from "classnames"
import {slide, fade, fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
@@ -33,7 +34,6 @@
import RoomItem from "@app/components/RoomItem.svelte"
import RoomName from "@app/components/RoomName.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
@@ -43,11 +43,16 @@
decodeRelay,
deriveRoom,
deriveUserRoomMembershipStatus,
getRoomType,
MESSAGE_KINDS,
MembershipStatus,
PROTECTED,
RoomType,
userSettingsValues,
} from "@app/core/state"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import VideoCallContent from "@app/components/VideoCallContent.svelte"
import {currentVoiceRoom, videoTileCount, voiceState} from "@app/voice"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
import {checked} from "@app/util/notifications"
@@ -59,6 +64,54 @@
const lastChecked = $checked[$page.url.pathname]
const url = decodeRelay(relay)
const room = deriveRoom(url, h)
const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice)
const voiceConnectedHere = $derived(
isVoiceRoom &&
$voiceState === "connected" &&
$currentVoiceRoom?.url === url &&
$currentVoiceRoom?.h === h,
)
let mobileRoomPanel = $state<"chat" | "video">("chat")
let voiceDesktopPanel = $state<"chat" | "video" | "split">("split")
const showMobileVideoPanel = $derived(
isVoiceRoom && $voiceState === "connected" && mobileRoomPanel === "video",
)
const pageContentFrame = $derived<"default" | "split-right">(
voiceConnectedHere && voiceDesktopPanel === "split" ? "split-right" : "default",
)
const pageContentHiddenDesktopVideoOnly = $derived(
voiceConnectedHere && voiceDesktopPanel === "video",
)
let prevVideoTileCount = $state(0)
$effect(() => {
if ($voiceState !== "connected") {
mobileRoomPanel = "chat"
voiceDesktopPanel = "chat"
prevVideoTileCount = 0
return
}
const here = isVoiceRoom && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h
const n = $videoTileCount
if (!here) {
prevVideoTileCount = 0
return
}
if (prevVideoTileCount === 0 && n >= 1) {
voiceDesktopPanel = "video"
mobileRoomPanel = "video"
}
prevVideoTileCount = n
})
const shouldProtect = canEnforceNip70(url)
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const at = $derived(parseInt($page.url.searchParams.get("at")!))
@@ -336,13 +389,7 @@
eventToEdit = event
}
const onEditPrevious = () => {
const prev = $events.toReversed().find(e => e.pubkey === $pubkey)
if (prev && canEditEvent(prev)) {
onEditEvent(prev)
}
}
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
onMount(() => {
const observer = new ResizeObserver(() => {
@@ -364,7 +411,7 @@
})
</script>
<PageBar>
<SpaceBar>
{#snippet icon()}
<RoomImage {url} {h} />
{/snippet}
@@ -372,17 +419,57 @@
<RoomName {url} {h} />
{/snippet}
{#snippet action()}
<div class="row-2 items-center">
<SpaceSearch {url} {h} />
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} />
</Button>
<SpaceMenuButton {url} />
</div>
{#if voiceConnectedHere}
<div class="flex gap-1 md:hidden">
<Button
class={cx("btn btn-sm", mobileRoomPanel === "chat" && "btn-primary")}
onclick={() => (mobileRoomPanel = "chat")}>
Chat
</Button>
<Button
class={cx("btn btn-sm", mobileRoomPanel === "video" && "btn-primary")}
onclick={() => (mobileRoomPanel = "video")}>
Video
</Button>
</div>
<div class="hidden flex-wrap gap-1 md:flex">
<Button
data-tip="Messages only"
class={cx("btn btn-sm", voiceDesktopPanel === "chat" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "chat")}>
Chat
</Button>
<Button
data-tip="Video only"
class={cx("btn btn-sm", voiceDesktopPanel === "video" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "video")}>
Video
</Button>
<Button
data-tip="Video and chat side by side"
class={cx("btn btn-sm", voiceDesktopPanel === "split" && "btn-primary")}
onclick={() => (voiceDesktopPanel = "split")}>
Video + Chat
</Button>
</div>
{/if}
<SpaceSearch {url} {h} />
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} />
</Button>
{/snippet}
</PageBar>
</SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<PageContent
bind:element
onscroll={onScroll}
contentFrame={pageContentFrame}
class={cx(
showMobileVideoPanel
? "hidden flex-col-reverse pt-4 md:flex md:flex-col-reverse"
: "flex flex-col-reverse pt-4",
pageContentHiddenDesktopVideoOnly && "md:hidden",
)}>
<div bind:this={dynamicPadding}></div>
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<div class="py-20">
@@ -454,52 +541,75 @@
{/if}
</PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted}
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3">
<p class="opacity-75">Only members are allowed to post to this room.</p>
{#if !$room.isClosed}
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Ask to Join
</Button>
{#if voiceConnectedHere}
<VideoCallContent variant="desktop-split" {url} {h} visible={voiceDesktopPanel === "split"} />
<VideoCallContent variant="desktop-full" {url} {h} visible={voiceDesktopPanel === "video"} />
{/if}
{#if isVoiceRoom && $voiceState === "connected"}
<VideoCallContent variant="mobile" {url} {h} visible={mobileRoomPanel === "video"} />
{/if}
<div
class={cx(
"chat__compose-zone flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0",
voiceConnectedHere && voiceDesktopPanel === "split" && "cw-split-chat",
pageContentHiddenDesktopVideoOnly && "md:hidden",
showMobileVideoPanel && "max-md:hidden",
)}
bind:this={chatCompose}>
<div class="chat__compose-inner min-w-0 flex-1">
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted}
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3">
<p class="opacity-75">Only members are allowed to post to this room.</p>
{#if !$room.isClosed}
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Login2} />
{/if}
Ask to Join
</Button>
{/if}
{/if}
{/if}
</div>
{:else}
<div>
{#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
bind:this={compose} />
{/key}
{/if}
</div>
{#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"}
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
<VoiceWidget />
</div>
{:else}
<div>
{#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
bind:this={compose} />
{/key}
{/if}
</div>
@@ -8,14 +8,13 @@
import {EVENT_TIME, getTagValue} from "@welshman/util"
import {fly} from "@lib/transition"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import CalendarAdd from "@assets/icons/calendar-add.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
import {pushModal} from "@app/util/modal"
@@ -111,25 +110,18 @@
})
</script>
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon={CalendarMinimalistic} />
</div>
{/snippet}
<SpaceBar>
{#snippet title()}
<Icon icon={CalendarMinimalistic} />
<strong>Calendar</strong>
{/snippet}
{#snippet action()}
<div class="row-2">
<Button class="btn btn-primary btn-sm" onclick={makeEvent}>
<Icon icon={CalendarAdd} />
Create an Event
</Button>
<SpaceMenuButton {url} />
</div>
<Button class="btn btn-primary btn-sm" onclick={makeEvent}>
<Icon icon={Add} />
Create
</Button>
{/snippet}
</PageBar>
</SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2 pt-4">
{#each items as { event, dateDisplay, isFirstFutureEvent }, i (event.id)}
@@ -7,18 +7,16 @@
import {request} from "@welshman/net"
import {repository} from "@welshman/app"
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import Content from "@app/components/Content.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import CalendarEventMeta from "@app/components/CalendarEventMeta.svelte"
@@ -60,22 +58,11 @@
})
</script>
<PageBar>
{#snippet icon()}
<div>
<Button class="btn btn-neutral btn-sm flex-nowrap whitespace-nowrap" onclick={back}>
<Icon icon={AltArrowLeft} />
<span class="hidden sm:inline">Go back</span>
</Button>
</div>
{/snippet}
<SpaceBar {back}>
{#snippet title()}
<h1 class="text-xl">{getTagValue("title", $event?.tags || []) || ""}</h1>
{/snippet}
{#snippet action()}
<SpaceMenuButton {url} />
{/snippet}
</PageBar>
</SpaceBar>
<PageContent class="flex flex-col gap-3 p-2 pt-4">
{#if $event}
+7 -21
View File
@@ -4,7 +4,7 @@
import {goto} from "$app/navigation"
import type {Readable} from "svelte/store"
import {readable} from "svelte/store"
import {now, int, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
import {now, int, ifLet, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER} from "@welshman/util"
import {pubkey, publishThunk} from "@welshman/app"
@@ -14,12 +14,11 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
@@ -272,13 +271,7 @@
eventToEdit = event
}
const onEditPrevious = () => {
const prev = $events.toReversed().find(e => e.pubkey === $pubkey)
if (prev && canEditEvent(prev)) {
onEditEvent(prev)
}
}
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
onMount(() => {
const controller = new AbortController()
@@ -302,22 +295,15 @@
})
</script>
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon={ChatRound} />
</div>
{/snippet}
<SpaceBar>
{#snippet title()}
<Icon icon={ChatRound} />
<strong>Chat</strong>
{/snippet}
{#snippet action()}
<div class="row-2 items-center">
<SpaceSearch {url} />
<SpaceMenuButton {url} />
</div>
<SpaceSearch {url} />
{/snippet}
</PageBar>
</SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
@@ -7,13 +7,13 @@
import type {TrustedEvent} from "@welshman/util"
import {CLASSIFIED, getTagValue} from "@welshman/util"
import {fly} from "@lib/transition"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
import {decodeRelay} from "@app/core/state"
@@ -62,25 +62,18 @@
})
</script>
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon={NotesMinimalistic} />
</div>
{/snippet}
<SpaceBar>
{#snippet title()}
<strong>Classified Listings</strong>
<Icon icon={CaseMinimalistic} />
<strong>Classifieds</strong>
{/snippet}
{#snippet action()}
<div class="row-2">
<Button class="btn btn-primary btn-sm" onclick={createClassified}>
<Icon icon={NotesMinimalistic} />
Create a Listing
</Button>
<SpaceMenuButton {url} />
</div>
<Button class="btn btn-primary btn-sm" onclick={createClassified}>
<Icon icon={Add} />
Create
</Button>
{/snippet}
</PageBar>
</SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2 pt-4">
{#each items as event (event.id)}
@@ -7,17 +7,15 @@
import {repository} from "@welshman/app"
import {request} from "@welshman/net"
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import ClassifiedActions from "@app/components/ClassifiedActions.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte"
@@ -57,24 +55,11 @@
})
</script>
<PageBar>
{#snippet icon()}
<div>
<Button class="btn btn-neutral btn-sm flex-nowrap whitespace-nowrap" onclick={back}>
<Icon icon={AltArrowLeft} />
<span class="hidden sm:inline">Go back</span>
</Button>
</div>
{/snippet}
<SpaceBar {back}>
{#snippet title()}
<h1 class="text-xl">{getTagValue("title", $event?.tags || []) || ""}</h1>
{/snippet}
{#snippet action()}
<div>
<SpaceMenuButton {url} />
</div>
{/snippet}
</PageBar>
</SpaceBar>
<PageContent class="flex flex-col p-2 pt-4">
{#if $event}
+10 -17
View File
@@ -7,13 +7,13 @@
import type {TrustedEvent} from "@welshman/util"
import {ZAP_GOAL, getTagValue} from "@welshman/util"
import {fly} from "@lib/transition"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte"
import {decodeRelay, makeCommentFilter} from "@app/core/state"
@@ -61,25 +61,18 @@
})
</script>
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon={NotesMinimalistic} />
</div>
{/snippet}
<SpaceBar>
{#snippet title()}
<Icon icon={StarFallMinimalistic} />
<strong>Goals</strong>
{/snippet}
{#snippet action()}
<div class="row-2">
<Button class="btn btn-primary btn-sm" onclick={createGoal}>
<Icon icon={NotesMinimalistic} />
Create a Goal
</Button>
<SpaceMenuButton {url} />
</div>
<Button class="btn btn-primary btn-sm" onclick={createGoal}>
<Icon icon={Add} />
Create
</Button>
{/snippet}
</PageBar>
</SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2 pt-4">
{#each items as event (event.id)}
@@ -7,17 +7,15 @@
import {repository} from "@welshman/app"
import {request} from "@welshman/net"
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import GoalActions from "@app/components/GoalActions.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte"
@@ -58,24 +56,11 @@
})
</script>
<PageBar>
{#snippet icon()}
<div>
<Button class="btn btn-neutral btn-sm flex-nowrap whitespace-nowrap" onclick={back}>
<Icon icon={AltArrowLeft} />
<span class="hidden sm:inline">Go back</span>
</Button>
</div>
{/snippet}
<SpaceBar {back}>
{#snippet title()}
<h1 class="text-xl">{$event?.content}</h1>
{/snippet}
{#snippet action()}
<div>
<SpaceMenuButton {url} />
</div>
{/snippet}
</PageBar>
</SpaceBar>
<PageContent class="flex flex-col p-2 pt-4">
{#if $event}
+5 -15
View File
@@ -19,9 +19,8 @@
import History from "@assets/icons/history.svg?dataurl"
import {createScroller} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
@@ -31,7 +30,7 @@
import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS} from "@app/core/state"
const url = decodeRelay($page.params.relay!)
const since = ago(MONTH)
const since = ago(3, MONTH)
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}])
const content = deriveEventsForUrl(url, [{kinds: CONTENT_KINDS, since}])
@@ -104,21 +103,12 @@
})
</script>
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon={History} />
</div>
{/snippet}
<SpaceBar>
{#snippet title()}
<Icon icon={History} />
<strong>Recent Activity</strong>
{/snippet}
{#snippet action()}
<div class="row-2">
<SpaceMenuButton {url} />
</div>
{/snippet}
</PageBar>
</SpaceBar>
<div bind:this={element}>
<PageContent class="flex flex-col gap-2 p-2 pt-4">
+9 -16
View File
@@ -8,12 +8,12 @@
import {THREAD, getTagValue} from "@welshman/util"
import {fly} from "@lib/transition"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {decodeRelay} from "@app/core/state"
@@ -62,25 +62,18 @@
})
</script>
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon={NotesMinimalistic} />
</div>
{/snippet}
<SpaceBar>
{#snippet title()}
<Icon icon={NotesMinimalistic} />
<strong>Threads</strong>
{/snippet}
{#snippet action()}
<div class="row-2">
<Button class="btn btn-primary btn-sm" onclick={createThread}>
<Icon icon={NotesMinimalistic} />
Create a Thread
</Button>
<SpaceMenuButton {url} />
</div>
<Button class="btn btn-sm btn-primary" onclick={createThread}>
<Icon icon={Add} />
Create
</Button>
{/snippet}
</PageBar>
</SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2 pt-4">
{#each items as event (event.id)}
@@ -7,17 +7,15 @@
import {repository} from "@welshman/app"
import {request} from "@welshman/net"
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte"
@@ -57,24 +55,11 @@
})
</script>
<PageBar>
{#snippet icon()}
<div>
<Button class="btn btn-neutral btn-sm flex-nowrap whitespace-nowrap" onclick={back}>
<Icon icon={AltArrowLeft} />
<span class="hidden sm:inline">Go back</span>
</Button>
</div>
{/snippet}
<SpaceBar {back}>
{#snippet title()}
<h1 class="text-xl">{getTagValue("title", $event?.tags || []) || ""}</h1>
{/snippet}
{#snippet action()}
<div>
<SpaceMenuButton {url} />
</div>
{/snippet}
</PageBar>
</SpaceBar>
<PageContent class="flex flex-col p-2 pt-4">
{#if $event}
Binary file not shown.
Binary file not shown.
+11 -11
View File
@@ -1,13 +1,13 @@
identifier: social.flotilla
name: Flotilla
tags: nostr nip29 community chat group
changelog: CHANGELOG.md
homepage: https://flotilla.social
description: Self-hosted community chat and threads built on the nostr protocol.
repository: https://github.com/coracle-social/flotilla
blossom_servers:
- https://cdn.zapstore.dev
- https://hbr.coracle.social
assets:
- app-release-signed.apk
tags:
- nostr
- nip29
- community
- chat
- group
license: MIT
website: https://flotilla.social
repository: https://gitea.coracle.social/coracle/flotilla
release_source: ./android/app/build/outputs/apk/release/app-release-signed.apk
release_notes: ./CHANGELOG.md