Compare commits

..

153 Commits

Author SHA1 Message Date
userAdityaa 80df16f97b feat: redesign toast notifications for UX (#148)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-04 16:45:49 +00:00
junaiddshaukat 18cb245599 Remove room/space leave indications (#149)
Co-authored-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
Co-committed-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
2026-04-04 16:28:11 +00:00
Jon Staab fd6cc84be6 Simplify chat compose layout 2026-04-04 09:02:52 -07:00
Jon Staab 9311cab3b2 Move away from fixed positioned page elements because they act squirrely on android 2026-04-03 17:16:47 -07:00
userAdityaa fceccf47be fix(ui): hide report badge for non-admin users (#147)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-03 23:54:20 +00:00
Jon Staab fe20fbfd28 Add polls 2026-04-03 10:56:00 -07:00
junaiddshaukat 4f3a2a1660 Add space search to recent activity page (#59) (#119)
Co-authored-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
Co-committed-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
2026-04-03 16:58:35 +00:00
Jon Staab 1c8457a4bf Fix notification badge on mobile nav 2026-04-02 16:47:32 -07:00
Jon Staab 8710043a02 Fix env conventions again 2026-04-02 14:01:09 -07:00
Jon Staab dc46b42cb6 Fix platform logo 2026-04-02 13:49:01 -07:00
Jon Staab 2f1972e70a Add contributing file 2026-04-02 13:31:37 -07:00
mplorentz c5fcf12165 Fix error toast when failing to join room. (#113)
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-04-02 19:35:46 +00:00
mplorentz 61ed632579 Change audio devices in call (#112)
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-04-02 19:33:48 +00:00
Jon Staab 86f4b75c52 Merge subs to avoid hitting limits 2026-04-02 11:49:26 -07:00
bhavishy2801 b26ab916d5 feat: use NIP-50 relay-side search with scope selection (#114)
Co-authored-by: Bhavishy <bhavishyrocker2801@gmail.com>
Co-committed-by: Bhavishy <bhavishyrocker2801@gmail.com>
2026-04-02 18:49:18 +00:00
nayan9617 c882198206 fix: respect VITE_PLATFORM_LOGO with fallback (#116)
Co-authored-by: nayan9617 <nayanp4925@gmail.com>
Co-committed-by: nayan9617 <nayanp4925@gmail.com>
2026-04-02 17:52:44 +00:00
Jon Staab 4aef27ffd5 Fix xcode version 2026-04-02 07:08:16 -07:00
Jon Staab cf4e3f5fc6 Bump version 2026-04-02 07:05:34 -07:00
Jon Staab 57eb919c83 Bump welshman to fix poll behavior 2026-04-02 07:02:57 -07:00
Jon Staab 85cfaf2bc9 Remove redundant join space button 2026-04-02 06:29:37 -07:00
Jon Staab 25a69a8191 Small tweaks 2026-04-01 14:07:29 -07:00
Jon Staab 6feeb23b1f Bump welshman 2026-04-01 11:39:05 -07:00
Jon Staab 4b92ffe3c5 remove duplicate spaces button 2026-04-01 11:33:56 -07:00
Jon Staab 823a9c3271 Combine discover and space list into a single page 2026-03-31 14:24:09 -07:00
Jon Staab fe89df2aa3 Fix some chat related bugs 2026-03-31 11:25:59 -07:00
Jon Staab 97ff8ff802 Bump version 2026-03-31 09:55:04 -07:00
Jon Staab a10a9e7043 Bump pomade 2026-03-31 09:53:30 -07:00
Jon Staab 4f42abc2ff Bump version 2026-03-30 15:22:01 -07:00
Jon Staab fe042c88b8 Apply safe area insets to new messages button 2026-03-30 15:15:41 -07:00
Jon Staab 55e3a31b61 Show notifications on non-nip29 chat 2026-03-30 14:29:24 -07:00
Jon Staab 5760be4313 Add back button to chat detail 2026-03-30 14:24:40 -07:00
Jon Staab 2fd7556a52 Fix new messages button, improve room load 2026-03-30 14:20:30 -07:00
Jon Staab e8ed9cd379 Fix chat new messages fixed button 2026-03-30 13:22:22 -07:00
Jon Staab eeeb3c96d2 Bump welshman 2026-03-30 11:46:12 -07:00
Jon Staab 2da5dee6bd Add pow to wrapped messages 2026-03-30 11:46:12 -07:00
Jon Staab a66193ff45 Fix some display bugs 2026-03-30 11:46:12 -07:00
Jon Staab 55131ba7ce Remove replaceState from SpaceMenu since we're never in a drawer any more 2026-03-30 11:46:12 -07:00
Jon Staab df6282d2ba Fix nav overflow on mobile 2026-03-30 11:46:12 -07:00
Jon Staab 6ebe792ce5 Show notification badge on voice room item 2026-03-30 11:46:11 -07:00
Jon Staab 6c9bdb2ccd Detect blossom support using supported_nips 2026-03-30 11:46:11 -07:00
Jon Staab bc94c705f3 Make space syncing more robust 2026-03-30 11:46:11 -07:00
Jon Staab 2b9b4da2cc Add all rooms to notifications 2026-03-30 11:46:11 -07:00
Jon Staab 090070d1f9 Add dm relay 2026-03-30 11:46:11 -07:00
mplorentz 16a73f27c9 Add a dialog before joining voice rooms (#109)
After using the voice rooms more since we removed the option for voice-only rooms I think you were right to suggest a dialog box before joining rooms. It felt far to clunky to have to join the voice call any time you just wanted to try to view room members, edit room settings, or just view the recent text chat.

This adds a dialog that allows the user to decline to join the call but still access the text part of the room along with associated settings and controls. It also acts as another confirmation step before turning on the user's microphone, and allows them to choose an audio input so they don't have to mess with the (generally terrible) browser controls for doing so. We should probably have controls to change your audio input and output from within the call as well, but I think this is enough for an MVP.

![Screenshot 2026-03-27 at 11.10.53 AM.png](/attachments/3ac271a6-5d17-4063-9ac6-3e5bdef10ccf)

Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: coracle/flotilla#109
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-03-27 19:02:56 +00:00
mplorentz 82245d895c Voice Room Membership Error (#106)
Before this we were showing "Failed to join voice room" if the relay rejected our request for a livekit token because we aren't a member of the room. Now it shows the error "Failed to join voice room: you must be a member."

Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: coracle/flotilla#106
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-03-27 17:45:42 +00:00
Jon Staab 610b8dd171 Use secure storage for session data 2026-03-23 17:51:51 -07:00
Jon Staab f5b1e91378 Handle json parsing errors on file upload 2026-03-23 14:34:45 -07:00
Jon Staab 1de6d7a874 Set up default messaging relays 2026-03-23 14:21:04 -07:00
Jon Staab b716f3f792 Improve dm relay defaults and warnings 2026-03-23 12:53:24 -07:00
Jon Staab 75053bbbb1 Create health check framework 2026-03-23 11:17:45 -07:00
Jon Staab f9c7ed4936 Move relay action items to top of page 2026-03-19 12:41:23 -07:00
hodlbod 1f5be54cb1 Migrate Reports dialog to ActionItems dialog, add room join requests to queue 2026-03-19 15:40:34 +00:00
hodlbod 0761cdd28f Add android fallback for background push notifications (#102) 2026-03-19 15:32:32 +00:00
Jon Staab 7e2a0e9d5f Show notification badges regardless of favorite status 2026-03-17 15:11:02 -07:00
Jon Staab 7ae887561d Remove bad signers, fix some ui bugs 2026-03-17 15:05:46 -07:00
Jon Staab baa1d49b3a Tweak mobile nav 2026-03-17 15:03:40 -07:00
Jon Staab 58a6be911a Hide close button in dialog if in a noEscape modal 2026-03-17 14:55:40 -07:00
mplorentz 368f0b048b Expect pubkey in kind 39004 (#101)
This adjusts our implementation of the Livekit presence event to match the NIP (https://github.com/nostr-protocol/nips/pull/2238#issuecomment-4057645310). Specifically we now expect the user's Nostr pubkey in the `participant` tag instead of the livekit identity string.

I also fixed a bug I found where a malformed `participant` tag would crash the rendering of VoiceWidget, causing it to appear frozen.

There is a corresponding zooid PR [here](https://github.com/coracle-social/zooid/pull/11)

Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: coracle/flotilla#101
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-03-17 19:38:10 +00:00
Jon Staab 10894e17a5 Fix phantom badges 2026-03-16 13:46:00 -07:00
Jon Staab ec8a7a40e2 Fix room icon 2026-03-16 13:42:01 -07: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
168 changed files with 6833 additions and 2736 deletions
+1 -1
View File
@@ -9,4 +9,4 @@ build
# Env files (keep .env for build; exclude local overrides) # Env files (keep .env for build; exclude local overrides)
.env.local .env.local
.env.*.local .env.*.local
+3 -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_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_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_URL=https://app.flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
@@ -15,7 +15,8 @@ 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_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_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_GLITCHTIP_API_KEY= VITE_GLITCHTIP_API_KEY=
+1 -1
View File
@@ -6,7 +6,7 @@ on:
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: coracle-social/flotilla
jobs: jobs:
build-and-push-image: build-and-push-image:
+2 -1
View File
@@ -1,6 +1,6 @@
# Env # Env
.env
.env.local .env.local
.env.*.local
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
@@ -25,6 +25,7 @@ android/app/src/main/assets/public/
# Web/JavaScript # Web/JavaScript
node_modules/ node_modules/
.pnpm-store/
build/ build/
.svelte-kit/ .svelte-kit/
+1
View File
@@ -169,6 +169,7 @@ src/
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss - When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates - Do not define svelte event handlers inline, instead name them and put them in the script section of templates
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly. - Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
**Human-First Simplicity (Jon Staab Style):** **Human-First Simplicity (Jon Staab Style):**
+27
View File
@@ -1,5 +1,32 @@
# Changelog # Changelog
# 1.7.2
* Fix race condition in nip 46
* Remove duplicate spaces button
* Combine discover and space list pages
* Fix some chat related bugs
* Fix bug with joining spaces
# 1.7.1
* Fix pomade registration fallback in case of offline signer
# 1.7.0
* Enable email/password login
* Add up/edit to direct messages
* Fix a number of UI bugs
* Improve navigation on mobile
* Improve performance and syncing reliability
* Add proof of work to DMs
* Detect blossom support using supported_nips
* Improve notification badges
* Add voice rooms (@mplorentz)
* Re-design relay onboarding and settings
* Add android fallback for push notifications
* Fix file uploads on android
# 1.6.5 # 1.6.5
* Attempt to fix permission grant for notifications * Attempt to fix permission grant for notifications
+56
View File
@@ -0,0 +1,56 @@
## Project Overview
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
Please visit our [issue tracker](https://gitea.coracle.social/coracle/flotilla/issues) to contribute. Any new issues should be opened without a milestone, label, or project and the project owners will triage them.
### Milestones
Milestones indicate how soon a given task should be tackled.
- [Current](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=1) issues are immediately actionable.
- [Next](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=2) means an issue is blocked.
- [Future](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=3) means we're deferring work until a later date.
### Labels
- [Design](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=15) issues need design work before being implemented. This might take the form of a high-quality mockup, wireframes, user flows, or just a couple notes about where things go, depending on the nature of the task.
- [Dev](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=16) issues are ready to be implemented. Most of the work will be related to architecting and writing code.
- [Easy](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=14) issues have no dependencies, and are scoped quite narrowly.
- [Priority](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=6) issues include bugs and urgent feature requests. These should get attention first if possible, although sometimes long-standing performance issues or subtle bugs might end up here for a while.
- [Ideas](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=13) are for things that aren't scoped out yet, or which need protocol work before getting designed or implemented.
### Projects
Issues may or may not have a project. Projects are used to group issues thematically just for organization.
## Coding conventions
There are a few conventions that are helpful to know right out of the gate.
- Most nostr protocol functionality is implemented via the [welshman library](https://welshman.coracle.social/)
- Use Svelte 4 **stores** rather than runes for all state outside UI components
- Most global state flows through Welshman's `repository` (unidirectional)
- Query state using `deriveEventsMapped` or `deriveProfile` etc
- Events are published via `publishThunk`, which allows for optimistic UI updates during signing/pow generation.
- Components should have minimal props - e.g. instead of passing a whole `relay` through, pass its `url`.
- Use `AbortController` when possible instead of request ids
- Use `undefined` or optional properties instead of `null`
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
- When dynamically building classes, use `cx` from `classnames`.
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates.
## Contributing Workflow
To contribute, do the following:
- Find or create an issue and assign yourself (comment instead if you're not able to self-assign)
- If the issue is a design task, attach or link out to any mockups/wireframes/flowcharts
- Once a design task is completed, a maintainer will remove the `design` label and add the `dev` label
- If the issue is a development task, fork the repository and create a branch prefixed by the issue number, e.g. `105-deep-links`
- Before requesting a review, be sure to review any agent-generated code, run the pre-commit hooks, and test the changes.
- Open a PR and request a review. A maintainer will get back to you with requested changes, or will merge the PR.
- Keep your PR up-to-date by rebasing frequently on `dev`. Avoid force-pushing to `dev`.
- PRs are rebased, squashed, and merged to keep commit history simple.
- An issue may have multiple PRs. Once complete, it can be closed.
+3 -2
View File
@@ -4,6 +4,8 @@
FROM node:20-bookworm AS builder FROM node:20-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends curl
RUN npm install -g pnpm@latest RUN npm install -g pnpm@latest
WORKDIR /app WORKDIR /app
@@ -20,7 +22,6 @@ ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
ENV NODE_OPTIONS=--max_old_space_size=16384 ENV NODE_OPTIONS=--max_old_space_size=16384
RUN pnpm run build RUN pnpm run build
# Stage 2: Runtime
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
@@ -28,4 +29,4 @@ WORKDIR /app
# Copy only the built output - no source, no .env, no dev deps # Copy only the built output - no source, no .env, no dev deps
COPY --from=builder /app/build ./build COPY --from=builder /app/build ./build
CMD ["npx", "serve", "build"] CMD ["npx", "serve", "-s", "build"]
+5 -3
View File
@@ -6,7 +6,7 @@ If you would like to be interoperable with Flotilla, please check out this guide
## Environment ## 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_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 - `VITE_PLATFORM_URL` - The url where the app will be hosted
@@ -16,11 +16,13 @@ You can also optionally create an `.env` file and populate it with the following
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color - `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
- `VITE_PLATFORM_DESCRIPTION` - A description of the app - `VITE_PLATFORM_DESCRIPTION` - A description of the app
These values **won't** be used for a built version. Instead, env variables should be provided to `build.sh` directly or to the built container.
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer. If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
## Development ## Development
See [CONTRIBUTING.md](AGENTS.md). See [CONTRIBUTING.md](CONTRIBUTING.md).
## Deployment ## Deployment
@@ -29,7 +31,7 @@ To run your own Flotilla, it's as simple as:
```sh ```sh
pnpm install pnpm install
pnpm run build pnpm run build
npx serve build npx serve -s build
``` ```
Or, if you prefer to use a container: Or, if you prefer to use a container:
+7 -2
View File
@@ -1,4 +1,5 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android { android {
namespace = "social.flotilla" namespace = "social.flotilla"
@@ -7,8 +8,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 41 versionCode 44
versionName "1.6.5" versionName "1.7.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -35,6 +36,10 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.work:work-runtime:2.10.3"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation "fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.22.0"
implementation project(':capacitor-android') implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
+1
View File
@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':aparajita-capacitor-secure-storage')
implementation project(':capacitor-community-safe-area') implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-filesystem') implementation project(':capacitor-filesystem')
+2
View File
@@ -42,4 +42,6 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <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> </manifest>
@@ -1,5 +1,15 @@
package social.flotilla; package social.flotilla;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {} import social.flotilla.notifications.AndroidPushFallbackPlugin;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(AndroidPushFallbackPlugin.class);
super.onCreate(savedInstanceState);
}
}
@@ -0,0 +1,99 @@
package social.flotilla.notifications
import android.content.Context
import android.content.SharedPreferences
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.util.concurrent.TimeUnit
@CapacitorPlugin(name = "AndroidPushFallback")
class AndroidPushFallbackPlugin : Plugin() {
companion object {
const val PREFS_NAME = "CapacitorStorage"
const val KEY_STATE = "androidPushFallback.state"
const val UNIQUE_PERIODIC_WORK = "androidPushFallback.periodic"
const val UNIQUE_IMMEDIATE_WORK = "androidPushFallback.immediate"
}
private fun getPrefs(): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
@PluginMethod
fun syncState(call: PluginCall) {
val state: JSObject? = call.getObject("state")
if (state != null) {
getPrefs().edit().putString(KEY_STATE, state.toString()).apply()
if (isEnabled(state.toString())) {
scheduleWork()
} else {
cancelWork()
}
}
call.resolve()
}
private fun isEnabled(rawState: String?): Boolean {
if (rawState == null || rawState.isEmpty()) {
return false
}
return try {
val state = JSONObject(rawState)
val subscriptions: JSONArray? = state.optJSONArray("subscriptions")
subscriptions != null && subscriptions.length() > 0
} catch (_: JSONException) {
false
}
}
private fun scheduleWork() {
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
val workManager = WorkManager.getInstance(context)
val periodic = PeriodicWorkRequest.Builder(
AndroidPushFallbackWorker::class.java,
15,
TimeUnit.MINUTES,
).setConstraints(constraints).build()
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
UNIQUE_PERIODIC_WORK,
ExistingPeriodicWorkPolicy.UPDATE,
periodic,
)
workManager.enqueueUniqueWork(
UNIQUE_IMMEDIATE_WORK,
ExistingWorkPolicy.REPLACE,
immediate,
)
}
private fun cancelWork() {
val workManager = WorkManager.getInstance(context)
workManager.cancelUniqueWork(UNIQUE_IMMEDIATE_WORK)
workManager.cancelUniqueWork(UNIQUE_PERIODIC_WORK)
}
}
@@ -0,0 +1,862 @@
package social.flotilla.notifications
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.util.Log
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.app.ActivityManager
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import fr.acinq.secp256k1.Secp256k1
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONArray
import org.json.JSONObject
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.KEY_STATE
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.PREFS_NAME
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Arrays
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import android.util.Base64
class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
companion object {
private const val TAG = "PushFallback"
private const val CHANNEL_ID = "flotilla_fallback"
private const val CURSOR_PREFIX = "androidPushFallback.cursor."
private const val SOCKET_TIMEOUT_SECONDS = 20L
private const val REJECTED = "__REJECTED__"
private const val KIND_RELAY_AUTH = 22242
private const val KIND_NIP46_RPC = 24133
private val SECP = Secp256k1.get()
}
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val client: OkHttpClient = OkHttpClient.Builder().build()
// ---- Socket pool ----
// Opens each relay URL at most once; caller must invoke closeAll() when done.
private inner class SocketPool {
private val sockets = ConcurrentHashMap<String, WebSocket>()
fun open(url: String, listener: WebSocketListener): WebSocket =
sockets.getOrPut(url) {
client.newWebSocket(Request.Builder().url(url).build(), listener)
}
fun closeAll() {
for ((_, ws) in sockets) ws.close(1000, "done")
sockets.clear()
}
}
override fun doWork(): Result {
if (isAppInForeground()) {
return Result.success()
}
val pool = SocketPool()
try {
val rawState = prefs.getString(KEY_STATE, "") ?: ""
if (rawState.isEmpty()) return Result.success()
val state = JSONObject(rawState)
val sessionInfo = getSessionInfo(state)
val subscriptions = parseSubscriptions(state)
if (subscriptions.isEmpty()) return Result.success()
val activeSince = state.optLong("activeSince", 0L)
val seen = mutableSetOf<String>()
var latestPair: Pair<String, JSONObject>? = null
for (sub in subscriptions) {
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
val since = maxOf(prefs.getLong(cursorKey, 0L), activeSince)
val result = pollRelay(sub, since, sessionInfo, pool)
if (result.lastCursor > prefs.getLong(cursorKey, 0L)) {
prefs.edit().putLong(cursorKey, result.lastCursor).apply()
}
for (event in result.events) {
val id = event.optString("id", "")
if (id.isNotEmpty() && seen.add(id)) {
val createdAt = event.optLong("created_at", 0L)
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
latestPair = Pair(sub.relay, event)
}
}
}
}
if (latestPair != null) {
val (relay, event) = latestPair!!
postNotification(relay, event)
}
return Result.success()
} catch (e: Exception) {
Log.e(TAG, "Worker failed", e)
return Result.success()
} finally {
pool.closeAll()
client.dispatcher.executorService.shutdown()
}
}
private fun isAppInForeground(): Boolean {
val am = applicationContext.getSystemService(ActivityManager::class.java) ?: return false
val tasks = am.getRunningAppProcesses() ?: return false
val pkg = applicationContext.packageName
return tasks.any { it.processName == pkg && it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND }
}
private fun getSessionInfo(state: JSONObject): SessionInfo {
val session = state.optJSONObject("session") ?: return SessionInfo("anonymous", "", JSONObject())
return SessionInfo(
session.optString("method", "anonymous"),
session.optString("pubkey", ""),
session,
)
}
private fun parseSubscriptions(state: JSONObject): List<Subscription> {
val result = mutableListOf<Subscription>()
val arr = state.optJSONArray("subscriptions") ?: return result
for (i in 0 until arr.length()) {
val item = arr.optJSONObject(i) ?: continue
val relay = item.optString("relay", "").trim()
if (!relay.startsWith("wss://") && !relay.startsWith("ws://")) continue
val filters = item.optJSONArray("filters")
if (filters == null || filters.length() == 0) continue
val key = item.optString("key", "").trim()
result.add(Subscription(relay, key, filters, item.optJSONArray("ignore")))
}
return result
}
private fun pollRelay(sub: Subscription, since: Long, sessionInfo: SessionInfo, pool: SocketPool): RelayResult {
val result = RelayResult()
val latch = CountDownLatch(1)
val listener = RelayListener(sub, since, sessionInfo, result, latch, pool)
pool.open(sub.relay, listener)
if (!latch.await(SOCKET_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
Log.d(TAG, "Relay ${sub.relay} timed out")
}
return result
}
private fun postNotification(relay: String, event: JSONObject) {
val context = applicationContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context.getSystemService(NotificationManager::class.java)
if (manager != null && manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(CHANNEL_ID, "Fallback Notifications", NotificationManager.IMPORTANCE_DEFAULT)
channel.description = "Notifications delivered by Android background fallback"
manager.createNotificationChannel(channel)
}
}
val id = event.optString("id", "")
val encodedRelay = Uri.encode(relay)
val url = "https://app.flotilla.social/?relay=$encodedRelay&id=$id"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
intent.setPackage(context.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val body = "New activity"
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle("Flotilla")
.setContentText(body)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.build()
NotificationManagerCompat.from(context).notify(1, notification)
}
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
val kinds = filter.optJSONArray("kinds")
if (kinds != null && kinds.length() > 0) {
val kind = event.optInt("kind", -1)
var found = false
for (i in 0 until kinds.length()) {
if (kinds.optInt(i, -1) == kind) { found = true; break }
}
if (!found) return false
}
val tags = event.optJSONArray("tags")
val iter = filter.keys()
while (iter.hasNext()) {
val key = iter.next()
if (!key.startsWith("#")) continue
val tagName = key.substring(1)
val allowed = filter.optJSONArray(key) ?: continue
if (allowed.length() == 0) continue
val allowedValues = mutableSetOf<String>()
for (i in 0 until allowed.length()) {
val v = allowed.optString(i, "")
if (v.isNotEmpty()) allowedValues.add(v)
}
var matched = false
if (tags != null) {
for (i in 0 until tags.length()) {
val tag = tags.optJSONArray(i) ?: continue
if (tag.optString(0, "") == tagName && allowedValues.contains(tag.optString(1, ""))) {
matched = true; break
}
}
}
if (!matched) return false
}
return true
}
// ---- Crypto helpers ----
private fun computeEventId(event: JSONObject): String {
return try {
val serialized = JSONArray()
serialized.put(0)
serialized.put(event.optString("pubkey", ""))
serialized.put(event.optLong("created_at", 0))
serialized.put(event.optInt("kind", 0))
serialized.put(event.optJSONArray("tags") ?: JSONArray())
serialized.put(event.optString("content", ""))
// JSONObject escapes forward slashes (/ -> \/), but NIP-01 event ID hashing
// requires unescaped slashes. Replace them before hashing.
val serializedStr = serialized.toString().replace("\\/", "/")
bytesToHex(sha256(serializedStr.toByteArray(StandardCharsets.UTF_8)))
} catch (_: Exception) {
""
}
}
private fun deriveXOnlyPubkey(secretHex: String): String {
val secret = hexToBytes(secretHex)
if (secret.size != 32 || !SECP.secKeyVerify(secret)) return ""
val pubkey65 = try { SECP.pubkeyCreate(secret) } catch (_: Exception) { return "" }
if (pubkey65.size != 65) return ""
return bytesToHex(Arrays.copyOfRange(pubkey65, 1, 33))
}
private fun schnorrSign(secretHex: String, messageHex: String): String {
val sk = hexToBytes(secretHex)
val msg = hexToBytes(messageHex)
if (sk.size != 32 || msg.size != 32 || !SECP.secKeyVerify(sk)) return ""
val aux = ByteArray(32).also { SecureRandom().nextBytes(it) }
val sig = try { SECP.signSchnorr(msg, sk, aux) } catch (_: Exception) { return "" }
if (sig.size != 64) return ""
return bytesToHex(sig)
}
private fun sha256(input: ByteArray): ByteArray =
try { MessageDigest.getInstance("SHA-256").digest(input) } catch (_: Exception) { ByteArray(32) }
private fun hexToBytes(hex: String?): ByteArray {
var s = hex?.trim()?.lowercase() ?: ""
if (s.startsWith("0x")) s = s.substring(2)
if (s.length % 2 == 1) s = "0$s"
val bytes = ByteArray(s.length / 2)
var i = 0
while (i < s.length) {
val hi = Character.digit(s[i], 16)
val lo = Character.digit(s[i + 1], 16)
if (hi < 0 || lo < 0) return ByteArray(0)
bytes[i / 2] = ((hi shl 4) + lo).toByte()
i += 2
}
return bytes
}
private fun bytesToHex(bytes: ByteArray): String {
val hex = "0123456789abcdef".toCharArray()
val chars = CharArray(bytes.size * 2)
for (i in bytes.indices) {
val v = bytes[i].toInt() and 0xFF
chars[i * 2] = hex[v ushr 4]
chars[i * 2 + 1] = hex[v and 0x0F]
}
return String(chars)
}
// ---- NIP-44 encryption (v2: ECDH + HKDF + ChaCha20 + HMAC-SHA256) ----
private fun nip44ConversationKey(clientSecret: String, theirPubkey: String): ByteArray {
val sk = hexToBytes(clientSecret)
val pk = hexToBytes("02$theirPubkey")
if (sk.size != 32 || pk.size != 33) return ByteArray(0)
val shared = try { SECP.pubKeyTweakMul(pk, sk) } catch (_: Exception) { return ByteArray(0) }
if (shared.size != 65) return ByteArray(0)
val sharedX = Arrays.copyOfRange(shared, 1, 33)
return hkdfExtract(sharedX, "nip44-v2".toByteArray(StandardCharsets.UTF_8))
}
private fun hkdfExtract(ikm: ByteArray, salt: ByteArray): ByteArray {
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
mac.init(javax.crypto.spec.SecretKeySpec(salt, "HmacSHA256"))
return mac.doFinal(ikm)
}
private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
val result = ByteArray(length)
var prev = ByteArray(0)
var offset = 0
var counter = 1
while (offset < length) {
mac.init(javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"))
mac.update(prev)
mac.update(info)
mac.update(counter.toByte())
prev = mac.doFinal()
val toCopy = minOf(prev.size, length - offset)
System.arraycopy(prev, 0, result, offset, toCopy)
offset += toCopy
counter++
}
return result
}
private fun hmacSha256(key: ByteArray, vararg parts: ByteArray): ByteArray {
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
mac.init(javax.crypto.spec.SecretKeySpec(key, "HmacSHA256"))
for (part in parts) mac.update(part)
return mac.doFinal()
}
// ChaCha20 block function per RFC 8439
private fun chacha20Block(key: ByteArray, counter: Int, nonce: ByteArray): ByteArray {
fun Int.rotl(n: Int) = (this shl n) or (this ushr (32 - n))
val state = IntArray(16)
state[0] = 0x61707865; state[1] = 0x3320646e; state[2] = 0x79622d32; state[3] = 0x6b206574
for (i in 0..7) state[4 + i] = (key[i*4].toInt() and 0xFF) or
((key[i*4+1].toInt() and 0xFF) shl 8) or
((key[i*4+2].toInt() and 0xFF) shl 16) or
((key[i*4+3].toInt() and 0xFF) shl 24)
state[12] = counter
for (i in 0..2) state[13 + i] = (nonce[i*4].toInt() and 0xFF) or
((nonce[i*4+1].toInt() and 0xFF) shl 8) or
((nonce[i*4+2].toInt() and 0xFF) shl 16) or
((nonce[i*4+3].toInt() and 0xFF) shl 24)
val working = state.copyOf()
repeat(10) {
fun quarterRound(a: Int, b: Int, c: Int, d: Int) {
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(16)
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(12)
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(8)
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(7)
}
quarterRound(0,4,8,12); quarterRound(1,5,9,13); quarterRound(2,6,10,14); quarterRound(3,7,11,15)
quarterRound(0,5,10,15); quarterRound(1,6,11,12); quarterRound(2,7,8,13); quarterRound(3,4,9,14)
}
val out = ByteArray(64)
for (i in 0..15) {
val v = working[i] + state[i]
out[i*4] = v.toByte()
out[i*4+1] = (v ushr 8).toByte()
out[i*4+2] = (v ushr 16).toByte()
out[i*4+3] = (v ushr 24).toByte()
}
return out
}
private fun chacha20(key: ByteArray, nonce: ByteArray, data: ByteArray): ByteArray {
val out = ByteArray(data.size)
var counter = 0
var offset = 0
while (offset < data.size) {
val block = chacha20Block(key, counter, nonce)
val len = minOf(64, data.size - offset)
for (i in 0 until len) out[offset + i] = (data[offset + i].toInt() xor block[i].toInt()).toByte()
offset += len
counter++
}
return out
}
private fun nip44CalcPaddedLen(len: Int): Int {
if (len <= 32) return 32
val nextPower = 1 shl (Math.floor(Math.log((len - 1).toDouble()) / Math.log(2.0)).toInt() + 1)
val chunk = if (nextPower <= 256) 32 else nextPower / 8
return chunk * ((len - 1) / chunk + 1)
}
private fun nip44Pad(plaintext: String): ByteArray {
val unpadded = plaintext.toByteArray(StandardCharsets.UTF_8)
val len = unpadded.size
val padded = ByteArray(2 + nip44CalcPaddedLen(len))
padded[0] = (len ushr 8).toByte()
padded[1] = len.toByte()
System.arraycopy(unpadded, 0, padded, 2, len)
return padded
}
private fun nip44Unpad(padded: ByteArray): String {
val len = ((padded[0].toInt() and 0xFF) shl 8) or (padded[1].toInt() and 0xFF)
if (len == 0 || len > padded.size - 2) return ""
return String(padded, 2, len, StandardCharsets.UTF_8)
}
private fun encryptNip44(plaintext: String, conversationKey: ByteArray): String {
return try {
val nonce = ByteArray(32).also { SecureRandom().nextBytes(it) }
val keys = hkdfExpand(conversationKey, nonce, 76)
val chachaKey = keys.sliceArray(0 until 32)
val chachaNonce = keys.sliceArray(32 until 44)
val hmacKey = keys.sliceArray(44 until 76)
val padded = nip44Pad(plaintext)
val ciphertext = chacha20(chachaKey, chachaNonce, padded)
val mac = hmacSha256(hmacKey, nonce, ciphertext)
val payload = ByteArray(1 + 32 + ciphertext.size + 32)
payload[0] = 2
System.arraycopy(nonce, 0, payload, 1, 32)
System.arraycopy(ciphertext, 0, payload, 33, ciphertext.size)
System.arraycopy(mac, 0, payload, 33 + ciphertext.size, 32)
Base64.encodeToString(payload, Base64.NO_WRAP)
} catch (_: Exception) {
""
}
}
private fun decryptNip44(payload: String, conversationKey: ByteArray): String {
return try {
if (payload.isEmpty() || payload[0] == '#') return ""
val data = Base64.decode(payload, Base64.NO_WRAP)
if (data.size < 99 || data[0] != 2.toByte()) return ""
val nonce = data.sliceArray(1 until 33)
val ciphertext = data.sliceArray(33 until data.size - 32)
val mac = data.sliceArray(data.size - 32 until data.size)
val keys = hkdfExpand(conversationKey, nonce, 76)
val chachaKey = keys.sliceArray(0 until 32)
val chachaNonce = keys.sliceArray(32 until 44)
val hmacKey = keys.sliceArray(44 until 76)
val expectedMac = hmacSha256(hmacKey, nonce, ciphertext)
if (!expectedMac.contentEquals(mac)) return ""
val padded = chacha20(chachaKey, chachaNonce, ciphertext)
nip44Unpad(padded)
} catch (_: Exception) {
""
}
}
// ---- Signing ----
private fun signWithNip01Secret(secretHex: String, eventJson: String, expectedPubkey: String): String {
return try {
val secret = hexToBytes(secretHex)
if (secret.size != 32) return ""
val event = JSONObject(eventJson)
var pubkey = event.optString("pubkey", expectedPubkey)
if (pubkey.isEmpty()) pubkey = deriveXOnlyPubkey(secretHex)
if (pubkey.isEmpty()) return ""
event.put("pubkey", pubkey)
val id = computeEventId(event)
if (id.isEmpty()) return ""
val sig = schnorrSign(secretHex, id)
if (sig.isEmpty()) return ""
event.put("id", id)
event.put("sig", sig)
event.toString()
} catch (_: Exception) {
""
}
}
private fun signWithNip55ContentResolver(packageName: String, eventJson: String, pubkey: String): String {
val uri = Uri.parse("content://$packageName.SIGN_EVENT")
var cursor: Cursor? = null
return try {
cursor = applicationContext.contentResolver.query(uri, arrayOf(eventJson, "", pubkey), "1", null, null)
if (cursor == null || !cursor.moveToFirst()) return ""
val rejIdx = cursor.getColumnIndex("rejected")
if (rejIdx >= 0) {
val v = cursor.getString(rejIdx)
if (v == "1" || v.equals("true", ignoreCase = true)) return REJECTED
}
val eventIdx = cursor.getColumnIndex("event")
if (eventIdx >= 0) cursor.getString(eventIdx) ?: "" else ""
} catch (_: Exception) {
""
} finally {
cursor?.close()
}
}
// ---- Data types ----
private data class SessionInfo(
val method: String,
val pubkey: String,
val session: JSONObject,
)
private data class Subscription(
val relay: String,
val key: String,
val filters: JSONArray,
val ignore: JSONArray?,
)
private class RelayResult {
val events = mutableListOf<JSONObject>()
var lastCursor = 0L
}
// ---- Relay listener ----
private inner class RelayListener(
private val sub: Subscription,
private val since: Long,
private val sessionInfo: SessionInfo,
private val result: RelayResult,
private val latch: CountDownLatch,
private val pool: SocketPool,
) : WebSocketListener() {
private val subId = UUID.randomUUID().toString().replace("-", "")
private var done = false
private var authed = false
private var authEventId = ""
private var nip46InFlight = false
private var pendingDone = false
override fun onOpen(webSocket: WebSocket, response: Response) {
sendReq(webSocket)
}
private fun sendReq(webSocket: WebSocket) {
val req = JSONArray()
req.put("REQ")
req.put(subId)
for (i in 0 until sub.filters.length()) {
val filter = sub.filters.optJSONObject(i) ?: continue
val shaped = JSONObject(filter.toString())
if (since > 0) shaped.put("since", since + 1)
shaped.put("limit", 1)
req.put(shaped)
}
if (req.length() <= 2) { finish(); return }
send(webSocket, req.toString())
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val message = JSONArray(text)
Log.d(TAG, "Received message from ${sub.relay}: $text")
when (message.optString(0, "")) {
"EVENT" -> {
val event = message.optJSONObject(2) ?: return
if (!matchesAnyFilter(sub.filters, event)) return
if (isIgnored(event)) return
result.events.add(event)
val createdAt = event.optLong("created_at", 0L)
if (createdAt > result.lastCursor) result.lastCursor = createdAt
}
"AUTH" -> {
// Only auth once per connection
if (!authed) {
authed = true
tryAuth(webSocket, message.optString(1, ""))
}
}
"OK" -> {
val okId = message.optString(1, "")
val accepted = message.optBoolean(2, false)
if (accepted && okId == authEventId) sendReq(webSocket)
}
"EOSE" -> {
send(webSocket, JSONArray().apply { put("CLOSE"); put(subId) }.toString())
finish()
}
}
} catch (_: Exception) {
finish()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
private fun finish() {
if (done) return
if (nip46InFlight) { pendingDone = true; return }
done = true
latch.countDown()
}
private fun isIgnored(event: JSONObject): Boolean {
val ignore = sub.ignore ?: return false
for (i in 0 until ignore.length()) {
val filter = ignore.optJSONObject(i) ?: continue
if (matchesFilter(filter, event)) return true
}
return false
}
private fun matchesAnyFilter(filters: JSONArray, event: JSONObject): Boolean {
for (i in 0 until filters.length()) {
val filter = filters.optJSONObject(i) ?: continue
if (matchesFilter(filter, event)) return true
}
return false
}
// ---- NIP-42 auth ----
private fun tryAuth(webSocket: WebSocket, challenge: String) {
if (challenge.isEmpty()) return
when (sessionInfo.method) {
"nip01" -> tryNip01Auth(webSocket, challenge)
"nip55" -> tryNip55Auth(webSocket, challenge)
"nip46" -> tryNip46Auth(webSocket, challenge)
// Pomade background auth is not supported: properly delegating to the Pomade signer
// from a background worker is complex, usage is rare, and relays that require auth
// may still be readable without it.
}
}
private fun buildAuthEvent(challenge: String): JSONObject {
return JSONObject().apply {
put("kind", KIND_RELAY_AUTH)
put("pubkey", sessionInfo.pubkey)
put("created_at", System.currentTimeMillis() / 1000L)
put("content", "")
put("id", "")
put("sig", "")
put("tags", JSONArray().apply {
put(JSONArray().apply { put("relay"); put(sub.relay) })
put(JSONArray().apply { put("challenge"); put(challenge) })
})
}
}
private fun sendAuthMessage(webSocket: WebSocket, signedEventJson: String): Boolean {
if (signedEventJson.isEmpty() || signedEventJson == REJECTED) return false
return try {
val event = JSONObject(signedEventJson)
authEventId = event.optString("id", "")
send(webSocket, JSONArray().apply { put("AUTH"); put(event) }.toString())
} catch (_: Exception) {
false
}
}
private fun send(webSocket: WebSocket, message: String): Boolean {
Log.d(TAG, "Sending message to ${webSocket.request().url}: $message")
return webSocket.send(message)
}
private fun tryNip01Auth(webSocket: WebSocket, challenge: String): Boolean {
val secret = sessionInfo.session.optString("secret", "")
if (secret.isEmpty()) return false
val signed = signWithNip01Secret(secret, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
return sendAuthMessage(webSocket, signed)
}
private fun tryNip55Auth(webSocket: WebSocket, challenge: String): Boolean {
val signerPackage = sessionInfo.session.optString("signer", "")
if (signerPackage.isEmpty()) return false
val signed = signWithNip55ContentResolver(signerPackage, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
return sendAuthMessage(webSocket, signed)
}
private fun tryNip46Auth(webSocket: WebSocket, challenge: String): Boolean {
val handler = sessionInfo.session.optJSONObject("handler") ?: return false
val clientSecret = sessionInfo.session.optString("secret", "")
val signerPubkey = handler.optString("pubkey", "")
val relays = handler.optJSONArray("relays")
if (clientSecret.isEmpty() || signerPubkey.isEmpty() || relays == null || relays.length() == 0) return false
val clientPubkey = deriveXOnlyPubkey(clientSecret)
if (clientPubkey.isEmpty()) return false
val authEventJson = buildAuthEvent(challenge).toString()
nip46InFlight = true
var success = false
try {
for (i in 0 until relays.length()) {
val signerRelay = relays.optString(i, "").trim()
if (!signerRelay.startsWith("wss://") && !signerRelay.startsWith("ws://")) continue
if (tryNip46ViaRelay(webSocket, signerRelay, clientSecret, clientPubkey, signerPubkey, authEventJson)) { success = true; break }
}
} finally {
nip46InFlight = false
if (pendingDone) finish()
}
return success
}
private fun tryNip46ViaRelay(
relaySocket: WebSocket,
signerRelay: String,
clientSecret: String,
clientPubkey: String,
signerPubkey: String,
authEventJson: String,
): Boolean {
val localLatch = CountDownLatch(1)
val signedEvent = StringBuilder()
val requestId = UUID.randomUUID().toString().replace("-", "")
val signerSocket = pool.open(signerRelay, object : WebSocketListener() {
private var done = false
override fun onOpen(webSocket: WebSocket, response: Response) {
try {
val rpcEnvelope = JSONObject().apply {
put("kind", KIND_NIP46_RPC)
put("pubkey", clientPubkey)
put("created_at", System.currentTimeMillis() / 1000L)
put("content", encryptNip44(
JSONObject().apply {
put("id", requestId)
put("method", "sign_event")
put("params", JSONArray().apply { put(authEventJson) })
}.toString(),
nip44ConversationKey(clientSecret, signerPubkey),
))
put("id", "")
put("sig", "")
put("tags", JSONArray().apply { put(JSONArray().apply { put("p"); put(signerPubkey) }) })
}
val signedEnvelope = signWithNip01Secret(clientSecret, rpcEnvelope.toString(), clientPubkey)
if (signedEnvelope.isEmpty()) { finish(); return }
val sentAt = System.currentTimeMillis() / 1000L
send(webSocket, JSONArray().apply { put("EVENT"); put(JSONObject(signedEnvelope)) }.toString())
send(webSocket, JSONArray().apply {
put("REQ")
put(requestId)
put(JSONObject().apply {
put("#p", JSONArray().apply { put(clientPubkey) })
put("kinds", JSONArray().apply { put(KIND_NIP46_RPC) })
put("since", sentAt)
put("limit", 10)
})
}.toString())
} catch (_: Exception) {
finish()
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val message = JSONArray(text)
val msgType = message.optString(0, "")
Log.d(TAG, "NIP-46 signer message: $msgType from ${webSocket.request().url}")
if (msgType != "EVENT") return
val event = message.optJSONObject(2) ?: return
val tags = event.optJSONArray("tags")
var hasP = false
if (tags != null) {
for (i in 0 until tags.length()) {
val tag = tags.optJSONArray(i) ?: continue
if (tag.optString(0, "") == "p" && tag.optString(1, "") == clientPubkey) { hasP = true; break }
}
}
if (!hasP) { Log.d(TAG, "NIP-46 event missing p tag for client"); return }
val decryptedContent = decryptNip44(event.optString("content", ""), nip44ConversationKey(clientSecret, signerPubkey))
Log.d(TAG, "NIP-46 decrypted response: $decryptedContent")
if (decryptedContent.isEmpty()) return
val payload = JSONObject(decryptedContent)
if (requestId == payload.optString("id", "")) {
val result = payload.optString("result", "")
if (result.isNotEmpty()) {
signedEvent.setLength(0)
signedEvent.append(result)
finish()
}
}
} catch (e: Exception) {
Log.e(TAG, "NIP-46 signer message error", e)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
private fun finish() {
if (!done) { done = true; localLatch.countDown() }
}
})
try {
localLatch.await(5, TimeUnit.SECONDS)
} catch (_: InterruptedException) {
return false
}
if (signedEvent.isEmpty()) return false
val authEvent = JSONObject(signedEvent.toString())
authEventId = authEvent.optString("id", "")
val authMessage = JSONArray().apply { put("AUTH"); put(authEvent) }.toString()
Log.d(TAG, "NIP-46 sending AUTH to relay ${relaySocket.request().url}: $authMessage")
return try {
relaySocket.send(authMessage)
} catch (e: Exception) {
Log.e(TAG, "NIP-46 failed to send AUTH", e)
false
}
}
}
}
+2
View File
@@ -1,6 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '2.2.20'
repositories { repositories {
google() google()
@@ -9,6 +10,7 @@ buildscript {
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.13.2' classpath 'com.android.tools.build:gradle:8.13.2'
classpath 'com.google.gms:google-services:4.4.4' classpath 'com.google.gms:google-services:4.4.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
+3
View File
@@ -2,6 +2,9 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/android/capacitor')
include ':aparajita-capacitor-secure-storage'
project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android')
include ':capacitor-community-safe-area' include ':capacitor-community-safe-area'
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area/android') project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area/android')
+2 -5
View File
@@ -2,11 +2,8 @@
temp_env=$(declare -p -x) temp_env=$(declare -p -x)
if [ -f .env.template ]; then if [ -f .env ]; then
source .env.template source .env
fi
if [ -f .env.local ]; then
source .env.local
fi fi
# Avoid overwriting env vars provided directly # Avoid overwriting env vars provided directly
+3
View File
@@ -4,6 +4,9 @@ const config: CapacitorConfig = {
appId: "social.flotilla", appId: "social.flotilla",
appName: "Flotilla", appName: "Flotilla",
webDir: "build", webDir: "build",
ios: {
scheme: "Flotilla Chat",
},
android: { android: {
adjustMarginsForEdgeToEdge: true, adjustMarginsForEdgeToEdge: true,
}, },
+4 -4
View File
@@ -358,14 +358,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.6.5; MARKETING_VERSION = 1.7.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -385,14 +385,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.6.5; MARKETING_VERSION = 1.7.2;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
+8 -6
View File
@@ -20,8 +20,16 @@
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <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> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@@ -47,11 +55,5 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict> </dict>
</plist> </plist>
+1
View File
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area' pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem' pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
+14 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.6.5", "version": "1.7.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -42,6 +42,7 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@capacitor-community/safe-area": "^8.0.1", "@capacitor-community/safe-area": "^8.0.1",
"@capacitor/android": "^8.0.1", "@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
@@ -57,7 +58,7 @@
"@getalby/lightning-tools": "^6.1.0", "@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2", "@getalby/sdk": "^5.1.2",
"@noble/curves": "^1.9.7", "@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.1", "@pomade/core": "^0.2.2",
"@poppanator/sveltekit-svg": "^4.2.1", "@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2", "@tiptap/core": "^2.27.2",
@@ -65,16 +66,16 @@
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8", "@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.8.8", "@welshman/app": "^0.8.12",
"@welshman/content": "^0.8.8", "@welshman/content": "^0.8.12",
"@welshman/editor": "^0.8.8", "@welshman/editor": "^0.8.12",
"@welshman/feeds": "^0.8.8", "@welshman/feeds": "^0.8.12",
"@welshman/lib": "^0.8.8", "@welshman/lib": "^0.8.12",
"@welshman/net": "^0.8.8", "@welshman/net": "^0.8.12",
"@welshman/router": "^0.8.8", "@welshman/router": "^0.8.12",
"@welshman/signer": "^0.8.8", "@welshman/signer": "^0.8.12",
"@welshman/store": "^0.8.8", "@welshman/store": "^0.8.12",
"@welshman/util": "^0.8.8", "@welshman/util": "^0.8.12",
"compressorjs-next": "^1.1.2", "compressorjs-next": "^1.1.2",
"daisyui": "^4.12.24", "daisyui": "^4.12.24",
"date-picker-svelte": "^2.17.0", "date-picker-svelte": "^2.17.0",
@@ -83,6 +84,7 @@
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"idb": "^8.0.3", "idb": "^8.0.3",
"livekit-client": "^2.17.2",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main", "nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4", "nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.6.14",
+285 -115
View File
@@ -11,6 +11,9 @@ importers:
.: .:
dependencies: dependencies:
'@aparajita/capacitor-secure-storage':
specifier: ^8.0.0
version: 8.0.0
'@capacitor-community/safe-area': '@capacitor-community/safe-area':
specifier: ^8.0.1 specifier: ^8.0.1
version: 8.0.1(@capacitor/core@8.0.1) version: 8.0.1(@capacitor/core@8.0.1)
@@ -57,8 +60,8 @@ importers:
specifier: ^1.9.7 specifier: ^1.9.7
version: 1.9.7 version: 1.9.7
'@pomade/core': '@pomade/core':
specifier: ^0.2.1 specifier: ^0.2.2
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.2(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
'@poppanator/sveltekit-svg': '@poppanator/sveltekit-svg':
specifier: ^4.2.1 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)) 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 +84,35 @@ importers:
specifier: ^0.6.8 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)) 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': '@welshman/app':
specifier: ^0.8.8 specifier: ^0.8.12
version: 0.8.8(b90dd618d8ad3ba87405490e903259ce) version: 0.8.12(3074ef6691f94dc03952d8dbc98013a7)
'@welshman/content': '@welshman/content':
specifier: ^0.8.8 specifier: ^0.8.12
version: 0.8.8(nostr-tools@2.20.0(typescript@5.9.3)) version: 0.8.12(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/editor': '@welshman/editor':
specifier: ^0.8.8 specifier: ^0.8.12
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)) version: 0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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': '@welshman/feeds':
specifier: ^0.8.8 specifier: ^0.8.12
version: 0.8.8(827c582d718d0d373e9315813bab1085) version: 0.8.12(d5b74f0c83250e052e0b96f7ff5804e8)
'@welshman/lib': '@welshman/lib':
specifier: ^0.8.8 specifier: ^0.8.12
version: 0.8.8 version: 0.8.12
'@welshman/net': '@welshman/net':
specifier: ^0.8.8 specifier: ^0.8.12
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) version: 0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': '@welshman/router':
specifier: ^0.8.8 specifier: ^0.8.12
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))) version: 0.8.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': '@welshman/signer':
specifier: ^0.8.8 specifier: ^0.8.12
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)) version: 0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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': '@welshman/store':
specifier: ^0.8.8 specifier: ^0.8.12
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) version: 0.8.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
'@welshman/util': '@welshman/util':
specifier: ^0.8.8 specifier: ^0.8.12
version: 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)) version: 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3))
compressorjs-next: compressorjs-next:
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2 version: 1.1.2
@@ -134,6 +137,9 @@ importers:
idb: idb:
specifier: ^8.0.3 specifier: ^8.0.3
version: 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: nostr-signer-capacitor-plugin:
specifier: github:coracle-social/nostr-signer-capacitor-plugin#main 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) version: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1)
@@ -229,6 +235,10 @@ packages:
'@antfu/utils@0.7.10': '@antfu/utils@0.7.10':
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
'@aparajita/capacitor-secure-storage@8.0.0':
resolution: {integrity: sha512-oYnwSjdIh23aRNgz8982+TmFvQH/2yZkEdw1iIg+H2ziFJoOVELPTc7u6Ez2HwOuDIW5AGqBX75GvrzQ+D70Qg==}
engines: {node: '>=20.0.0'}
'@apideck/better-ajv-errors@0.3.6': '@apideck/better-ajv-errors@0.3.6':
resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -737,6 +747,9 @@ packages:
'@braintree/sanitize-url@7.1.1': '@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
'@bufbuild/protobuf@1.10.1':
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
'@canvas/image-data@1.1.0': '@canvas/image-data@1.1.0':
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==} resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
@@ -750,6 +763,11 @@ packages:
peerDependencies: peerDependencies:
'@capacitor/core': ^8.0.0 '@capacitor/core': ^8.0.0
'@capacitor/android@8.2.0':
resolution: {integrity: sha512-XLm5OsWLPfXQxDxzFS7SOdMEgGvW+2c7TGLXkTR2cSKdkWK5Abns4imlT5qghKYhjM9r74IrDkBWg/9ALUGNKQ==}
peerDependencies:
'@capacitor/core': ^8.2.0
'@capacitor/app@8.0.0': '@capacitor/app@8.0.0':
resolution: {integrity: sha512-OwzIkUs4w433Bu9WWAEbEYngXEfJXZ9Wmdb8eoaqzYBgB0W9/3Ed/mh6sAYPNBAZlpyarmewgP7Nb+d3Vrh+xA==} resolution: {integrity: sha512-OwzIkUs4w433Bu9WWAEbEYngXEfJXZ9Wmdb8eoaqzYBgB0W9/3Ed/mh6sAYPNBAZlpyarmewgP7Nb+d3Vrh+xA==}
peerDependencies: peerDependencies:
@@ -773,6 +791,9 @@ packages:
'@capacitor/core@8.0.1': '@capacitor/core@8.0.1':
resolution: {integrity: sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==} resolution: {integrity: sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==}
'@capacitor/core@8.2.0':
resolution: {integrity: sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==}
'@capacitor/filesystem@8.1.0': '@capacitor/filesystem@8.1.0':
resolution: {integrity: sha512-AfawIxQ8xBmKsEn/vEpgurGQB9+hFXRtwEiCXR+SSS0MkTw4bJrvLGnloZ/PblegYefvnay1q079Yz3PQ6y1dA==} resolution: {integrity: sha512-AfawIxQ8xBmKsEn/vEpgurGQB9+hFXRtwEiCXR+SSS0MkTw4bJrvLGnloZ/PblegYefvnay1q079Yz3PQ6y1dA==}
peerDependencies: peerDependencies:
@@ -783,6 +804,11 @@ packages:
peerDependencies: peerDependencies:
'@capacitor/core': ^8.0.0 '@capacitor/core': ^8.0.0
'@capacitor/ios@8.2.0':
resolution: {integrity: sha512-X2/VtM4qP/R1SM0VQ5W/VotEc6PS/KTooD33EijsfAHWBdee+xmBapW8SeNLnu16wJ+tsfWlvtipaJEyfKbRKQ==}
peerDependencies:
'@capacitor/core': ^8.2.0
'@capacitor/keyboard@8.0.0': '@capacitor/keyboard@8.0.0':
resolution: {integrity: sha512-ycPW6iQyFwzDK95jihesj5EGiyyGSfbBqNek11iNp9tBOB7zDeYkUA2S/vPpOETt3dhP6pWr7a9gNVGuEfj11g==} resolution: {integrity: sha512-ycPW6iQyFwzDK95jihesj5EGiyyGSfbBqNek11iNp9tBOB7zDeYkUA2S/vPpOETt3dhP6pWr7a9gNVGuEfj11g==}
peerDependencies: peerDependencies:
@@ -1098,89 +1124,105 @@ packages:
resolution: {integrity: sha512-HWpu3wRqss0vqze56Y/peCrMOsILjoorwU0ZiqF4dYQIl03dD4k71tHstC2/y+7KqNtgb7+ItSdXJydfwspDyA==} resolution: {integrity: sha512-HWpu3wRqss0vqze56Y/peCrMOsILjoorwU0ZiqF4dYQIl03dD4k71tHstC2/y+7KqNtgb7+ItSdXJydfwspDyA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.3.0-rc.2': '@img/sharp-libvips-linux-arm@1.3.0-rc.2':
resolution: {integrity: sha512-tyXAQ0WCfXZf2dwm7F+IN/t/s324EcdpbW3dh8rwh8NHIkijeHGyiAHs45Bs8SnsTM/RjR+uPigxFMF/QYAiTw==} resolution: {integrity: sha512-tyXAQ0WCfXZf2dwm7F+IN/t/s324EcdpbW3dh8rwh8NHIkijeHGyiAHs45Bs8SnsTM/RjR+uPigxFMF/QYAiTw==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.3.0-rc.2': '@img/sharp-libvips-linux-ppc64@1.3.0-rc.2':
resolution: {integrity: sha512-lfJrlawp2PjxBu3Nh/2EOsgigNgr2o8MOG3XS2ibkKpJ3K/1YcUu9sTQV0S/n8+ak2R9MmJ3uTJqRVjdYkwWxg==} resolution: {integrity: sha512-lfJrlawp2PjxBu3Nh/2EOsgigNgr2o8MOG3XS2ibkKpJ3K/1YcUu9sTQV0S/n8+ak2R9MmJ3uTJqRVjdYkwWxg==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.3.0-rc.2': '@img/sharp-libvips-linux-riscv64@1.3.0-rc.2':
resolution: {integrity: sha512-2WUcL/k7uk6i5ZSXCQmOGgGxwsfKEtJA28vNfFb6gT+Zv7vdAMbnjjGMFRtTT7RUcFHgN2olvhfnEjtIMY49MQ==} resolution: {integrity: sha512-2WUcL/k7uk6i5ZSXCQmOGgGxwsfKEtJA28vNfFb6gT+Zv7vdAMbnjjGMFRtTT7RUcFHgN2olvhfnEjtIMY49MQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.3.0-rc.2': '@img/sharp-libvips-linux-s390x@1.3.0-rc.2':
resolution: {integrity: sha512-wx8/WmVA+kwLfwEN6UzjFlRz6erSibq4nGItfH3Nv+OITCjx8pH3Sl67T0tbwjU3M24GOcDFBIJ6rB+2oXbMzQ==} resolution: {integrity: sha512-wx8/WmVA+kwLfwEN6UzjFlRz6erSibq4nGItfH3Nv+OITCjx8pH3Sl67T0tbwjU3M24GOcDFBIJ6rB+2oXbMzQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.3.0-rc.2': '@img/sharp-libvips-linux-x64@1.3.0-rc.2':
resolution: {integrity: sha512-Rk8oOssrmTzhiuhDMPCw5Gadd4/mj5QPqrkxbg0R1VEaQeNo51d4YNbIokEDp2PqSTo+unUs6SHN8prkFKHP5A==} resolution: {integrity: sha512-Rk8oOssrmTzhiuhDMPCw5Gadd4/mj5QPqrkxbg0R1VEaQeNo51d4YNbIokEDp2PqSTo+unUs6SHN8prkFKHP5A==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.3.0-rc.2': '@img/sharp-libvips-linuxmusl-arm64@1.3.0-rc.2':
resolution: {integrity: sha512-K2TaPlrPox8uf3K01R8S+AfhPqVVWlEK6+RxkJNGasN0k1iFhu9hMhWl7+sEiWj2V30TKcppRqQWUv7H3qym9w==} resolution: {integrity: sha512-K2TaPlrPox8uf3K01R8S+AfhPqVVWlEK6+RxkJNGasN0k1iFhu9hMhWl7+sEiWj2V30TKcppRqQWUv7H3qym9w==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.3.0-rc.2': '@img/sharp-libvips-linuxmusl-x64@1.3.0-rc.2':
resolution: {integrity: sha512-88HtWiP7sBX6rb1Hw7cf3H+1ufkB+YocfeMFtmAg6oOc8hvNcby8MVPyE7GL+YD7LKPBadcScaeVnGzYWYRaMQ==} resolution: {integrity: sha512-88HtWiP7sBX6rb1Hw7cf3H+1ufkB+YocfeMFtmAg6oOc8hvNcby8MVPyE7GL+YD7LKPBadcScaeVnGzYWYRaMQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.35.0-rc.0': '@img/sharp-linux-arm64@0.35.0-rc.0':
resolution: {integrity: sha512-6pdCj+H0+sNsX7vpbxF2FhDF+fe7Hc/mfjlG5caFVUrACIMMOMc962xTE0Y1+XE3EWM8hUmGp3y6hISjJDnwYA==} resolution: {integrity: sha512-6pdCj+H0+sNsX7vpbxF2FhDF+fe7Hc/mfjlG5caFVUrACIMMOMc962xTE0Y1+XE3EWM8hUmGp3y6hISjJDnwYA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.35.0-rc.0': '@img/sharp-linux-arm@0.35.0-rc.0':
resolution: {integrity: sha512-oy0+atKDov9vn9mMVlyS0V2BTKTFzR3cFhZ9ilF98vnGSvrWdevUlfjNdTE5pN/xZq6z0GnkIAUL5KJhwh5yzQ==} resolution: {integrity: sha512-oy0+atKDov9vn9mMVlyS0V2BTKTFzR3cFhZ9ilF98vnGSvrWdevUlfjNdTE5pN/xZq6z0GnkIAUL5KJhwh5yzQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.35.0-rc.0': '@img/sharp-linux-ppc64@0.35.0-rc.0':
resolution: {integrity: sha512-SvX6E6h/i/zhhGheJiSHbFb3loglNDi+H6wEpnPdp5SGlyiJabLVco93kHBeo4lkfQAwYACRW+yFhRfNhhZO3Q==} resolution: {integrity: sha512-SvX6E6h/i/zhhGheJiSHbFb3loglNDi+H6wEpnPdp5SGlyiJabLVco93kHBeo4lkfQAwYACRW+yFhRfNhhZO3Q==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.35.0-rc.0': '@img/sharp-linux-riscv64@0.35.0-rc.0':
resolution: {integrity: sha512-EY+6k/u87hfZgBXJaNNRwEN1ACPHUudcW2ObVmNA1eADnFircVvdPjvKUCc5bVwzjmG1imzNJWoapIB9wZheFQ==} resolution: {integrity: sha512-EY+6k/u87hfZgBXJaNNRwEN1ACPHUudcW2ObVmNA1eADnFircVvdPjvKUCc5bVwzjmG1imzNJWoapIB9wZheFQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.35.0-rc.0': '@img/sharp-linux-s390x@0.35.0-rc.0':
resolution: {integrity: sha512-owTWB3KiGs03QpcHFGNBToB5DIEuCsHugzVS7h8Za6f2W0Nw8qedJF5oSIr71nBj4jF4NmTMd5w7EFOOKn8pFA==} resolution: {integrity: sha512-owTWB3KiGs03QpcHFGNBToB5DIEuCsHugzVS7h8Za6f2W0Nw8qedJF5oSIr71nBj4jF4NmTMd5w7EFOOKn8pFA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.35.0-rc.0': '@img/sharp-linux-x64@0.35.0-rc.0':
resolution: {integrity: sha512-XQoXfEHwz0TTtf6DMT7rLXNc7qb7okjSe+8vzusdTuqVXBi+km+Jwvc9DKL9azMWKR4TR/ArvQ7Y5dFnckb9VA==} resolution: {integrity: sha512-XQoXfEHwz0TTtf6DMT7rLXNc7qb7okjSe+8vzusdTuqVXBi+km+Jwvc9DKL9azMWKR4TR/ArvQ7Y5dFnckb9VA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.35.0-rc.0': '@img/sharp-linuxmusl-arm64@0.35.0-rc.0':
resolution: {integrity: sha512-hfCXVq35g/zA+Lpa3x1gUXHLi0rLWUPbVGpbPox2zyx2byfKXf5Lcq6xoMJUrQkmT+s8BaOP8TbmhG8ZQgUFyw==} resolution: {integrity: sha512-hfCXVq35g/zA+Lpa3x1gUXHLi0rLWUPbVGpbPox2zyx2byfKXf5Lcq6xoMJUrQkmT+s8BaOP8TbmhG8ZQgUFyw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.35.0-rc.0': '@img/sharp-linuxmusl-x64@0.35.0-rc.0':
resolution: {integrity: sha512-TsV3KFF9i2wKHHsXnRz9N5H66swuDqpk+KPiTugbtdTo+um33BoFZC7F+Ty+DBoOWf4TT+j7IiJfpen5bC5FRw==} resolution: {integrity: sha512-TsV3KFF9i2wKHHsXnRz9N5H66swuDqpk+KPiTugbtdTo+um33BoFZC7F+Ty+DBoOWf4TT+j7IiJfpen5bC5FRw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.35.0-rc.0': '@img/sharp-wasm32@0.35.0-rc.0':
resolution: {integrity: sha512-5eRvTRqUbNDEd999tRRwXaEO5CERA1WDiVrNDgh+g0IlhCJ79jQkfTE+/dKEO8VbhUwVT6qWFjse+/3KjXhUKg==} resolution: {integrity: sha512-5eRvTRqUbNDEd999tRRwXaEO5CERA1WDiVrNDgh+g0IlhCJ79jQkfTE+/dKEO8VbhUwVT6qWFjse+/3KjXhUKg==}
@@ -1283,6 +1325,12 @@ packages:
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} 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': '@noble/ciphers@0.5.3':
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
@@ -1376,9 +1424,9 @@ packages:
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@pomade/core@0.2.1': '@pomade/core@0.2.2':
resolution: {integrity: sha512-zXpPQPkhVe7OchmRDe2MbHdUxiCSeUuMwrHOyeOBs/xD1EfY093Mwj6Cu/OLfz0wxivBDSp1GMMmxqKbLWam3Q==} resolution: {integrity: sha512-FoilLsO0gVjiKMW3LV63pmXU7x3gh8YVGVulyR6QJr4h47XrsBg8vPkZtKWr4+sH3sW31e2tNIPUb3ptiuhrMA==}
version: 0.2.1 version: 0.2.2
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
peerDependencies: peerDependencies:
'@frostr/bifrost': ^1.0.7 '@frostr/bifrost': ^1.0.7
@@ -1488,66 +1536,79 @@ packages:
resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.56.0': '@rollup/rollup-linux-arm-musleabihf@4.56.0':
resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.56.0': '@rollup/rollup-linux-arm64-gnu@4.56.0':
resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.56.0': '@rollup/rollup-linux-arm64-musl@4.56.0':
resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.56.0': '@rollup/rollup-linux-loong64-gnu@4.56.0':
resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.56.0': '@rollup/rollup-linux-loong64-musl@4.56.0':
resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.56.0': '@rollup/rollup-linux-ppc64-gnu@4.56.0':
resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.56.0': '@rollup/rollup-linux-ppc64-musl@4.56.0':
resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.56.0': '@rollup/rollup-linux-riscv64-gnu@4.56.0':
resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.56.0': '@rollup/rollup-linux-riscv64-musl@4.56.0':
resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.56.0': '@rollup/rollup-linux-s390x-gnu@4.56.0':
resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.56.0': '@rollup/rollup-linux-x64-gnu@4.56.0':
resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.56.0': '@rollup/rollup-linux-x64-musl@4.56.0':
resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.56.0': '@rollup/rollup-openbsd-x64@4.56.0':
resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==}
@@ -1670,30 +1731,35 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.10.0': '@tauri-apps/cli-linux-arm64-musl@2.10.0':
resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.10.0': '@tauri-apps/cli-linux-riscv64-gnu@2.10.0':
resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.10.0': '@tauri-apps/cli-linux-x64-gnu@2.10.0':
resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.10.0': '@tauri-apps/cli-linux-x64-musl@2.10.0':
resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.10.0': '@tauri-apps/cli-win32-arm64-msvc@2.10.0':
resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==}
@@ -1823,6 +1889,9 @@ packages:
'@types/cookie@0.6.0': '@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 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': '@types/eslint@9.6.1':
resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==}
@@ -1967,83 +2036,83 @@ packages:
'@vite-pwa/assets-generator': '@vite-pwa/assets-generator':
optional: true optional: true
'@welshman/app@0.8.8': '@welshman/app@0.8.12':
resolution: {integrity: sha512-pyySouAJwGZ2RSC29egiFft38Ctuioodon6xWFxB7HvJ9Llsh5b53qjkrQcAYM7lUAzXwtalf2v4Z3EwYdUObg==} resolution: {integrity: sha512-kRp+AVzn4i3FvZmdlyMknFUAb/5SnUz9A/cFKkDqWHsd+N3PbNcL2ZOlV9v5NI77GtsDF2ez6PEQfsZxWvkS/g==}
peerDependencies: peerDependencies:
'@pomade/core': ^0.1.3 '@pomade/core': ^0.2.1
'@welshman/feeds': 0.8.8 '@welshman/feeds': 0.8.12
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@welshman/net': 0.8.8 '@welshman/net': 0.8.12
'@welshman/router': 0.8.8 '@welshman/router': 0.8.12
'@welshman/signer': 0.8.8 '@welshman/signer': 0.8.12
'@welshman/store': 0.8.8 '@welshman/store': 0.8.12
'@welshman/util': 0.8.8 '@welshman/util': 0.8.12
svelte: ^4.0.0 || ^5.0.0 svelte: ^4.0.0 || ^5.0.0
'@welshman/content@0.8.8': '@welshman/content@0.8.12':
resolution: {integrity: sha512-5jh2YMoqINzkOEVSDZec6JbAqiC0WThwRuPwJOwiJlAFYQ4LC0MAT1HQ8z9pht/0TXdjYQUu2X+jngqqICNOiw==} resolution: {integrity: sha512-hviVTXdyGf04Xq7mGo/82fq6lnbyuUYOGPkf8pJqPkfGh0f3i9nKof6gkzPvjZeEYSczneI2GpLIxkaZ3w1/tw==}
peerDependencies: peerDependencies:
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@welshman/editor@0.8.8': '@welshman/editor@0.8.12':
resolution: {integrity: sha512-54WD2d6HEEiuoPgl/LeE4eaLtF2/SrYObk+IE9UUrJjoXcK/BK3vt8ltzazvBLR8ntfKOQINc4DhkeuBxiiCpA==} resolution: {integrity: sha512-CEXszH6pfM1kZHU9WnB6Z97YlxEOtgao6R3hhnBL/kRXy2tUTLpmFWyMONg2vu8Uzxtwz665eZhucLsju60U6w==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@welshman/util': 0.8.8 '@welshman/util': 0.8.12
nostr-editor: ^1.1.1 nostr-editor: ^1.1.1
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@welshman/feeds@0.8.8': '@welshman/feeds@0.8.12':
resolution: {integrity: sha512-o5JuptpWSNr6wtbM0RfSxTJgZStaNxPz160tE9u0SZzs1/a9sq/Yzesw7s+g0nKukRjBbl70DOqpTqOqfXAEIw==} resolution: {integrity: sha512-Dp/063qdrbe096z6IpIneazsqscfzSwsg09ZNHB6c3OsVb6iLEXLe7mEpATGcRsZ8memuFNLVLIP51eVkBqPcQ==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@welshman/net': 0.8.8 '@welshman/net': 0.8.12
'@welshman/router': 0.8.8 '@welshman/router': 0.8.12
'@welshman/signer': 0.8.8 '@welshman/signer': 0.8.12
'@welshman/util': 0.8.8 '@welshman/util': 0.8.12
'@welshman/lib@0.8.8': '@welshman/lib@0.8.12':
resolution: {integrity: sha512-77ZfVtodV05276ceR8c+JdDFqhOpmy2W6PkgDYbnKstQzKb5TN6wBvcLKxJppTzWMeWbyi2JADsuOYvW1jpOSQ==} resolution: {integrity: sha512-7Y1GjAcABquWF47A1Jni5JdP+k0GH2yRmEbVhIU+0R0TubCwPAKS38J2LTvtuE9CJMX6hPS9IKEZS6qTOAaVuw==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
'@welshman/net@0.8.8': '@welshman/net@0.8.12':
resolution: {integrity: sha512-Rug3GzVzyABG21g++cCLOVXdjAieV6rJUZqstE8i/olZvOEWZpZ9R901DoUSDR07U2HTrAwHQrjgb1HmH4jiDQ==} resolution: {integrity: sha512-Ba71jwb8BBwUfPPtWHKYLB0HqeDYK64oqwxfo5bYldtPfGYrlD+a7lHFSZvNyOhvyXygCaIMnaye1QxWAHP8ng==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@welshman/util': 0.8.8 '@welshman/util': 0.8.12
'@welshman/router@0.8.8': '@welshman/router@0.8.12':
resolution: {integrity: sha512-j5O7F7KGQtOIvBJctEiUNcLfHBUnhHlYHxUx7ImPPurc1zLzt3JovvJJFubXMQoQ26D01DsK/AA1L5WZNebUhA==} resolution: {integrity: sha512-Rr7ryBNTvTvjoLsDRMKPuoNJbBv2MgyqN2338p4vVhPMK6MOGM3Nx1og0LpHDGiLlCFuPM8RpParpIWfNnWbKw==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@welshman/net': 0.8.8 '@welshman/net': 0.8.12
'@welshman/util': 0.8.8 '@welshman/util': 0.8.12
'@welshman/signer@0.8.8': '@welshman/signer@0.8.12':
resolution: {integrity: sha512-rswHrTdc1+yvAno2h3JELzjp+LCfiYfUr8ACvwSSHAqDwrtezppfh0WDEPaYBp2EVSJ6tKMM1sVey0quO63aMw==} resolution: {integrity: sha512-eO4mw2QOR2d2oCS4zgptkCgjC1s8X+1vNnXfWDbtlEwhk7PD4ySSCkpNVMeElq+uluLOugmKvgDc4gfnIH3p2A==}
version: 0.8.8 version: 0.8.12
peerDependencies: peerDependencies:
'@noble/curves': ^1.9.7 '@noble/curves': ^1.9.7
'@noble/hashes': ^2.0.1 '@noble/hashes': ^2.0.1
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@welshman/net': 0.8.8 '@welshman/net': 0.8.12
'@welshman/util': 0.8.8 '@welshman/util': 0.8.12
nostr-signer-capacitor-plugin: '*' nostr-signer-capacitor-plugin: '*'
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@welshman/store@0.8.8': '@welshman/store@0.8.12':
resolution: {integrity: sha512-mTFueKZi9CtrtvCZT5eT5QaLMs94LxQg4y7oO5PZp9wv8EGSnB9p7XIflM0OfpKwF7c0pu1RdXcjVlvMDsC6QQ==} resolution: {integrity: sha512-3IUzPRMMVF6Pcw3DaKkfJ4S6bYzVtk6Ze3FnaHs1xJIxkQj9GyBO9VreiovULPW0djsnrP5+1UEN4mKZsG3hXg==}
peerDependencies: peerDependencies:
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@welshman/net': 0.8.8 '@welshman/net': 0.8.12
'@welshman/util': 0.8.8 '@welshman/util': 0.8.12
svelte: ^4.0.0 || ^5.0.0 svelte: ^4.0.0 || ^5.0.0
'@welshman/util@0.8.8': '@welshman/util@0.8.12':
resolution: {integrity: sha512-SNT1VXab6ce36EVfjs1A2uwWs5elYTI4eXi8SUuj42k8CqNIAtG+bOf/JFIxXNTfl3NSxxZdWzpLLZWBqgpAxQ==} resolution: {integrity: sha512-lgftFt2moXZdN5fuL0RoAnAARV0n0d2+Q56gt7KrBSevjoCbtJgBVX5idvxL5PCEfh81veovJtty6eHxrhQv5A==}
peerDependencies: peerDependencies:
'@noble/curves': ^1.9.7 '@noble/curves': ^1.9.7
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
nostr-tools: ^2.19.4 nostr-tools: ^2.19.4
'@xml-tools/parser@1.0.11': '@xml-tools/parser@1.0.11':
@@ -3334,6 +3403,9 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true hasBin: true
jose@6.2.1:
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
js-base64@3.7.8: js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
@@ -3438,6 +3510,11 @@ packages:
linkifyjs@4.3.2: linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} 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: load-json-file@4.0.0:
resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3472,6 +3549,10 @@ packages:
lodash@4.17.23: lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} 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: lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -4273,6 +4354,9 @@ packages:
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
sade@1.8.1: sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -4302,6 +4386,13 @@ packages:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'} 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: semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true hasBin: true
@@ -4703,6 +4794,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
typed-emitter@2.1.0:
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
typescript-eslint@8.53.1: typescript-eslint@8.53.1:
resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==} resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4847,6 +4941,10 @@ packages:
webidl-conversions@4.0.2: webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} 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: whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -5064,6 +5162,14 @@ snapshots:
'@antfu/utils@0.7.10': {} '@antfu/utils@0.7.10': {}
'@aparajita/capacitor-secure-storage@8.0.0':
dependencies:
'@capacitor/android': 8.2.0(@capacitor/core@8.2.0)
'@capacitor/app': 8.0.0(@capacitor/core@8.2.0)
'@capacitor/core': 8.2.0
'@capacitor/ios': 8.2.0(@capacitor/core@8.2.0)
'@capacitor/keyboard': 8.0.0(@capacitor/core@8.2.0)
'@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)': '@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)':
dependencies: dependencies:
ajv: 8.18.0 ajv: 8.18.0
@@ -5733,6 +5839,8 @@ snapshots:
'@braintree/sanitize-url@7.1.1': {} '@braintree/sanitize-url@7.1.1': {}
'@bufbuild/protobuf@1.10.1': {}
'@canvas/image-data@1.1.0': {} '@canvas/image-data@1.1.0': {}
'@capacitor-community/safe-area@8.0.1(@capacitor/core@8.0.1)': '@capacitor-community/safe-area@8.0.1(@capacitor/core@8.0.1)':
@@ -5743,10 +5851,18 @@ snapshots:
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@capacitor/core': 8.0.1
'@capacitor/android@8.2.0(@capacitor/core@8.2.0)':
dependencies:
'@capacitor/core': 8.2.0
'@capacitor/app@8.0.0(@capacitor/core@8.0.1)': '@capacitor/app@8.0.0(@capacitor/core@8.0.1)':
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@capacitor/core': 8.0.1
'@capacitor/app@8.0.0(@capacitor/core@8.2.0)':
dependencies:
'@capacitor/core': 8.2.0
'@capacitor/assets@3.0.5(@types/node@25.0.10)(typescript@5.9.3)': '@capacitor/assets@3.0.5(@types/node@25.0.10)(typescript@5.9.3)':
dependencies: dependencies:
'@capacitor/cli': 5.7.8 '@capacitor/cli': 5.7.8
@@ -5817,6 +5933,10 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@capacitor/core@8.2.0':
dependencies:
tslib: 2.8.1
'@capacitor/filesystem@8.1.0(@capacitor/core@8.0.1)': '@capacitor/filesystem@8.1.0(@capacitor/core@8.0.1)':
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@capacitor/core': 8.0.1
@@ -5826,10 +5946,18 @@ snapshots:
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@capacitor/core': 8.0.1
'@capacitor/ios@8.2.0(@capacitor/core@8.2.0)':
dependencies:
'@capacitor/core': 8.2.0
'@capacitor/keyboard@8.0.0(@capacitor/core@8.0.1)': '@capacitor/keyboard@8.0.0(@capacitor/core@8.0.1)':
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@capacitor/core': 8.0.1
'@capacitor/keyboard@8.0.0(@capacitor/core@8.2.0)':
dependencies:
'@capacitor/core': 8.2.0
'@capacitor/preferences@8.0.0(@capacitor/core@8.0.1)': '@capacitor/preferences@8.0.0(@capacitor/core@8.0.1)':
dependencies: dependencies:
'@capacitor/core': 8.0.1 '@capacitor/core': 8.0.1
@@ -6298,6 +6426,12 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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@0.5.3': {}
'@noble/ciphers@1.3.0': {} '@noble/ciphers@1.3.0': {}
@@ -6436,15 +6570,15 @@ snapshots:
'@polka/url@1.0.0-next.29': {} '@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.2(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies: dependencies:
'@frostr/bifrost': 1.0.7(typescript@5.9.3) '@frostr/bifrost': 1.0.7(typescript@5.9.3)
'@noble/hashes': 2.0.1 '@noble/hashes': 2.0.1
'@peculiar/x509': 1.14.3 '@peculiar/x509': 1.14.3
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@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.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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/signer': 0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3))
cbor-x: 1.6.0 cbor-x: 1.6.0
hash-wasm: 4.12.0 hash-wasm: 4.12.0
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
@@ -6859,6 +6993,8 @@ snapshots:
'@types/cookie@0.6.0': {} '@types/cookie@0.6.0': {}
'@types/dom-mediacapture-record@1.0.22': {}
'@types/eslint@9.6.1': '@types/eslint@9.6.1':
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -7031,26 +7167,26 @@ snapshots:
optionalDependencies: optionalDependencies:
'@vite-pwa/assets-generator': 0.2.6 '@vite-pwa/assets-generator': 0.2.6
'@welshman/app@0.8.8(b90dd618d8ad3ba87405490e903259ce)': '@welshman/app@0.8.12(3074ef6691f94dc03952d8dbc98013a7)':
dependencies: 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)) '@pomade/core': 0.2.2(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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/feeds': 0.8.12(d5b74f0c83250e052e0b96f7ff5804e8)
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@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.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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/router': 0.8.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3))
fuse.js: 7.1.0 fuse.js: 7.1.0
svelte: 5.48.0 svelte: 5.48.0
throttle-debounce: 5.0.2 throttle-debounce: 5.0.2
'@welshman/content@0.8.8(nostr-tools@2.20.0(typescript@5.9.3))': '@welshman/content@0.8.12(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies: dependencies:
'@braintree/sanitize-url': 7.1.1 '@braintree/sanitize-url': 7.1.1
nostr-tools: 2.20.0(typescript@5.9.3) 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.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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: dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) '@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)) '@tiptap/extension-code': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
@@ -7065,64 +7201,64 @@ snapshots:
'@tiptap/extension-text': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) '@tiptap/extension-text': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@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) '@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/lib': 0.8.12
'@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.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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-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) nostr-tools: 2.20.0(typescript@5.9.3)
tippy.js: 6.3.7 tippy.js: 6.3.7
'@welshman/feeds@0.8.8(827c582d718d0d373e9315813bab1085)': '@welshman/feeds@0.8.12(d5b74f0c83250e052e0b96f7ff5804e8)':
dependencies: dependencies:
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@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.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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/router': 0.8.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3))
trava: 1.2.1 trava: 1.2.1
'@welshman/lib@0.8.8': '@welshman/lib@0.8.12':
dependencies: dependencies:
'@scure/base': 1.2.6 '@scure/base': 1.2.6
'@types/events': 3.0.3 '@types/events': 3.0.3
events: 3.3.0 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.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)':
dependencies: dependencies:
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@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.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3))
events: 3.3.0 events: 3.3.0
isomorphic-ws: 5.0.0(ws@8.18.3) isomorphic-ws: 5.0.0(ws@8.18.3)
transitivePeerDependencies: transitivePeerDependencies:
- ws - 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.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))':
dependencies: dependencies:
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@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.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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.12(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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: dependencies:
'@noble/curves': 1.9.7 '@noble/curves': 1.9.7
'@noble/hashes': 2.0.1 '@noble/hashes': 2.0.1
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@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.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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-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) 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.12(@welshman/lib@0.8.12)(@welshman/net@0.8.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)':
dependencies: dependencies:
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
'@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.12(@welshman/lib@0.8.12)(@welshman/util@0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(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/util': 0.8.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3))
svelte: 5.48.0 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.12(@noble/curves@1.9.7)(@welshman/lib@0.8.12)(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies: dependencies:
'@noble/curves': 1.9.7 '@noble/curves': 1.9.7
'@types/ws': 8.18.1 '@types/ws': 8.18.1
'@welshman/lib': 0.8.8 '@welshman/lib': 0.8.12
js-base64: 3.7.8 js-base64: 3.7.8
nostr-tools: 2.20.0(typescript@5.9.3) nostr-tools: 2.20.0(typescript@5.9.3)
nostr-wasm: 0.1.0 nostr-wasm: 0.1.0
@@ -8530,6 +8666,8 @@ snapshots:
jiti@1.21.7: {} jiti@1.21.7: {}
jose@6.2.1: {}
js-base64@3.7.8: {} js-base64@3.7.8: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -8605,6 +8743,19 @@ snapshots:
linkifyjs@4.3.2: {} 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: load-json-file@4.0.0:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -8637,6 +8788,8 @@ snapshots:
lodash@4.17.23: {} lodash@4.17.23: {}
loglevel@1.9.2: {}
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
lru-cache@11.2.4: {} lru-cache@11.2.4: {}
@@ -9427,6 +9580,11 @@ snapshots:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
optional: true
sade@1.8.1: sade@1.8.1:
dependencies: dependencies:
mri: 1.2.0 mri: 1.2.0
@@ -9458,6 +9616,10 @@ snapshots:
sax@1.4.4: {} sax@1.4.4: {}
sdp-transform@2.15.0: {}
sdp@3.2.1: {}
semver@5.7.2: {} semver@5.7.2: {}
semver@6.3.1: {} semver@6.3.1: {}
@@ -9982,6 +10144,10 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 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): typescript-eslint@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies: 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) '@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 +10256,10 @@ snapshots:
webidl-conversions@4.0.2: {} webidl-conversions@4.0.2: {}
webrtc-adapter@9.0.4:
dependencies:
sdp: 3.2.1
whatwg-url@5.0.0: whatwg-url@5.0.0:
dependencies: dependencies:
tr46: 0.0.3 tr46: 0.0.3
+1 -1
View File
@@ -2,7 +2,7 @@ import dotenv from "dotenv"
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config" import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
dotenv.config({path: ".env.local"}) dotenv.config({path: ".env.local"})
dotenv.config({path: ".env.template"}) dotenv.config({path: ".env"})
export default defineConfig({ export default defineConfig({
preset, preset,
+7 -19
View File
@@ -390,28 +390,12 @@ progress[value]::-webkit-progress-value {
/* content width for fixed elements */ /* content width for fixed elements */
.cw { .left-content {
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))]; @apply md:left-[calc(18.5rem+var(--sail))];
}
.cw-full {
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
}
.cb {
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
}
.ct {
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
} }
/* Keyboard open state adjustments */ /* Keyboard open state adjustments */
body.keyboard-open .cb {
@apply bottom-sai;
}
body.keyboard-open .hide-on-keyboard { body.keyboard-open .hide-on-keyboard {
display: none; display: none;
} }
@@ -419,7 +403,11 @@ body.keyboard-open .hide-on-keyboard {
/* chat view */ /* chat view */
.chat__compose { .chat__compose {
@apply cb cw fixed z-compose; @apply relative z-compose mb-14 flex-grow md:mb-0;
}
.chat__compose .chat__compose-inner {
@apply min-w-0;
} }
.chat__scroll-down { .chat__scroll-down {
+3 -4
View File
@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {page} from "$app/stores"
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
import Dialog from "@lib/components/Dialog.svelte" import Dialog from "@lib/components/Dialog.svelte"
import Landing from "@app/components/Landing.svelte" import Landing from "@app/components/Landing.svelte"
import Toast from "@app/components/Toast.svelte" import Toast from "@app/components/Toast.svelte"
import PrimaryNav from "@app/components/PrimaryNav.svelte" import PrimaryNav from "@app/components/PrimaryNav.svelte"
import {modals} from "@app/util/modal" import {modal} from "@app/util/modal"
interface Props { interface Props {
children: Snippet children: Snippet
@@ -20,8 +19,8 @@
<PrimaryNav> <PrimaryNav>
{@render children?.()} {@render children?.()}
</PrimaryNav> </PrimaryNav>
{:else if !$modals[$page.url.hash.slice(1)]} {:else if !$modal}
<Dialog children={{component: Landing, props: {}}} /> <Dialog noEscape children={{component: Landing, props: {}}} />
{/if} {/if}
</div> </div>
<Toast /> <Toast />
+175 -143
View File
@@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {onMount} from "svelte" import {onMount} from "svelte"
import {goto} from "$app/navigation"
import { import {
ago,
int, int,
ms, ms,
partition, partition,
ifLet,
spec, spec,
nthEq, nthEq,
nthNe, nthNe,
@@ -32,25 +35,27 @@
messagingRelayListsByPubkey, messagingRelayListsByPubkey,
} from "@welshman/app" } from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl" import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMembers from "@app/components/ChatMembers.svelte" import ChatMembers from "@app/components/ChatMembers.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte" import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChatCompose.svelte" import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte" import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import {userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state" import {userSettingsValues, deriveChat} from "@app/core/state"
import {pushModal} from "@app/util/modal" 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" import {pushToast} from "@app/util/toast"
type Props = { type Props = {
@@ -62,13 +67,15 @@
const chat = deriveChat(pubkeys) const chat = deriveChat(pubkeys)
const others = remove($pubkey!, pubkeys) const others = remove($pubkey!, pubkeys)
const missingRelayLists = $derived(pubkeys.filter(pk => !$messagingRelayListsByPubkey.has(pk))) const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
const showMembers = () => const showMembers = () =>
others.length === 1 others.length === 1
? pushModal(ProfileDetail, {pubkey: others[0]}) ? pushModal(ProfileDetail, {pubkey: others[0]})
: pushModal(ChatMembers, {pubkeys: others}) : pushModal(ChatMembers, {pubkeys: others})
const back = () => goto("/chat")
const replyTo = (event: TrustedEvent) => { const replyTo = (event: TrustedEvent) => {
parent = event parent = event
compose?.focus() compose?.focus()
@@ -78,75 +85,117 @@
parent = undefined parent = undefined
} }
const onSubmit = async (params: EventContent) => { const clearEventToEdit = () => {
const ptags = remove($pubkey!, pubkeys).map(tagPubkey) eventToEdit = undefined
// 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 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,
pow: 16,
})
}
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),
pow: 16,
}),
),
)
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 loading = $state(true)
let compose: ChatCompose | undefined = $state() let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state() let parent: TrustedEvent | undefined = $state()
let chatCompose: HTMLElement | undefined = $state() let eventToEdit: TrustedEvent | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
const elements = $derived.by(() => { const elements = $derived.by(() => {
const elements = [] const elements = []
@@ -182,20 +231,6 @@
for (const pubkey of others) { for (const pubkey of others) {
loadMessagingRelayList(pubkey) loadMessagingRelayList(pubkey)
} }
const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) {
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
}
})
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
return () => {
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
}) })
setTimeout(() => { setTimeout(() => {
@@ -204,75 +239,58 @@
</script> </script>
<PageBar> <PageBar>
{#snippet title()} <div class="flex">
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}> <Button onclick={back} class="place-self-start pr-3 md:hidden flex items-center">
{#if others.length === 0} <Icon icon={ArrowLeft} size={7} />
<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">
<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> </Button>
{/snippet} <div class="flex items-center justify-between gap-4">
{#snippet action()} <div class="ellipsize flex items-center gap-4 whitespace-nowrap">
{#if remove($pubkey, missingRelayLists).length > 0} <Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
{@const count = remove($pubkey, missingRelayLists).length} {#if others.length === 0}
{@const label = count > 1 ? "lists are" : "list is"} <div class="row-2">
<div <ProfileCircle pubkey={$pubkey!} size={5} />
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer" <ProfileName pubkey={$pubkey!} />
data-tip="{count} messaging {label} not configured."> </div>
<Icon icon={Danger} /> {:else if others.length === 1}
{count} <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">
<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> </div>
{/if} </div>
{/snippet} </div>
</PageBar> </PageBar>
<PageContent class="flex flex-col-reverse gap-2 pt-4"> <PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div> {#if missingRelayLists.length > 0}
{#if missingRelayLists.includes($pubkey!)}
<div class="py-12"> <div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center"> <div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error"> <p class="row-2 text-lg text-error">
<Icon icon={Danger} /> <Icon icon={Danger} />
Your messaging relays are not configured. Direct messages are not enabled
</p> </p>
<p> <p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit Ask
your <Link class="link" href="/settings/relays">relay settings page</Link> to receive messages. {#each missingRelayLists as pubkey (pubkey)}
</p> <ProfileLink {pubkey} />
</div> {/each}
</div> to enable direct messaging by opening this conversation in their app.
{:else if missingRelayLists.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error">
<Icon icon={Danger} />
{missingRelayLists.length} messaging
{missingRelayLists.length > 1 ? "lists are" : "list is"} not configured.
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
sure everyone in this conversation has set up their messaging relays.
</p> </p>
</div> </div>
</div> </div>
@@ -285,7 +303,9 @@
event={$state.snapshot(value as TrustedEvent)} event={$state.snapshot(value as TrustedEvent)}
{pubkeys} {pubkeys}
{showPubkey} {showPubkey}
{replyTo} /> {replyTo}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if} {/if}
{/each} {/each}
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center"> <p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
@@ -298,13 +318,25 @@
</Spinner> </Spinner>
{@render info?.()} {@render info?.()}
</p> </p>
<div class="h-screen"></div>
</PageContent> </PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}> <div class="chat__compose bg-base-200">
<div> <div>
{#if parent} {#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" /> <ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if} {/if}
{#if eventToEdit}
<ChatComposeEdit clear={clearEventToEdit} />
{/if}
</div> </div>
<ChatCompose bind:this={compose} {onSubmit} /> {#key eventToEdit}
<ChatCompose
bind:this={compose}
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
disabled={Boolean(missingRelayLists.length)} />
{/key}
</div> </div>
+42 -6
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onDestroy, onMount} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import cx from "classnames"
import type {EventContent} from "@welshman/util" import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl" import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@@ -10,21 +12,44 @@
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
type Props = { type Props = {
content?: string
disabled?: boolean
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void onSubmit: (event: EventContent) => void
} }
const {onSubmit}: Props = $props() const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
const autofocus = !isMobile const autofocus = !isMobile && !disabled
const uploading = writable(false) const uploading = writable(false)
const editorClass = $derived(
cx("chat-editor flex-grow overflow-hidden", {
"pointer-events-none opacity-50": disabled,
}),
)
export const focus = () => editor.then(ed => ed.chain().focus().run()) 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 uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => { const submit = async () => {
if ($uploading) return if ($uploading || disabled) return
const ed = await editor const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim() const content = ed.getText({blockSeparator: "\n"}).trim()
@@ -38,19 +63,30 @@
} }
const editor = makeEditor({ const editor = makeEditor({
content,
autofocus, autofocus,
submit, submit,
uploading, uploading,
aggressive: true, aggressive: true,
encryptFiles: 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> </script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}> <form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200" class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
disabled={$uploading} disabled={$uploading || disabled}
onclick={uploadFiles}> onclick={uploadFiles}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
@@ -58,13 +94,13 @@
<Icon icon={GallerySend} /> <Icon icon={GallerySend} />
{/if} {/if}
</Button> </Button>
<div class="chat-editor flex-grow overflow-hidden"> <div class={editorClass} aria-disabled={disabled}>
<EditorContent {editor} /> <EditorContent {editor} />
</div> </div>
<Button <Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send" data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full" class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
disabled={$uploading} disabled={$uploading || disabled}
onclick={submit}> onclick={submit}>
<Icon icon={Plane} /> <Icon icon={Plane} />
</Button> </Button>
+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>
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import {getRelaysFromList} from "@welshman/util"
import {waitForThunkError, setMessagingRelays, userRelayList, setRelays} from "@welshman/app"
import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/core/state"
import {pushToast} from "@app/util/toast"
type Props = {
next: () => void
}
const {next}: Props = $props()
let loading = $state(false)
const back = () => history.back()
const enable = async () => {
loading = true
try {
if (getRelaysFromList($userRelayList).length === 0) {
const error = await waitForThunkError(await setRelays(DEFAULT_RELAYS.map(r => ["r", r])))
if (error) {
pushToast({theme: "error", message: error})
return
}
}
const error = await waitForThunkError(await setMessagingRelays(DEFAULT_MESSAGING_RELAYS))
if (error) {
pushToast({theme: "error", message: error})
return
}
await next()
} finally {
loading = false
}
}
</script>
<Modal tag="form" onsubmit={preventDefault(enable)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Enable direct messaging?</ModalTitle>
</ModalHeader>
<p>Direct messaging isn't currently enabled. Would you like to turn it on?</p>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Enable direct messaging</Spinner>
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</Modal>
+5 -4
View File
@@ -5,11 +5,11 @@
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {pubkey, loadMessagingRelayList} from "@welshman/app" import {pubkey, loadMessagingRelayList} from "@welshman/app"
import {fade} from "@lib/transition" import {fade} from "@lib/transition"
import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {makeChatPath} from "@app/util/routes" import {makeChatPath, goToChat} from "@app/util/routes"
import {notifications} from "@app/util/notifications" import {notifications} from "@app/util/notifications"
interface Props { interface Props {
@@ -24,6 +24,7 @@
const others = uniq(remove($pubkey!, props.pubkeys)) const others = uniq(remove($pubkey!, props.pubkeys))
const active = $derived($page.params.chat === props.id) const active = $derived($page.params.chat === props.id)
const path = makeChatPath(props.pubkeys) const path = makeChatPath(props.pubkeys)
const openChat = () => goToChat(props.pubkeys)
onMount(() => { onMount(() => {
for (const pk of others) { for (const pk of others) {
@@ -32,7 +33,7 @@
}) })
</script> </script>
<Link class="flex flex-col justify-start gap-1" href={makeChatPath(props.pubkeys)}> <Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
<div <div
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}" class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
class:bg-base-100={active}> class:bg-base-100={active}>
@@ -71,4 +72,4 @@
</p> </p>
</div> </div>
</div> </div>
</Link> </Button>
+13 -6
View File
@@ -23,11 +23,13 @@
interface Props { interface Props {
event: TrustedEvent event: TrustedEvent
replyTo: (event: TrustedEvent) => void replyTo: (event: TrustedEvent) => void
canEdit?: (event: TrustedEvent) => boolean
onEdit?: (event: TrustedEvent) => void
pubkeys: string[] pubkeys: string[]
showPubkey?: boolean 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 isOwn = event.pubkey === $pubkey
const profileDisplay = deriveProfileDisplay(event.pubkey) const profileDisplay = deriveProfileDisplay(event.pubkey)
@@ -35,16 +37,21 @@
const [_, colorValue] = colors[hash(event.pubkey) % colors.length] const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
const reply = () => replyTo(event) const reply = () => replyTo(event)
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
const deleteReaction = (event: TrustedEvent) => const deleteReaction = (event: TrustedEvent) =>
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys}) sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys, pow: 16})
const createReaction = (template: EventContent) => const createReaction = (template: EventContent) =>
sendWrapped({event: makeReaction({event, protect: false, ...template}), recipients: pubkeys}) sendWrapped({
event: makeReaction({event, protect: false, ...template}),
recipients: pubkeys,
pow: 16,
})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply}) const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply, edit})
const togglePopover = () => { const togglePopover = () => {
if (popoverIsVisible) { if (popoverIsVisible) {
@@ -71,7 +78,7 @@
<Tippy <Tippy
bind:popover bind:popover
component={ChatMessageMenu} component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo}} props={{event, pubkeys, popover, replyTo, edit}}
params={{ params={{
interactive: true, interactive: true,
trigger: "manual", trigger: "manual",
@@ -93,7 +100,7 @@
{/if} {/if}
<div class="flex min-w-0 flex-col" class:items-end={isOwn}> <div class="flex min-w-0 flex-col" class:items-end={isOwn}>
<TapTarget <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}> onTap={showMobileMenu}>
{#if showPubkey} {#if showPubkey}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -18,6 +18,7 @@
sendWrapped({ sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}), event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys, recipients: pubkeys,
pow: 16,
}) })
</script> </script>
+8 -1
View File
@@ -4,12 +4,14 @@
import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte" import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import Pen from "@assets/icons/pen.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-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 reply = () => replyTo(event)
const onEdit = () => edit?.()
const showInfo = () => { const showInfo = () => {
popover.hide() popover.hide()
@@ -24,6 +26,11 @@
<Icon size={4} icon={Reply} /> <Icon size={4} icon={Reply} />
</Button> </Button>
{/if} {/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}> <Button class="btn join-item btn-xs" onclick={showInfo}>
<Icon size={4} icon={Code2} /> <Icon size={4} icon={Code2} />
</Button> </Button>
@@ -3,6 +3,7 @@
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app" import {sendWrapped} from "@welshman/app"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" 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 Reply from "@assets/icons/reply-2.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl" import Copy from "@assets/icons/copy.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl"
@@ -20,15 +21,17 @@
pubkeys: string[] pubkeys: string[]
event: TrustedEvent event: TrustedEvent
reply: () => void 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) => { const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back() history.back()
sendWrapped({ sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}), event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys, recipients: pubkeys,
pow: 16,
}) })
}).bind(undefined, event, pubkeys) }).bind(undefined, event, pubkeys)
@@ -39,6 +42,11 @@
reply() reply()
} }
const sendEdit = () => {
history.back()
edit?.()
}
const copyText = () => { const copyText = () => {
history.back() history.back()
clip(event.content) clip(event.content)
@@ -62,6 +70,12 @@
<Icon size={4} icon={Reply} /> <Icon size={4} icon={Reply} />
Send Reply Send Reply
</Button> </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}> <Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} /> <Icon size={4} icon={SmileCircle} />
Send Reaction Send Reaction
+2 -3
View File
@@ -2,7 +2,6 @@
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {onMount} from "svelte" import {onMount} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {goto} from "$app/navigation"
import {tryCatch, uniq} from "@welshman/lib" import {tryCatch, uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util" import {fromNostrURI} from "@welshman/util"
import {loadMessagingRelayList} from "@welshman/app" import {loadMessagingRelayList} from "@welshman/app"
@@ -19,11 +18,11 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte" import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {makeChatPath} from "@app/util/routes" import {goToChat} from "@app/util/routes"
const back = () => history.back() const back = () => history.back()
const onSubmit = () => goto(makeChatPath(pubkeys)) const onSubmit = () => goToChat(pubkeys)
const addPubkey = (pubkey: string) => { const addPubkey = (pubkey: string) => {
pubkeys = uniq([...pubkeys, pubkey]) pubkeys = uniq([...pubkeys, pubkey])
+1 -1
View File
@@ -20,7 +20,7 @@
const title = getTagValue("title", event.tags) const title = getTagValue("title", event.tags)
const h = getTagValue("h", event.tags) const h = getTagValue("h", event.tags)
const images = getTagValues("image", event.tags) const images = new Set(getTagValues("image", event.tags))
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || [] const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
</script> </script>
+10
View File
@@ -4,6 +4,7 @@
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl" import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl" import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl" import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -11,6 +12,7 @@
import ThreadCreate from "@app/components/ThreadCreate.svelte" import ThreadCreate from "@app/components/ThreadCreate.svelte"
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte" import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte" import GoalCreate from "@app/components/GoalCreate.svelte"
import PollCreate from "@app/components/PollCreate.svelte"
type Props = { type Props = {
url: string url: string
@@ -28,6 +30,8 @@
const createClassified = () => pushModal(ClassifiedCreate, {url, h}) const createClassified = () => pushModal(ClassifiedCreate, {url, h})
const createPoll = () => pushModal(PollCreate, {url, h})
let ul: Element let ul: Element
onMount(() => { onMount(() => {
@@ -60,4 +64,10 @@
Create Thread Create Thread
</Button> </Button>
</li> </li>
<li>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />
Ask a Question
</Button>
</li>
</ul> </ul>
+5 -1
View File
@@ -64,7 +64,11 @@
</script> </script>
<div bind:this={spacer}></div> <div bind:this={spacer}></div>
<form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3"> <form
in:fly
bind:this={form}
onsubmit={preventDefault(submit)}
class="left-content bottom-sai right-sai ml-2 pl-2 fixed z-feature">
<div class="card2 mx-2 my-2 bg-alt shadow-md"> <div class="card2 mx-2 my-2 bg-alt shadow-md">
<div class="relative"> <div class="relative">
<div class="note-editor flex-grow overflow-hidden"> <div class="note-editor flex-grow overflow-hidden">
+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>Your recovery codes have been sent!</p>
<p> <p>
For security reasons, you may receive three or more emails with recovery codes in them. Please 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> </p>
<StringMultiInput bind:value={otps} placeholder="Enter your recovery codes..." /> <StringMultiInput bind:value={otps} placeholder="Enter your recovery codes..." />
</ModalBody> </ModalBody>
+1 -1
View File
@@ -3,7 +3,7 @@
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer" import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app" import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
import Widget from "@assets/icons/widget-2.svg?dataurl" import Widget from "@assets/icons/widget-4.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl" import Letter from "@assets/icons/letter.svg?dataurl"
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl" import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
import Compass from "@assets/icons/compass-big.svg?dataurl" import Compass from "@assets/icons/compass-big.svg?dataurl"
+1
View File
@@ -77,6 +77,7 @@
controller.stop() controller.stop()
loginWithNip46(pubkey, clientSecret, signerPubkey, relays) loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
setChecked("*")
} else { } else {
return pushToast({ return pushToast({
theme: "error", theme: "error",
+1 -1
View File
@@ -86,7 +86,7 @@
<p>Your login codes have been sent!</p> <p>Your login codes have been sent!</p>
<p> <p>
For security reasons, you may receive three or more emails with login codes in them. Please 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> </p>
<StringMultiInput bind:value={otps} placeholder="Enter your login codes..." /> <StringMultiInput bind:value={otps} placeholder="Enter your login codes..." />
</ModalBody> </ModalBody>
+54 -108
View File
@@ -1,12 +1,9 @@
<script lang="ts"> <script lang="ts">
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import {pubkey} from "@welshman/app"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl" import Server from "@assets/icons/server.svg?dataurl"
import Moon from "@assets/icons/moon.svg?dataurl" import GalleryMinimalistic from "@assets/icons/gallery-minimalistic.svg?dataurl"
import Settings from "@assets/icons/settings-minimalistic.svg?dataurl" import Shield from "@assets/icons/shield-minimalistic.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl" import Bell from "@assets/icons/bell.svg?dataurl"
import Wallet from "@assets/icons/wallet.svg?dataurl" import Wallet from "@assets/icons/wallet.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -14,120 +11,69 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.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 LogOut from "@app/components/LogOut.svelte"
import {PLATFORM_NAME} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {theme} from "@app/util/theme" import {theme} from "@app/util/theme"
const back = () => history.back()
const logout = () => pushModal(LogOut) const logout = () => pushModal(LogOut)
const toggleTheme = () => theme.set($theme === "dark" ? "light" : "dark") const toggleTheme = () => theme.set($theme === "dark" ? "light" : "dark")
</script> </script>
<Modal> <Modal>
<ModalBody> <ModalBody>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-8 items-center py-12 max-w-[16rem] m-auto w-full">
<Link replaceState href="/settings/profile"> {#if $pubkey}
<CardButton class="btn-neutral"> <Link replaceState href="/settings/profile">
{#snippet icon()} <Profile inert pubkey={$pubkey} />
<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>
</Link> </Link>
{/if} {/if}
<Link replaceState href="/settings/relays"> <div class="grid grid-cols-3 gap-3 w-full">
<CardButton class="btn-neutral"> <Link
{#snippet icon()} replaceState
<div><Icon icon={Server} size={7} /></div> href="/settings/alerts"
{/snippet} class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
{#snippet title()} <Icon icon={Bell} size={5} />
<div>Relays</div> Alerts
{/snippet} </Link>
{#snippet info()} {#if Capacitor.getPlatform() !== "ios"}
<div>Control how {PLATFORM_NAME} talks to the network</div> <Link
{/snippet} replaceState
</CardButton> href="/settings/wallet"
</Link> class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Link replaceState href="/settings/content"> <Icon icon={Wallet} size={5} />
<CardButton class="btn-neutral"> Wallet
{#snippet icon()} </Link>
<div><Icon icon={Settings} size={7} /></div> {/if}
{/snippet} <Link
{#snippet title()} replaceState
<div>Settings</div> href="/settings/relays"
{/snippet} class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
{#snippet info()} <Icon icon={Server} size={5} />
<div>Get into the details about how {PLATFORM_NAME} works</div> Relays
{/snippet} </Link>
</CardButton> <Link
</Link> replaceState
<Button onclick={toggleTheme}> href="/settings/content"
<CardButton class="btn-neutral"> class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
{#snippet icon()} <Icon icon={GalleryMinimalistic} size={5} />
<div><Icon icon={Moon} size={7} /></div> Content
{/snippet} </Link>
{#snippet title()} <Link
<div>Theme</div> replaceState
{/snippet} href="/settings/privacy"
{#snippet info()} class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<div>Switch between light and dark mode</div> <Icon icon={Shield} size={5} />
{/snippet} Privacy
</CardButton> </Link>
</Button> </div>
<Link replaceState href="/settings/about"> <div class="flex gap-3 items-center opacity-75 text-sm">
<CardButton class="btn-neutral"> <Button onclick={toggleTheme}>Theme</Button>
{#snippet icon()} /
<div><Icon icon={Code2} size={7} /></div> <Link replaceState href="/settings/about">About</Link>
{/snippet} /
{#snippet title()} <Button onclick={logout}>Log Out</Button>
<div>About</div> </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> </div>
</ModalBody> </ModalBody>
</Modal> </Modal>
-32
View File
@@ -1,32 +0,0 @@
<script lang="ts">
import Link from "@lib/components/Link.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import {makeSpacePath} from "@app/util/routes"
import {notifications} from "@app/util/notifications"
const {url} = $props()
const path = makeSpacePath(url)
</script>
<Link replaceState href={path}>
<CardButton class="btn-neutral shadow-md bg-alt rounded-box border-none">
{#snippet icon()}
<RelayIcon {url} size={12} class="rounded-full" />
{/snippet}
{#snippet title()}
<div class="flex gap-1">
<RelayName {url} />
{#if $notifications.has(path)}
<div class="relative top-1 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</div>
{/snippet}
{#snippet info()}
<div><RelayDescription {url} /></div>
{/snippet}
</CardButton>
</Link>
+22 -13
View File
@@ -2,38 +2,47 @@
import {onMount, mount, unmount} from "svelte" import {onMount, mount, unmount} from "svelte"
import Drawer from "@lib/components/Drawer.svelte" import Drawer from "@lib/components/Drawer.svelte"
import Dialog from "@lib/components/Dialog.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) { if ($modal && !$modal.options.noEscape) {
clearModals() popModal()
} }
} }
const onKeyDown = (e: any) => { const onKeyDown = (e: any) => {
if (e.code === "Escape" && e.target === document.body) { if (e.code === "Escape" && e.target === document.body) {
closeModals() closeModal()
} }
} }
let element: HTMLElement let element: HTMLElement
let instance: any | undefined const instances: Record<string, any> = {}
onMount(() => { onMount(() => {
return modal.subscribe($modal => { return modalStack.subscribe($modalStack => {
if (instance) { const ids = $modalStack.map(({id}) => id)
unmount(instance, {outro: true})
instance = undefined for (const [id, instance] of Object.entries(instances)) {
if (!ids.includes(id)) {
unmount(instance, {outro: true})
delete instances[id]
}
} }
if ($modal) { for (const item of $modalStack) {
const {options, component, props} = $modal if (instances[item.id]) {
continue
}
const {options, component, props} = item
const wrapper = options.drawer ? Drawer : Dialog const wrapper = options.drawer ? Drawer : Dialog
instance = mount(wrapper as any, { instances[item.id] = mount(wrapper as any, {
target: element, target: element,
props: { props: {
onClose: closeModals, onClose: closeModal,
noEscape: options.noEscape,
fullscreen: options.fullscreen, fullscreen: options.fullscreen,
children: {component, props}, children: {component, props},
}, },
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {notificationSettings} from "@app/core/state" import {notificationSettings} from "@app/core/state"
import {onNotification} from "@app/util/notifications" import {onNotification} from "@app/util/push"
let audioElement: HTMLAudioElement let audioElement: HTMLAudioElement
+4
View File
@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import type {ComponentProps} from "svelte" import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util" import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte" import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
import NoteContentThread from "@app/components/NoteContentThread.svelte" import NoteContentThread from "@app/components/NoteContentThread.svelte"
import NoteContentClassified from "@app/components/NoteContentClassified.svelte" import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
import NoteContentGoal from "@app/components/NoteContentGoal.svelte" import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props() const props: ComponentProps<typeof Content> = $props()
@@ -19,6 +21,8 @@
<NoteContentClassified {...props} /> <NoteContentClassified {...props} />
{:else if props.event.kind === ZAP_GOAL} {:else if props.event.kind === ZAP_GOAL}
<NoteContentGoal {...props} /> <NoteContentGoal {...props} />
{:else if props.event.kind === Poll}
<NoteContentPoll {...props} />
{:else} {:else}
<Content {...props} /> <Content {...props} />
{/if} {/if}
@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import type {ComponentProps} from "svelte" import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util" import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte" import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte" import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte" import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte" import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte" import ContentMinimal from "@app/components/ContentMinimal.svelte"
const props: ComponentProps<typeof ContentMinimal> = $props() const props: ComponentProps<typeof ContentMinimal> = $props()
@@ -19,6 +21,8 @@
<NoteContentMinimalClassified {...props} /> <NoteContentMinimalClassified {...props} />
{:else if props.event.kind === ZAP_GOAL} {:else if props.event.kind === ZAP_GOAL}
<NoteContentMinimalGoal {...props} /> <NoteContentMinimalGoal {...props} />
{:else if props.event.kind === Poll}
<NoteContentMinimalPoll {...props} />
{:else} {:else}
<ContentMinimal {...props} /> <ContentMinimal {...props} />
{/if} {/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {derived} from "svelte/store"
import {PollResponse} from "nostr-tools/kinds"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {deriveEvents} from "@app/core/state"
import {getPollResults} from "@app/util/polls"
const props: ComponentProps<typeof ContentMinimal> = $props()
const responses = deriveEvents([{kinds: [PollResponse], "#e": [props.event.id]}])
const results = derived(responses, $responses => getPollResults(props.event, $responses))
</script>
<div class="flex flex-col gap-0">
<ContentMinimal {...props} />
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
</div>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {onMount} from "svelte"
import {request} from "@welshman/net"
import {PollResponse} from "nostr-tools/kinds"
import PollVotes from "@app/components/PollVotes.svelte"
import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props()
onMount(() => {
if (!props.url) {
return
}
request({
relays: [props.url],
filters: [{kinds: [PollResponse], "#e": [props.event.id]}],
})
})
</script>
<div class="flex flex-col gap-3">
<Content event={props.event} showEntire url={props.url} />
{#if props.url}
<PollVotes url={props.url} event={props.event} />
{/if}
</div>
+1 -1
View File
@@ -89,7 +89,7 @@
<p>Let's start by confirming your email.</p> <p>Let's start by confirming your email.</p>
<p> <p>
For security reasons, you may receive three or more emails with confirmation codes in them. 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> </p>
<StringMultiInput bind:value={otps} placeholder="Enter your confirmation codes..." /> <StringMultiInput bind:value={otps} placeholder="Enter your confirmation codes..." />
</ModalBody> </ModalBody>
+238
View File
@@ -0,0 +1,238 @@
<script lang="ts">
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
import {makeEvent} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {Poll} from "nostr-tools/kinds"
import {isMobile, preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
import PlusCircle from "@assets/icons/add-circle.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state"
import {canEnforceNip70} from "@app/core/commands"
import type {PollType} from "@app/util/polls"
type Props = {
url: string
h?: string
}
const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url)
type DraftOption = {
id: string
value: string
}
const back = () => history.back()
const addOption = () => {
options = [...options, {id: randomId(), value: ""}]
}
const removeOption = (id: string) => {
options = options.filter(option => option.id !== id)
}
const updateOption = (id: string, value: string) => {
options = options.map(option => (option.id === id ? {...option, value} : option))
}
const reorderOptions = (targetId: string) => {
if (!draggedOptionId) {
return
}
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
const targetIndex = options.findIndex(option => option.id === targetId)
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
return
}
options = insertAt(targetIndex, options[sourceIndex], removeAt(sourceIndex, options))
}
const onDragStart = (e: DragEvent, id: string) => {
draggedOptionId = id
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", id)
}
}
const onDragOver = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
}
const onDrop = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
draggedOptionId = undefined
}
const onDragEnd = () => {
draggedOptionId = undefined
}
const submit = async () => {
if (!title.trim()) {
return pushToast({theme: "error", message: "Please provide a title for your poll."})
}
const nonEmptyOptions = removeUndefined(options.map(option => option.value.trim() || undefined))
if (nonEmptyOptions.length < 2) {
return pushToast({theme: "error", message: "Please provide at least two options."})
}
if (endsAt && endsAt <= now()) {
return pushToast({theme: "error", message: "End time must be in the future."})
}
const tags: string[][] = [
...nonEmptyOptions.map(option => ["option", randomId(), option]),
["polltype", pollType],
["relay", url],
]
if (endsAt) {
tags.push(["endsAt", String(endsAt)])
}
if (h) {
tags.push(["h", h])
}
if (await shouldProtect) {
tags.push(PROTECTED)
}
publishThunk({
relays: [url],
event: makeEvent(Poll, {content: title.trim(), tags}),
})
history.back()
}
let title = $state("")
let pollType = $state<PollType>("singlechoice")
let endsAt = $state<number | undefined>()
let options = $state<DraftOption[]>([
{id: randomId(), value: "Yes"},
{id: randomId(), value: "No"},
])
let draggedOptionId = $state<string | undefined>()
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Poll</ModalTitle>
<ModalSubtitle>Ask a question and collect votes right in the feed.</ModalSubtitle>
</ModalHeader>
<div class="col-8 relative">
<Field>
{#snippet label()}
<p>Question*</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={title}
class="grow"
type="text"
placeholder="What would you like to ask?" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Options*</p>
{/snippet}
{#snippet input()}
<div class="flex flex-col gap-2" role="list">
{#each options as option, index (option.id)}
<div
class="flex items-center gap-2"
draggable="true"
role="listitem"
ondragstart={e => onDragStart(e, option.id)}
ondragover={e => onDragOver(e, option.id)}
ondrop={e => onDrop(e, option.id)}
ondragend={onDragEnd}>
<div class="cursor-move opacity-70" aria-label="Drag handle">
<Icon icon={HamburgerMenu} size={4} />
</div>
<label class="input input-bordered flex w-full items-center gap-2">
<input
value={option.value}
class="grow"
type="text"
placeholder={`Option ${index + 1}`}
oninput={e => updateOption(option.id, e.currentTarget.value)} />
</label>
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
<Icon icon={MinusCircle} size={4} />
</Button>
</div>
{/each}
<Button class="btn btn-outline btn-sm self-end" onclick={addOption}>
<Icon icon={PlusCircle} size={4} />
Add option
</Button>
</div>
{/snippet}
</Field>
<div class="flex flex-col gap-2">
<FieldInline>
{#snippet label()}
Poll type
{/snippet}
{#snippet input()}
<select class="select select-bordered w-full max-w-xs" bind:value={pollType}>
<option value="singlechoice">Single choice</option>
<option value="multiplechoice">Multiple choice</option>
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
Ends at
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={endsAt} />
{/snippet}
</FieldInline>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="btn btn-primary">Create Poll</Button>
</ModalFooter>
</Modal>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import {makePollPath} from "@app/util/routes"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const h = getTagValue("h", event.tags)
</script>
<Link
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
href={makePollPath(url, event.id)}>
<NoteContent {event} {url} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
{#if h}
in <RoomLink {url} {h} />
{/if}
</span>
<CommentActions segment="polls" showActivity {url} {event} />
</div>
</Link>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import {tweened} from "svelte/motion"
import type {TrustedEvent} from "@welshman/util"
import {noop} from "@welshman/lib"
import {stopPropagation} from "@lib/html"
import {getPollType, isPollClosed} from "@app/util/polls"
type Props = {
event: TrustedEvent
option: {id: string; label: string}
results: {voters: number; options: {id: string; votes: number}[]}
selectedIds: string[]
setSingleChoice: (id: string) => void
toggleMultipleChoice: (id: string) => void
}
const {event, option, results, selectedIds, setSingleChoice, toggleMultipleChoice}: Props =
$props()
const pollType = getPollType(event)
const closed = isPollClosed(event)
const selected = $derived(
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
)
const onselect = () =>
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
const tweenedVotes = tweened(votes, {duration: 300})
const tweenedMax = tweened(maxVotes, {duration: 300})
$effect(() => {
tweenedVotes.set(votes)
})
$effect(() => {
tweenedMax.set(maxVotes)
})
</script>
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
<div class="flex items-center justify-between gap-2">
<label class="flex min-w-0 flex-grow items-center gap-2">
{#if !closed}
{#if pollType === "singlechoice"}
<input
name={event.id}
type="radio"
class="radio radio-primary radio-sm"
checked={selected}
onclick={stopPropagation(noop)}
onchange={onselect} />
{:else}
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={selected}
onclick={stopPropagation(noop)}
onchange={onselect} />
{/if}
{/if}
<span class="truncate">{option.label}</span>
</label>
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
</div>
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
</div>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import {onDestroy} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
import {PollResponse} from "nostr-tools/kinds"
import {formatTimestampRelative} from "@welshman/lib"
import {deriveEvents} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {makePollResponse} from "@app/core/commands"
import PollOption from "@app/components/PollOption.svelte"
import {
getPollEndsAt,
getPollOptions,
getPollResponseSelections,
getPollResults,
getPollType,
isPollClosed,
} from "@app/util/polls"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const responses = deriveEvents([{kinds: [PollResponse], "#e": [event.id]}])
const pollType = getPollType(event)
const options = getPollOptions(event)
const closed = isPollClosed(event)
const endsAt = getPollEndsAt(event)
const publishDelay = pollType === "multiplechoice" ? 10_000 : undefined
const getOwnResponse = (responses: TrustedEvent[]) => {
let latest: TrustedEvent | undefined
for (const response of responses) {
if (response.pubkey !== $pubkey) {
continue
}
if (!latest || response.created_at > latest.created_at) {
latest = response
}
}
return latest
}
const publishSelection = (selection: string[]) => {
if (activeThunk) {
abortThunk(activeThunk)
}
if (selection.length === 0) {
activeThunk = undefined
return
}
activeThunk = publishThunk({
relays: [url],
event: makePollResponse({event, selectedIds: selection}),
delay: publishDelay,
})
}
const publishCurrentSelection = () => {
const selection = pollType === "singlechoice" ? selectedIds.slice(0, 1) : selectedIds
if (selection.length === 0) {
return pushToast({theme: "error", message: "Please select at least one option."})
}
publishSelection(selection)
}
const results = $derived(getPollResults(event, $responses))
const ownResponse = $derived(getOwnResponse($responses))
const setSingleChoice = (id: string) => {
selectedIds = [id]
publishCurrentSelection()
}
const toggleMultipleChoice = (id: string) => {
selectedIds = selectedIds.includes(id)
? selectedIds.filter(selectedId => selectedId !== id)
: [...selectedIds, id]
publishCurrentSelection()
}
let selectedIds = $state<string[]>([])
let activeThunk: ReturnType<typeof publishThunk> | undefined
$effect(() => {
if (ownResponse) {
selectedIds = getPollResponseSelections(ownResponse, pollType)
}
})
onDestroy(() => {
if (activeThunk) {
abortThunk(activeThunk)
}
})
</script>
<div class="flex flex-col gap-2">
{#each options as option (option.id)}
<PollOption {event} {option} {results} {selectedIds} {setSingleChoice} {toggleMultipleChoice} />
{/each}
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm opacity-75">
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
{#if endsAt}
{#if closed}
• Ended {formatTimestampRelative(endsAt)}
{:else}
• Ends {formatTimestampRelative(endsAt)}
{/if}
{/if}
</div>
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
</div>
</div>
+20 -50
View File
@@ -1,24 +1,20 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {splitAt} from "@welshman/lib"
import {userProfile} from "@welshman/app" 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 Letter from "@assets/icons/letter.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import HomeSmile from "@assets/icons/home-smile.svg?dataurl" import Widget from "@assets/icons/widget-4.svg?dataurl"
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl" import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl" import Settings from "@assets/icons/settings.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte" import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte" import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import {userSpaceUrls, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state" import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications" import {notifications} from "@app/util/notifications"
import {goToLastChat} from "@app/util/routes" import {goToChat, makeSpacePath} from "@app/util/routes"
type Props = { type Props = {
children?: Snippet children?: Snippet
@@ -26,46 +22,19 @@
const {children}: Props = $props() const {children}: Props = $props()
const chatHandler = () => goToChat()
const showSettingsMenu = () => pushModal(MenuSettings) const showSettingsMenu = () => pushModal(MenuSettings)
let windowHeight = $state(0) const anySpaceNotifications = $derived(
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
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> </script>
<svelte:window bind:innerHeight={windowHeight} />
<div <div
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block"> class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-2 md:block">
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}> <div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
<div> <PrimaryNavSpaces />
{#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>
{#if PLATFORM_RELAYS.length > 0} {#if PLATFORM_RELAYS.length > 0}
<Divider /> <Divider />
{/if} {/if}
@@ -83,7 +52,7 @@
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem <PrimaryNavItem
title="Messages" title="Messages"
onclick={goToLastChat} onclick={chatHandler}
class="tooltip-right" class="tooltip-right"
notification={$notifications.has("/chat")}> notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} /> <ImageIcon alt="Messages" src={Letter} size={8} />
@@ -99,24 +68,25 @@
<!-- a little extra something for ios --> <!-- a little extra something for ios -->
<div <div
class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden"> class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
</div> </div>
<div <div
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden"> class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
<div class="content-padding-x content-sizing flex justify-between px-2"> <div class="content-padding-x content-sizing flex justify-between px-2">
<div class="flex gap-2 sm:gap-6"> <div class="flex gap-2 sm:gap-6">
<PrimaryNavItem title="Home" href="/home"> <PrimaryNavItem title="Search" href="/people">
<ImageIcon alt="Home" src={HomeSmile} size={8} /> <ImageIcon alt="Search" src={Magnifier} size={8} />
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem <PrimaryNavItem
title="Messages" title="Messages"
onclick={goToLastChat} href="/chat"
onclick={chatHandler}
notification={$notifications.has("/chat")}> notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} /> <ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem> </PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1} {#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}> <PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
<ImageIcon alt="Spaces" src={SettingsMinimalistic} size={8} /> <ImageIcon alt="Spaces" src={Widget} size={8} />
</PrimaryNavItem> </PrimaryNavItem>
{/if} {/if}
</div> </div>
@@ -0,0 +1,42 @@
<script lang="ts">
import {splitAt} from "@welshman/lib"
import Widget from "@assets/icons/widget-4.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(Math.max(0, (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"
prefix="no-highlight"
notification={otherSpaceNotifications}>
<ImageIcon alt="All Spaces" src={Widget} size={8} />
</PrimaryNavItem>
{/each}
</div>
+23 -8
View File
@@ -17,28 +17,43 @@
url?: string url?: string
showPubkey?: boolean showPubkey?: boolean
avatarSize?: number 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 relays = removeUndefined([url])
const profileDisplay = deriveProfileDisplay(pubkey, relays) const profileDisplay = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey) const handle = deriveHandleForPubkey(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey, url}) const openProfile = () => {
pushModal(ProfileDetail, {pubkey, url})
}
const copyPubkey = () => clip(nip19.npubEncode(pubkey)) const copyPubkey = () => clip(nip19.npubEncode(pubkey))
</script> </script>
<div class="flex max-w-full items-start gap-3"> <div class="flex max-w-full items-start gap-3">
<Button onclick={openProfile} class="py-1"> {#if inert}
<ProfileCircle {pubkey} size={avatarSize} /> <span class="py-1">
</Button> <ProfileCircle {pubkey} size={avatarSize} />
</span>
{:else}
<Button onclick={openProfile} class="py-1">
<ProfileCircle {pubkey} size={avatarSize} />
</Button>
{/if}
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis"> {#if inert}
{$profileDisplay} <span class="text-bold overflow-hidden text-ellipsis">
</Button> {$profileDisplay}
</span>
{:else}
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
</Button>
{/if}
<WotScore {pubkey} /> <WotScore {pubkey} />
</div> </div>
{#if $handle} {#if $handle}
+1 -1
View File
@@ -6,7 +6,7 @@
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
type Props = { type Props = {
pubkey: string pubkey?: string
class?: string class?: string
size?: number size?: number
url?: string url?: string
+4 -9
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {removeUndefined} from "@welshman/lib" import {removeUndefined} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util" import {ManagementMethod} from "@welshman/util"
import { import {
@@ -30,9 +29,10 @@
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte"
import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state" import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {addSpaceMembers} from "@app/core/commands"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {makeChatPath} from "@app/util/routes" import {goToChat} from "@app/util/routes"
export type Props = { export type Props = {
pubkey: string pubkey: string
@@ -51,11 +51,9 @@
const back = () => history.back() const back = () => history.back()
const chatPath = makeChatPath([pubkey])
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event}) const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
const openChat = () => goto(chatPath) const openChat = () => goToChat([pubkey])
const toggleMenu = (pubkey: string) => { const toggleMenu = (pubkey: string) => {
showMenu = !showMenu showMenu = !showMenu
@@ -85,10 +83,7 @@
}) })
const restoreMember = async () => { const restoreMember = async () => {
const {error} = await manageRelay(url!, { const error = await addSpaceMembers(url!, [pubkey])
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
+4 -2
View File
@@ -23,7 +23,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Reaction from "@app/components/Reaction.svelte" import Reaction from "@app/components/Reaction.svelte"
import ReportDetails from "@app/components/ReportDetails.svelte" import ReportDetails from "@app/components/ReportDetails.svelte"
import {REACTION_KINDS} from "@app/core/state" import {REACTION_KINDS, deriveUserIsSpaceAdmin} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
interface Props { interface Props {
@@ -78,6 +78,8 @@
} }
} }
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const onReportClick = () => pushModal(ReportDetails, {url, event}) const onReportClick = () => pushModal(ReportDetails, {url, event})
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values()))) const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
@@ -118,7 +120,7 @@
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children} {#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
<div class="flex min-w-0 flex-wrap gap-2"> <div class="flex min-w-0 flex-wrap gap-2">
{#if url && $reports.length > 0} {#if url && $reports.length > 0 && $userIsAdmin}
<button <button
type="button" type="button"
data-tip={`This content has been reported as "${displayList(reportReasons)}".`} data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
+65 -13
View File
@@ -1,30 +1,70 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {SvelteSet} from "svelte/reactivity"
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {tryCatch} from "@welshman/lib" import {tryCatch} from "@welshman/lib"
import {isShareableRelayUrl, normalizeRelayUrl} from "@welshman/util" import {isShareableRelayUrl, isIPAddress, normalizeRelayUrl} from "@welshman/util"
import {relaySearch} from "@welshman/app" import type {Thunk} from "@welshman/app"
import {waitForThunkError, relaySearch} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {errorMessage} from "@lib/util"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RelayItem from "@app/components/RelayItem.svelte" import RelayItem from "@app/components/RelayItem.svelte"
import {pushToast} from "@app/util/toast"
interface Props { interface Props {
relays: Readable<string[]> 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 term = $state("")
let limit = $state(20) let limit = $state(20)
let element: Element | undefined = $state() 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(() => { onMount(() => {
const scroller = createScroller({ const scroller = createScroller({
@@ -52,23 +92,35 @@
<RelayItem url={term}> <RelayItem url={term}>
<Button <Button
class="btn btn-outline btn-sm flex items-center" class="btn btn-outline btn-sm flex items-center"
onclick={() => addRelay(customUrl)}> disabled={loading.has(customUrl)}
<Icon icon={AddCircle} /> onclick={() => add(customUrl)}>
{#if loading.has(customUrl)}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={AddCircle} />
{/if}
Add Relay Add Relay
</Button> </Button>
</RelayItem> </RelayItem>
{/if} {/if}
{#each $relaySearch {#each searchResults as url (url)}
.searchValues(term)
.filter(url => !$relays.includes(url))
.slice(0, limit) as url (url)}
<RelayItem {url}> <RelayItem {url}>
<Button class="btn btn-outline btn-sm flex items-center" onclick={() => addRelay(url)}> <Button
<Icon icon={AddCircle} /> 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 Add Relay
</Button> </Button>
</RelayItem> </RelayItem>
{/each} {/each}
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter>
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
</ModalFooter>
</Modal> </Modal>
+91
View File
@@ -0,0 +1,91 @@
<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 DangerTriangle from "@assets/icons/danger-triangle.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>
{:else}
<p class="text-center py-12 flex justify-center items-center gap-2">
<Icon icon={DangerTriangle} />
No relay selections found.
</p>
{/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,26 @@
<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"
import type {HealthCheck} from "@app/util/health"
import {applyHealthCheck} from "@app/util/health"
type Props = {
healthCheck: HealthCheck
}
const {healthCheck}: Props = $props()
const apply = () => applyHealthCheck(healthCheck)
</script>
<div class="card2 card2-sm bg-alt flex justify-between">
<div class="flex flex-col gap-1">
<strong>{healthCheck.title}</strong>
<p class="text-sm">{healthCheck.description}</p>
</div>
<Button class="btn btn-neutral btn-sm" onclick={apply}>
<Icon icon={Stars} />
{healthCheck.action}
</Button>
</div>
@@ -0,0 +1,43 @@
<script lang="ts">
import Stars from "@assets/icons/stars.svg?dataurl"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import DangerTriangle from "@assets/icons/danger-triangle.svg?dataurl"
import Stethoscope from "@assets/icons/stethoscope.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import RelaySettingsHealthCheck from "@app/components/RelaySettingsHealthCheck.svelte"
import {PLATFORM_NAME} from "@app/core/state"
import {pendingHealthChecks, applyHealthCheck} from "@app/util/health"
const applyAll = () => {
for (const healthCheck of $pendingHealthChecks) {
applyHealthCheck(healthCheck)
}
}
</script>
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
<div class="flex justify-between items-center">
<strong class="flex items-center gap-3 text-lg">
<Icon icon={Stethoscope} />
Health Check
</strong>
<span class="flex items-center gap-2 text-sm">
<Icon icon={$pendingHealthChecks.length === 0 ? CheckCircle : DangerTriangle} />
{$pendingHealthChecks.length} Issue{$pendingHealthChecks.length === 1 ? "" : "s"} Detected
</span>
</div>
<p>
{PLATFORM_NAME} actively checks your connection to the network in the background to discover relays
that are offline, that you don't have access to, or are otherwise causing trouble.
</p>
{#each $pendingHealthChecks as healthCheck}
<RelaySettingsHealthCheck {healthCheck} />
{/each}
{#if $pendingHealthChecks.length > 0}
<Button class="btn btn-primary" onclick={applyAll}>
<Icon icon={Stars} />
Apply All Recommendations
</Button>
{/if}
</div>
@@ -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>
+4 -3
View File
@@ -9,9 +9,10 @@
type Props = { type Props = {
url: string url: string
hideFavorites?: boolean
} }
const {url}: Props = $props() const {url, hideFavorites}: Props = $props()
const rooms = deriveUserRooms(url) const rooms = deriveUserRooms(url)
const favorited = deriveGroupListPubkeys(url) const favorited = deriveGroupListPubkeys(url)
</script> </script>
@@ -34,7 +35,7 @@
</div> </div>
{/if} {/if}
</div> </div>
<div> <div class="min-w-0">
<h2 class="ellipsize whitespace-nowrap text-xl"> <h2 class="ellipsize whitespace-nowrap text-xl">
<RelayName {url} /> <RelayName {url} />
</h2> </h2>
@@ -43,7 +44,7 @@
</div> </div>
<RelayDescription {url} /> <RelayDescription {url} />
</div> </div>
{#if $favorited.size > 0} {#if !hideFavorites && $favorited.size > 0}
<div class="row-2 card2 card2-sm bg-alt"> <div class="row-2 card2 card2-sm bg-alt">
Favorited By: Favorited By:
<ProfileCircles pubkeys={Array.from($favorited)} /> <ProfileCircles pubkeys={Array.from($favorited)} />
+2 -2
View File
@@ -26,7 +26,7 @@
const back = () => history.back() const back = () => history.back()
const onDelete = () => { const onResolved = () => {
if ($reports.size === 0) { if ($reports.size === 0) {
back() back()
} }
@@ -40,7 +40,7 @@
<ModalSubtitle>All reports for this event are shown below.</ModalSubtitle> <ModalSubtitle>All reports for this event are shown below.</ModalSubtitle>
</ModalHeader> </ModalHeader>
{#each $reports.values() as report (report.id)} {#each $reports.values() as report (report.id)}
<ReportItem {url} event={report} {onDelete} /> <ReportItem {url} event={report} {onResolved} />
{/each} {/each}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
+3 -3
View File
@@ -15,10 +15,10 @@
type Props = { type Props = {
url: string url: string
event: TrustedEvent event: TrustedEvent
onDelete?: () => void onResolved?: () => void
} }
const {url, event, onDelete}: Props = $props() const {url, event, onResolved}: Props = $props()
const etag = getTag("e", event.tags) const etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags) const ptag = getTag("p", event.tags)
@@ -45,7 +45,7 @@
{/if} {/if}
</span> </span>
</div> </div>
<ReportMenu {url} {event} {onDelete} /> <ReportMenu {url} {event} {onResolved} />
</div> </div>
{#if event.content} {#if event.content}
<div class="border-l-2 border-primary pl-3"> <div class="border-l-2 border-primary pl-3">
+6 -6
View File
@@ -20,10 +20,10 @@
type Props = { type Props = {
url: string url: string
event: TrustedEvent event: TrustedEvent
onDelete?: () => void onResolved?: () => void
} }
const {url, event, onDelete}: Props = $props() const {url, event, onResolved}: Props = $props()
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url) const userIsAdmin = deriveUserIsSpaceAdmin(url)
@@ -40,7 +40,7 @@
const deleteReport = async () => { const deleteReport = async () => {
publishDelete({event, relays: [url], protect: await shouldProtect}) publishDelete({event, relays: [url], protect: await shouldProtect})
onDelete?.() onResolved?.()
} }
const dismissReport = async () => { const dismissReport = async () => {
@@ -54,7 +54,7 @@
} else { } else {
pushToast({message: "Content has successfully been deleted!"}) pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id) repository.removeEvent(event.id)
onDelete?.() onResolved?.()
} }
} }
@@ -77,7 +77,7 @@
repository.removeEvent(event.id) repository.removeEvent(event.id)
repository.removeEvent(id) repository.removeEvent(id)
history.back() history.back()
setTimeout(() => onDelete?.()) setTimeout(() => onResolved?.())
} }
}, },
}) })
@@ -101,7 +101,7 @@
pushToast({message: "User has successfully been banned!"}) pushToast({message: "User has successfully been banned!"})
repository.removeEvent(event.id) repository.removeEvent(event.id)
history.back() history.back()
setTimeout(() => onDelete?.()) setTimeout(() => onResolved?.())
} }
}, },
}) })
+2 -2
View File
@@ -16,7 +16,7 @@
import Lock from "@assets/icons/lock.svg?dataurl" import Lock from "@assets/icons/lock.svg?dataurl"
import Microphone from "@assets/icons/microphone.svg?dataurl" import Microphone from "@assets/icons/microphone.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.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 {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -255,7 +255,7 @@
<strong class="text-lg">Room Settings</strong> <strong class="text-lg">Room Settings</strong>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon icon={VolumeLoud} /> <Icon icon={Bell} />
<span>Notifications</span> <span>Notifications</span>
</div> </div>
<input <input
+35 -3
View File
@@ -5,6 +5,7 @@
import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app" import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl" import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import Hashtag from "@assets/icons/hashtag.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 UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
@@ -15,6 +16,7 @@
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {uploadFile} from "@app/core/commands" import {uploadFile} from "@app/core/commands"
import {deriveHasLivekit, getRoomType, RoomType} from "@app/core/state"
type Props = { type Props = {
url: string url: string
@@ -27,12 +29,25 @@
const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props() const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props()
const values = $state(initialValues) const values = $state(initialValues)
const relayHasLivekit = deriveHasLivekit(url)
const submit = async () => { const submit = async () => {
const room = $state.snapshot(values) 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) { if (imageFile) {
const {error, result} = await uploadFile(imageFile, {maxWidth: 256, maxHeight: 256}) const {error, result} = await uploadFile(imageFile, {
maxWidth: 256,
maxHeight: 256,
})
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
@@ -76,6 +91,7 @@
let loading = $state(false) let loading = $state(false)
let imageFile = $state<File | undefined>() let imageFile = $state<File | undefined>()
let imagePreview = $state(initialValues.picture) let imagePreview = $state(initialValues.picture)
let roomType = $state(getRoomType(initialValues))
const handleImageUpload = async (event: Event) => { const handleImageUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0] const file = (event.target as HTMLInputElement).files?.[0]
@@ -145,7 +161,7 @@
{#if imagePreview} {#if imagePreview}
<ImageIcon src={imagePreview} alt="" class="rounded-lg" /> <ImageIcon src={imagePreview} alt="" class="rounded-lg" />
{:else} {:else}
<Icon icon={Hashtag} /> <Icon icon={roomType === RoomType.Voice ? Volume : Hashtag} />
{/if} {/if}
<input bind:value={values.name} class="grow" type="text" /> <input bind:value={values.name} class="grow" type="text" />
</label> </label>
@@ -161,6 +177,22 @@
</label> </label>
{/snippet} {/snippet}
</FieldInline> </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> <strong class="md:hidden">Permissions</strong>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isRestricted} /> <input type="checkbox" class="checkbox" bind:checked={values.isRestricted} />
@@ -176,7 +208,7 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isClosed} /> <input type="checkbox" class="checkbox" bind:checked={values.isClosed} />
<span class="text-sm opacity-75">Ignore requests to join</span> <span class="text-sm opacity-75">Membership requires approval</span>
</div> </div>
</ModalBody> </ModalBody>
{@render footer({loading})} {@render footer({loading})}
+14 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Hashtag from "@assets/icons/hashtag.svg?dataurl" import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Volume from "@assets/icons/volume.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
import {deriveRoom} from "@app/core/state" import {deriveRoom} from "@app/core/state"
@@ -8,15 +9,25 @@
h: string h: string
url: string url: string
size?: number 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 room = deriveRoom(url, h)
const isVoiceRoom = $derived($room.livekit)
</script> </script>
{#if $room.picture} {#if isVoiceRoom}
<div class="flex shrink-0 items-center gap-1.5">
<Icon size={size + 1} icon={Volume} />
{#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" /> <ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
{:else} {:else}
<Icon icon={Hashtag} {size} /> <Icon icon={fallbackIcon} {size} />
{/if} {/if}
@@ -1,18 +0,0 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getPubkeyTagValues} from "@welshman/util"
import ProfileLink from "@app/components/ProfileLink.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
</script>
{#each getPubkeyTagValues(event.tags) as pubkey}
<div class="py-1 text-center text-xs opacity-75">
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> left the room
</div>
{/each}
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import {getTagValue, ManagementMethod} from "@welshman/util"
import type {TrustedEvent, PublishedRoomMeta} from "@welshman/util"
import {repository, manageRelay} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {deriveRoom} from "@app/core/state"
import {addRoomMembers} from "@app/core/commands"
type Props = {
url: string
event: TrustedEvent
onResolved?: () => void
}
const {url, event, onResolved}: Props = $props()
const h = getTagValue("h", event.tags) || ""
const room = deriveRoom(url, h)
const showProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const dismiss = async () => {
loading = true
try {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
params: [event.id, "Join request dismissed"],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Join request has been dismissed."})
repository.removeEvent(event.id)
onResolved?.()
}
} finally {
loading = false
}
}
const accept = async () => {
loading = true
try {
const error = await addRoomMembers(url, $room as PublishedRoomMeta, [event.pubkey])
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Member has been added to the room!"})
onResolved?.()
}
} finally {
loading = false
}
}
let loading = $state(false)
</script>
<div class="column gap-4 card2 card2-sm bg-alt">
<div class="flex justify-between gap-2">
<div>
<Button class="inline text-primary" onclick={showProfile}>
<ProfileName pubkey={event.pubkey} {url} />
</Button>
<span>
requested membership in #<RoomName {url} {h} />
</span>
</div>
<div class="flex gap-2">
<Button class="btn btn-neutral btn-sm" onclick={dismiss} disabled={loading}>Dismiss</Button>
<Button class="btn btn-primary btn-sm" onclick={accept} disabled={loading}>Accept</Button>
</div>
</div>
</div>
+9 -30
View File
@@ -2,9 +2,8 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import {setKey, popKey} from "@lib/implicit" import {setKey, popKey} from "@lib/implicit"
import {sleep} from "@welshman/lib" import {sleep} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util" import {displayProfileByPubkey} from "@welshman/app"
import {manageRelay} from "@welshman/app" import type {PublishedRoomMeta} from "@welshman/util"
import {addRoomMember, displayProfileByPubkey, waitForThunkError} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -22,6 +21,7 @@
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {deriveRoom, deriveSpaceMembers} from "@app/core/state" import {deriveRoom, deriveSpaceMembers} from "@app/core/state"
import {addRoomMembers} from "@app/core/commands"
interface Props { interface Props {
url: string url: string
@@ -42,35 +42,14 @@
// Show loading for auto submit callback // Show loading for auto submit callback
await sleep(500) await sleep(500)
const results = await Promise.all( const error = await addRoomMembers(url, $room as PublishedRoomMeta, pubkeys)
pubkeys
.filter(pubkey => !$spaceMembers.includes(pubkey))
.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
for (const {error} of results) { if (error) {
if (error) { pushToast({theme: "error", message: error})
return pushToast({theme: "error", message: error}) } else {
} pushToast({message: "Members have successfully been added!"})
back()
} }
const errors = await Promise.all(
pubkeys.map(pubkey => waitForThunkError(addRoomMember(url, $room, pubkey))),
)
for (const error of errors) {
if (error) {
return pushToast({theme: "error", message: errors[0]})
}
}
pushToast({message: "Members have successfully been added!"})
back()
} finally { } finally {
loading = false loading = false
} }
+1 -1
View File
@@ -12,6 +12,6 @@
const room = deriveRoom(url, h) const room = deriveRoom(url, h)
</script> </script>
<span class="ellipsize {props.class}"> <span class="ellipsize min-w-0 {props.class}">
{$room?.name || h} {$room?.name || h}
</span> </span>
@@ -0,0 +1,58 @@
<script lang="ts">
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ReportItem from "@app/components/ReportItem.svelte"
import RoomJoinItem from "@app/components/RoomJoinItem.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {REPORT} from "@welshman/util"
import {deriveSpaceActionItems} from "@app/core/state"
interface Props {
url: string
}
const {url}: Props = $props()
const actionItems = deriveSpaceActionItems(url)
const back = () => history.back()
const onResolved = () => {
if ($actionItems.length === 0) {
back()
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Action Items</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-2">
{#each $actionItems as event (event.id)}
{#if event.kind === REPORT}
<ReportItem {url} {event} {onResolved} />
{:else}
<RoomJoinItem {url} {event} {onResolved} />
{/if}
{:else}
<p class="py-12 text-center">No action items found.</p>
{/each}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
</Modal>
+1 -23
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import Login from "@assets/icons/login-3.svg?dataurl" import Login from "@assets/icons/login-3.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Compass from "@assets/icons/compass.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -14,12 +13,6 @@
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte" import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
type Props = {
hideDiscover?: boolean
}
const {hideDiscover}: Props = $props()
const startJoin = () => pushModal(SpaceInviteAccept) const startJoin = () => pushModal(SpaceInviteAccept)
</script> </script>
@@ -30,23 +23,8 @@
<ModalSubtitle <ModalSubtitle
>Spaces are places where communities come together to work, play, and hang out.</ModalSubtitle> >Spaces are places where communities come together to work, play, and hang out.</ModalSubtitle>
</ModalHeader> </ModalHeader>
{#if !hideDiscover}
<Link href="/discover">
<CardButton class="btn-primary">
{#snippet icon()}
<div><Icon icon={Compass} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Explore Spaces</div>
{/snippet}
{#snippet info()}
<div>Join create, or browse spaces</div>
{/snippet}
</CardButton>
</Link>
{/if}
<Button onclick={startJoin}> <Button onclick={startJoin}>
<CardButton class={hideDiscover ? "btn-primary" : "btn-neutral"}> <CardButton class="btn-primary">
{#snippet icon()} {#snippet icon()}
<div><Icon icon={Login} size={7} /></div> <div><Icon icon={Login} size={7} /></div>
{/snippet} {/snippet}
+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 {displayRelayUrl, ManagementMethod} from "@welshman/util"
import {manageRelay, forceLoadRelay} from "@welshman/app" import {manageRelay, forceLoadRelay} from "@welshman/app"
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl" import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl" import Widget from "@assets/icons/widget-4.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl" import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
@@ -164,7 +164,7 @@
{#if imagePreview} {#if imagePreview}
<ImageIcon src={imagePreview} alt="" /> <ImageIcon src={imagePreview} alt="" />
{:else} {:else}
<Icon icon={SettingsMinimalistic} /> <Icon icon={Widget} />
{/if} {/if}
<input bind:value={values.name} class="grow" type="text" /> <input bind:value={values.name} class="grow" type="text" />
</label> </label>
+1 -1
View File
@@ -60,7 +60,7 @@
} else { } else {
const permissions = await Push.request() const permissions = await Push.request()
if (permissions === "granted") { if (permissions.startsWith("granted")) {
await setSpaceNotifications(url, true) await setSpaceNotifications(url, true)
} }
} }
+1 -1
View File
@@ -48,7 +48,7 @@
} else { } else {
const permissions = await Push.request() const permissions = await Push.request()
if (permissions === "granted") { if (permissions.startsWith("granted")) {
await setSpaceNotifications(url, true) await setSpaceNotifications(url, true)
} }
} }
+6 -4
View File
@@ -159,9 +159,11 @@
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button class="btn btn-primary" onclick={addMember}> {#if $userIsAdmin}
<Icon icon={AddCircle} /> <Button class="btn btn-primary" onclick={addMember}>
Add members <Icon icon={AddCircle} />
</Button> Add members
</Button>
{/if}
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+8 -17
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import {displayRelayUrl, ManagementMethod} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {manageRelay} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -13,6 +12,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte" import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {addSpaceMembers} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
interface Props { interface Props {
@@ -27,23 +27,14 @@
loading = true loading = true
try { try {
const results = await Promise.all( const error = await addSpaceMembers(url, pubkeys)
pubkeys.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
for (const {error} of results) { if (error) {
if (error) { pushToast({theme: "error", message: error})
return pushToast({theme: "error", message: error}) } else {
} pushToast({message: "Members have successfully been added!"})
back()
} }
pushToast({message: "Members have successfully been added!"})
back()
} finally { } finally {
loading = false loading = false
} }
+2 -4
View File
@@ -17,6 +17,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import {deriveSpaceBannedPubkeyItems, deriveSupportedMethods} from "@app/core/state" import {deriveSpaceBannedPubkeyItems, deriveSupportedMethods} from "@app/core/state"
import {addSpaceMembers} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
interface Props { interface Props {
@@ -55,10 +56,7 @@
} }
const restoreMember = async (pubkey: string) => { const restoreMember = async (pubkey: string) => {
const {error} = await manageRelay(url, { const error = await addSpaceMembers(url, [pubkey])
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
+73 -48
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, REPORT} from "@welshman/util" import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {Poll} from "nostr-tools/kinds"
import {deriveRelay, createSearch, pubkey} from "@welshman/app" import {deriveRelay, createSearch, pubkey} from "@welshman/app"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@@ -18,10 +18,11 @@
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl" import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl" import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl" import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl" import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl" import Bell from "@assets/icons/bell.svg?dataurl"
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl" import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -35,9 +36,10 @@
import SpaceJoin from "@app/components/SpaceJoin.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import SpaceMembers from "@app/components/SpaceMembers.svelte" import SpaceMembers from "@app/components/SpaceMembers.svelte"
import SpaceReports from "@app/components/SpaceReports.svelte" import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte" import RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte" import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte" import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import { import {
ENABLE_ZAPS, ENABLE_ZAPS,
@@ -45,18 +47,21 @@
deriveSpaceMembers, deriveSpaceMembers,
deriveUserRooms, deriveUserRooms,
deriveOtherRooms, deriveOtherRooms,
deriveOtherVoiceRooms,
userSpaceUrls, userSpaceUrls,
hasNip29, hasNip29,
deriveUserCanCreateRoom, deriveUserCanCreateRoom,
deriveUserIsSpaceAdmin, deriveUserIsSpaceAdmin,
deriveEventsForUrl, deriveEventsForUrl,
deriveSpaceActionItems,
notificationSettings, notificationSettings,
deriveShouldNotify, deriveShouldNotify,
displayRoom, displayRoom,
} from "@app/core/state" } from "@app/core/state"
import {setSpaceNotifications} from "@app/core/commands" import {setSpaceNotifications} from "@app/core/commands"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {makeSpacePath, makeChatPath} from "@app/util/routes" import {makeSpacePath, goToChat} from "@app/util/routes"
import {notifications} from "@app/util/notifications"
const {url} = $props() const {url} = $props()
@@ -66,11 +71,13 @@
const threadsPath = makeSpacePath(url, "threads") const threadsPath = makeSpacePath(url, "threads")
const classifiedsPath = makeSpacePath(url, "classifieds") const classifiedsPath = makeSpacePath(url, "classifieds")
const calendarPath = makeSpacePath(url, "calendar") const calendarPath = makeSpacePath(url, "calendar")
const pollsPath = makeSpacePath(url, "polls")
const userRooms = deriveUserRooms(url) const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url) const otherRooms = deriveOtherRooms(url)
const otherVoiceRooms = deriveOtherVoiceRooms(url)
const members = deriveSpaceMembers(url) const members = deriveSpaceMembers(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url) const userIsAdmin = deriveUserIsSpaceAdmin(url)
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}]) const actionItems = deriveSpaceActionItems(url)
const spaceKinds = derived( const spaceKinds = derived(
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]), deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
@@ -95,21 +102,23 @@
showMenu = !showMenu showMenu = !showMenu
} }
const showDetail = () => pushModal(SpaceDetail, {url}, {replaceState}) const showDetail = () => pushModal(SpaceDetail, {url})
const showMembers = () => pushModal(SpaceMembers, {url}, {replaceState}) const showMembers = () => pushModal(SpaceMembers, {url})
const showReports = () => pushModal(SpaceReports, {url}, {replaceState}) const showActionItems = () => pushModal(SpaceActionItems, {url})
const canCreateRoom = deriveUserCanCreateRoom(url) const canCreateRoom = deriveUserCanCreateRoom(url)
const createInvite = () => pushModal(SpaceInvite, {url}, {replaceState}) const createInvite = () => pushModal(SpaceInvite, {url})
const leaveSpace = () => pushModal(SpaceExit, {url}, {replaceState}) const leaveSpace = () => pushModal(SpaceExit, {url})
const joinSpace = () => pushModal(SpaceJoin, {url}, {replaceState}) const joinSpace = () => pushModal(SpaceJoin, {url})
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState}) const addRoom = () => pushModal(RoomCreate, {url})
const contactOwner = () => goToChat([$relay!.pubkey!])
const shouldNotify = deriveShouldNotify(url) const shouldNotify = deriveShouldNotify(url)
@@ -125,25 +134,24 @@
let term = $state("") let term = $state("")
let showMenu = $state(false) let showMenu = $state(false)
let replaceState = $state(false)
let element: Element | undefined = $state() let element: Element | undefined = $state()
onMount(() => {
replaceState = Boolean(element?.closest(".drawer"))
})
</script> </script>
<div bind:this={element} class="flex h-full flex-col justify-between"> <div bind:this={element} class="flex min-h-0 flex-1 flex-col">
<SecondaryNavSection class="pb-0"> <SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
<div> <div class="flex-shrink-0">
<Button <Button
class="flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100" class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
onclick={openMenu}> onclick={openMenu}>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<strong class="ellipsize flex items-center gap-1"> <strong class="flex items-center gap-1 relative">
<RelayName {url} /> <RelayName {url} class="ellipsize" />
<div
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
class:opacity-100={$userIsAdmin && $actionItems.length > 0}>
</div>
{#if $notificationSettings.push && !$shouldNotify} {#if $notificationSettings.push && !$shouldNotify}
<Icon icon={VolumeCross} size={3} class="opacity-50" /> <Icon icon={BellOff} size={3} class="opacity-50" />
{/if} {/if}
</strong> </strong>
<Icon icon={AltArrowDown} /> <Icon icon={AltArrowDown} />
@@ -175,29 +183,32 @@
</li> </li>
{#if $userIsAdmin} {#if $userIsAdmin}
<li> <li>
<Button onclick={showReports}> <Button onclick={showActionItems}>
<Icon icon={Danger} /> <Icon icon={Danger} />
View Reports ({$reports.length}) Action Items ({$actionItems.length})
{#if $actionItems.length > 0}
<div class="h-2 w-2 rounded-full bg-primary"></div>
{/if}
</Button> </Button>
</li> </li>
{/if} {/if}
{#if $relay?.pubkey && $relay.pubkey !== $pubkey} {#if $relay?.pubkey && $relay.pubkey !== $pubkey}
<li> <li>
<Link href={makeChatPath([$relay.pubkey])}> <Button onclick={contactOwner}>
<Icon icon={Letter} /> <Icon icon={Letter} />
Contact Owner Contact Owner
</Link> </Button>
</li> </li>
{/if} {/if}
<li> <li>
{#if $notificationSettings.push} {#if $notificationSettings.push}
<Button onclick={toggleSpaceNotifications}> <Button onclick={toggleSpaceNotifications}>
<Icon icon={$shouldNotify ? VolumeLoud : VolumeCross} /> <Icon icon={$shouldNotify ? Bell : BellOff} />
{$shouldNotify ? "Turn off" : "Turn on"} notifications {$shouldNotify ? "Turn off" : "Turn on"} notifications
</Button> </Button>
{:else} {:else}
<Link href="/settings/alerts"> <Link href="/settings/alerts">
<Icon icon={VolumeLoud} /> <Icon icon={Bell} />
Enable notifications Enable notifications
</Link> </Link>
{/if} {/if}
@@ -219,47 +230,51 @@
</Popover> </Popover>
{/if} {/if}
</div> </div>
<div <div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden">
class="flex max-h-[calc(100vh-150px)] min-h-0 flex-col gap-1 overflow-auto overflow-x-hidden">
{#if hasNip29($relay)} {#if hasNip29($relay)}
<SecondaryNavItem {replaceState} href={makeSpacePath(url, "recent")}> <SecondaryNavItem href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity <Icon icon={History} /> Recent Activity
</SecondaryNavItem> </SecondaryNavItem>
{:else} {:else}
<SecondaryNavItem {replaceState} href={chatPath}> <SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}>
<Icon icon={ChatRound} /> Chat <Icon icon={ChatRound} /> Chat
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)} {#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem {replaceState} href={goalsPath}> <SecondaryNavItem href={goalsPath}>
<Icon icon={StarFallMinimalistic} /> Goals <Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(THREAD)} {#if $spaceKinds.has(THREAD)}
<SecondaryNavItem {replaceState} href={threadsPath}> <SecondaryNavItem href={threadsPath}>
<Icon icon={NotesMinimalistic} /> Threads <Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(CLASSIFIED)} {#if $spaceKinds.has(CLASSIFIED)}
<SecondaryNavItem {replaceState} href={classifiedsPath}> <SecondaryNavItem href={classifiedsPath}>
<Icon icon={CaseMinimalistic} /> Classifieds <Icon icon={CaseMinimalistic} /> Classifieds
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(EVENT_TIME)} {#if $spaceKinds.has(EVENT_TIME)}
<SecondaryNavItem {replaceState} href={calendarPath}> <SecondaryNavItem href={calendarPath}>
<Icon icon={CalendarMinimalistic} /> Calendar <Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if $spaceKinds.has(Poll)}
<SecondaryNavItem href={pollsPath}>
<Icon icon={Revote} /> Polls
</SecondaryNavItem>
{/if}
{#if hasNip29($relay)} {#if hasNip29($relay)}
{#if $userRooms.length > 0} {#if $userRooms.length > 0}
<div class="h-2"></div> <div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader> <SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if} {/if}
{#each $userRooms as h, i (h)} {#each $userRooms as h (h)}
<SpaceMenuRoomItem notify {replaceState} {url} {h} /> <SpaceMenuRoomItem {url} {h} />
{/each} {/each}
{#if $otherRooms.length > 0} {#if $otherRooms.length > 0}
<div class="h-2"></div> <div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader> <SecondaryNavHeader>
{#if $userRooms.length > 0} {#if $userRooms.length > 0}
Other Rooms Other Rooms
@@ -274,19 +289,29 @@
<input bind:value={term} onblur={clearTerm} class="grow" /> <input bind:value={term} onblur={clearTerm} class="grow" />
</label> </label>
{/if} {/if}
{#each $roomSearch.searchValues(term) as h, i (h)} {#each $roomSearch.searchValues(term) as h (h)}
<SpaceMenuRoomItem {replaceState} {url} {h} /> <SpaceMenuRoomItem {url} {h} />
{/each} {/each}
{#if $otherVoiceRooms.length > 0}
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {url} {h} />
{/each}
{/if}
{#if $canCreateRoom} {#if $canCreateRoom}
<SecondaryNavItem {replaceState} onclick={addRoom}> <SecondaryNavItem onclick={addRoom}>
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
Create room Create room
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{/if} {/if}
<div class="h-5 flex-shrink-0"></div>
</div> </div>
</SecondaryNavSection> </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)+0.25rem)] md:pb-2 z-nav">
<VoiceWidget />
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}> <Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
<SocketStatusIndicator {url} /> <SocketStatusIndicator {url} />
</Button> </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>
+18 -14
View File
@@ -1,34 +1,38 @@
<script lang="ts"> <script lang="ts">
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl" import Bell from "@assets/icons/bell.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl" import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.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 {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes" import {makeRoomPath} from "@app/util/routes"
import {deriveShouldNotify} from "@app/core/state"
interface Props { interface Props {
url: any url: any
h: any h: any
notify?: boolean
replaceState?: boolean replaceState?: boolean
} }
const {url, h, notify = false, replaceState = false}: Props = $props() const {url, h, replaceState = false}: Props = $props()
const room = deriveRoom(url, h)
const roomType = $derived(getRoomType($room))
const path = makeRoomPath(url, h) const path = makeRoomPath(url, h)
const shouldNotifyForSpace = deriveShouldNotify(url) const shouldNotifyForSpace = deriveShouldNotify(url)
const shouldNotifyForRoom = deriveShouldNotify(url, h) const shouldNotifyForRoom = deriveShouldNotify(url, h)
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace) const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
</script> </script>
<SecondaryNavItem {#if roomType === RoomType.Voice}
href={path} <VoiceRoomItem {url} {h} {replaceState} {notification} />
{replaceState} {:else}
notification={notify ? $notifications.has(path) : false}> <SecondaryNavItem href={path} {replaceState} {notification}>
<RoomNameWithImage {url} {h} /> <RoomNameWithImage {url} {h} />
{#if showDifferenceIcon} {#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? VolumeLoud : VolumeCross} size={4} class="opacity-50" /> <Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
{/if} {/if}
</SecondaryNavItem> </SecondaryNavItem>
{/if}
+2 -2
View File
@@ -23,7 +23,7 @@
const back = () => history.back() const back = () => history.back()
const onDelete = () => { const onResolved = () => {
if ($reports.length === 0) { if ($reports.length === 0) {
back() back()
} }
@@ -38,7 +38,7 @@
</ModalHeader> </ModalHeader>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each $reports as event (event.id)} {#each $reports as event (event.id)}
<ReportItem {url} {event} {onDelete} /> <ReportItem {url} {event} {onResolved} />
{:else} {:else}
<p class="py-12 text-center">No reports found.</p> <p class="py-12 text-center">No reports found.</p>
{/each} {/each}
+121 -85
View File
@@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import {tick} from "svelte" import {tick} from "svelte"
import {createSearch} from "@welshman/app" import {debounce} from "throttle-debounce"
import {request} from "@welshman/net"
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib" import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, Filter} from "@welshman/util"
import {MESSAGE} from "@welshman/util" import {sortEventsDesc} from "@welshman/util"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {deriveEventsForUrl} from "@app/core/state" import {CONTENT_KINDS} from "@app/core/state"
import {goToEvent} from "@app/util/routes" import {goToEvent} from "@app/util/routes"
type Props = { type Props = {
@@ -19,14 +20,16 @@
const {url, h}: Props = $props() const {url, h}: Props = $props()
const spaceMessages = deriveEventsForUrl(
url,
h ? [{kinds: [MESSAGE], "#h": [h]}] : [{kinds: [MESSAGE]}],
)
let term = $state("") let term = $state("")
let show = $state(false) let show = $state(false)
let results = $state<TrustedEvent[]>([])
let loading = $state(false)
let input: HTMLInputElement | undefined = $state() let input: HTMLInputElement | undefined = $state()
let controller: AbortController | undefined
const relayStatus = $derived(
h ? `Searching this room on relay: ${url}.` : `Searching this space on relay: ${url}.`,
)
const open = () => { const open = () => {
show = true show = true
@@ -40,21 +43,53 @@
const clear = () => { const clear = () => {
term = "" term = ""
show = false show = false
loading = false
results = []
controller?.abort()
controller = undefined
} }
const getRelayUrls = () => [url]
const getFilter = (searchTerm: string): Filter =>
h
? {kinds: CONTENT_KINDS, "#h": [h], search: searchTerm}
: {kinds: CONTENT_KINDS, search: searchTerm}
const search = debounce(300, async (searchTerm: string) => {
controller?.abort()
if (!searchTerm.trim()) {
loading = false
results = []
return
}
controller = new AbortController()
loading = true
try {
const events = await request({
relays: getRelayUrls(),
autoClose: true,
signal: controller.signal,
filters: [getFilter(searchTerm.trim())],
})
results = sortEventsDesc(events)
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
results = []
}
} finally {
loading = false
}
})
const onInput = () => { const onInput = () => {
show = true void search(term)
} }
const searchIndex = $derived.by(() =>
createSearch($spaceMessages, {
getValue: event => event.id,
fuseOptions: {keys: ["content"]},
}),
)
const results = $derived(term ? searchIndex.searchOptions(term) : [])
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results)) const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
const getAgeSection = (createdAt: number) => { const getAgeSection = (createdAt: number) => {
@@ -95,73 +130,74 @@
} }
</script> </script>
<div> <button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}> <Icon size={4} icon={Magnifier} />
<Icon size={4} icon={Magnifier} /> </button>
</button> {#if show}
{#if show} <button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button> <div class="fixed top-sai right-sai left-content z-feature p-2">
<div class="fixed cw top-0 right-0 z-feature p-2"> <div
<div class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md" transition:fly={{y: -40, duration: 150}}>
transition:fly={{y: -40, duration: 150}}> <div class="flex justify-between">
<div class="flex justify-between"> <strong>Search</strong>
<strong>Search</strong> <Button onclick={clear}>
<Button onclick={clear}> <Icon icon={CloseCircle} />
<Icon icon={CloseCircle} /> </Button>
</Button> </div>
</div> <label class="input input-sm input-bordered flex w-full items-center gap-2">
<label class="input input-sm input-bordered flex w-full items-center gap-2"> <Icon size={4} icon={Magnifier} />
<Icon size={4} icon={Magnifier} /> <input
<input bind:this={input}
bind:this={input} bind:value={term}
bind:value={term} class="min-w-0 grow"
class="min-w-0 grow" type="text"
type="text" placeholder={h ? "Search this room..." : "Search this space..."}
placeholder={h ? "Search this room..." : "Search this space..."} oninput={onInput} />
oninput={onInput} /> </label>
</label> <div class="max-h-[65vh] overflow-y-auto">
<div class="max-h-[65vh] overflow-y-auto"> <p class="mb-2 text-xs opacity-70">{relayStatus}</p>
{#if !term} {#if !term}
<p class="text-sm opacity-70"> <p class="text-sm opacity-70">
{h ? "Search for messages in this room." : "Search for messages across this space."} {h ? "Search for content in this room." : "Search for content in this space."}
</p> </p>
{:else if eventsByAge.size === 0} {:else if loading}
<p class="text-sm opacity-70">No results found.</p> <p class="text-sm opacity-70">Searching...</p>
{:else} {:else if eventsByAge.size === 0}
<div class="col-2"> <p class="text-sm opacity-70">No results found.</p>
{#each eventsByAge as [key, events] (key)} {:else}
<div class="col-2">
{#each eventsByAge as [key, events] (key)}
<div class="col-2">
<p class="text-xs uppercase tracking-wide opacity-60">
{#if key === "day"}
Last 24 Hours
{:else if key === "week"}
Last 7 Days
{:else}
Older
{/if}
</p>
<div class="col-2"> <div class="col-2">
<p class="text-xs uppercase tracking-wide opacity-60"> {#each events as event (event.id)}
{#if key === "day"} <button
Last 24 Hours class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
{:else if key === "week"} onclick={() => onRoomSearchResultClick(event)}>
Last 7 Days <p class="line-clamp-2 text-sm">
{:else} {event.content.trim() || "(No text content)"}
Older </p>
{/if} <div class="row-2 text-xs opacity-70">
</p> <span>{getAgeLabel(event.created_at)}</span>
<div class="col-2"> <span>{formatTimestampAsDate(event.created_at)}</span>
{#each events as event (event.id)} </div>
<button </button>
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left" {/each}
onclick={() => onRoomSearchResultClick(event)}>
<p class="line-clamp-2 text-sm">
{event.content.trim() || "(No text content)"}
</p>
<div class="row-2 text-xs opacity-70">
<span>{getAgeLabel(event.created_at)}</span>
<span>{formatTimestampAsDate(event.created_at)}</span>
</div>
</button>
{/each}
</div>
</div> </div>
{/each} </div>
</div> {/each}
{/if} </div>
</div> {/if}
</div> </div>
</div> </div>
{/if} </div>
</div> {/if}
+74 -7
View File
@@ -1,28 +1,98 @@
<script lang="ts"> <script lang="ts">
import {parse, renderAsHtml} from "@welshman/content" import {parse, renderAsHtml} from "@welshman/content"
import Close from "@assets/icons/close.svg?dataurl"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {toast, popToast} from "@app/util/toast" import {toast, popToast} from "@app/util/toast"
let touchStartY = 0
let touchStartTime = 0
let dragY = $state(0)
let isSettling = $state(false)
let containerEl = $state<HTMLDivElement | undefined>(undefined)
$effect(() => {
if ($toast) {
dragY = 0
isSettling = false
}
})
$effect(() => {
if (!containerEl) return
containerEl.addEventListener("touchmove", onTouchMove, {passive: false})
return () => containerEl!.removeEventListener("touchmove", onTouchMove)
})
const onActionClick = () => { const onActionClick = () => {
$toast!.action!.onclick() $toast!.action!.onclick()
popToast($toast!.id) popToast($toast!.id)
} }
const onClose = () => popToast($toast!.id)
const onTouchStart = (e: TouchEvent) => {
touchStartY = e.touches[0].clientY
touchStartTime = Date.now()
dragY = 0
isSettling = false
}
const onTouchMove = (e: TouchEvent) => {
const delta = e.touches[0].clientY - touchStartY
if (delta < 0) {
e.preventDefault()
isSettling = false
dragY = delta
} else {
dragY = 0
}
}
const onTouchEnd = (e: TouchEvent) => {
const delta = e.changedTouches[0].clientY - touchStartY
const duration = Date.now() - touchStartTime
const isQuickFlick = duration < 400 && delta < 0
const isSlowDismiss = delta < -40
if (isQuickFlick || isSlowDismiss) {
dragY = 0
popToast($toast!.id)
} else {
isSettling = true
dragY = 0
setTimeout(() => {
isSettling = false
}, 200)
}
}
</script> </script>
{#if $toast} {#if $toast}
{@const theme = $toast.theme || "info"} {@const theme = $toast.theme || "info"}
<div transition:fly class="bottom-sai right-sai toast z-toast"> <div
bind:this={containerEl}
transition:fly={{y: -20}}
class="fixed z-toast top-[calc(var(--sait)+0.5rem)] left-[calc(var(--sail)+0.5rem)] right-[calc(var(--sair)+0.5rem)] flex flex-col gap-2 md:right-4 md:bottom-4 md:top-auto md:left-auto md:w-80"
style={dragY !== 0 || isSettling
? `transform: translateY(${dragY}px)${isSettling ? "; transition: transform 200ms ease-out" : ""}`
: ""}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}>
{#key $toast.id} {#key $toast.id}
<div <div
role="alert" role="alert"
class="alert flex justify-center whitespace-normal text-left" class="alert relative flex justify-center whitespace-normal text-left"
class:bg-base-100={theme === "info"} class:bg-base-100={theme === "info"}
class:text-base-content={theme === "info"} class:text-base-content={theme === "info"}
class:alert-error={theme === "error"}> class:alert-error={theme === "error"}>
<p class:welshman-content-error={theme === "error"}> <Button
class="absolute -top-2 -right-2 btn btn-circle btn-neutral btn-xs hidden md:inline-flex"
onclick={onClose}>
<Icon icon={Close} size={3} />
</Button>
<p class="md:pr-6" class:welshman-content-error={theme === "error"}>
{#if $toast.message} {#if $toast.message}
{@html renderAsHtml(parse({content: $toast.message}))} {@html renderAsHtml(parse({content: $toast.message}))}
{#if $toast.action} {#if $toast.action}
@@ -35,9 +105,6 @@
<Component toast={$toast} {...props} /> <Component toast={$toast} {...props} />
{/if} {/if}
</p> </p>
<Button class="flex items-center opacity-75" onclick={() => popToast($toast.id)}>
<Icon icon={CloseCircle} />
</Button>
</div> </div>
{/key} {/key}
</div> </div>
@@ -0,0 +1,128 @@
<script lang="ts">
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {
currentVoiceSession,
DeviceKind,
supportsAudioOutputSelection,
switchVoiceActiveDevice,
type VoiceSession,
} from "@app/voice"
import {popModal} from "@app/util/modal"
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
const livekitDeviceId = session.room.getActiveDevice(kind)
if (livekitDeviceId === undefined || livekitDeviceId === "" || livekitDeviceId === "default") {
return ""
}
return livekitDeviceId
}
let audioInputs = $state<MediaDeviceInfo[]>([])
let audioOutputs = $state<MediaDeviceInfo[]>([])
let selectedInput = $state("")
let selectedOutput = $state("")
const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return
try {
const devices = await navigator.mediaDevices.enumerateDevices()
audioInputs = devices.filter(d => d.kind === "audioinput")
audioOutputs = devices.filter(d => d.kind === "audiooutput")
} catch {
audioInputs = []
audioOutputs = []
}
}
$effect(() => {
loadDevices()
navigator.mediaDevices?.addEventListener?.("devicechange", loadDevices)
return () => navigator.mediaDevices?.removeEventListener?.("devicechange", loadDevices)
})
$effect(() => {
const session = $currentVoiceSession
if (!session) {
popModal()
return
}
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
})
const onInputChange = () => {
void switchVoiceActiveDevice(DeviceKind.AudioInput, selectedInput)
}
const onOutputChange = () => {
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
}
const onDone = () => {
popModal()
}
// Output not support in Safari
const canPickOutput = supportsAudioOutputSelection()
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Audio settings</ModalTitle>
<ModalSubtitle>Choose microphone and speaker for this call.</ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-4 pt-2">
<FieldInline>
{#snippet label()}
<p>Microphone</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={selectedInput}
onchange={onInputChange}
aria-label="Microphone">
<option value="">Default microphone</option>
{#each audioInputs as d (d.deviceId)}
<option value={d.deviceId}>
{d.label || `Microphone ${d.deviceId.slice(0, 8)}`}
</option>
{/each}
</select>
{/snippet}
</FieldInline>
{#if canPickOutput}
<FieldInline>
{#snippet label()}
<p>Speaker</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={selectedOutput}
onchange={onOutputChange}
aria-label="Speaker">
<option value="">Default speaker</option>
{#each audioOutputs as d (d.deviceId)}
<option value={d.deviceId}>
{d.label || `Speaker ${d.deviceId.slice(0, 8)}`}
</option>
{/each}
</select>
{/snippet}
</FieldInline>
{/if}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary w-full" onclick={onDone}>Done</Button>
</ModalFooter>
</Modal>
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts">
import cx from "classnames"
import {goto} from "$app/navigation"
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 {makeRoomPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import {makeRoomId} from "@app/core/state"
import {
VoiceState,
deriveVoiceParticipants,
cancelJoinVoiceRoom,
currentVoiceRoom,
voiceState,
isParticipantSpeaking,
participantKey,
type VoiceParticipant,
} from "@app/voice"
interface Props {
url: string
h: string
replaceState?: boolean
notification?: boolean
}
const {url, h, replaceState = false, notification = false}: Props = $props()
const participants = deriveVoiceParticipants(url, h)
const isActive = $derived(
$voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h),
)
const isJoining = $derived(
$voiceState === VoiceState.Joining && $currentVoiceRoom?.id === makeRoomId(url, h),
)
const handleClick = async (e: MouseEvent) => {
if (isActive) return
if (isJoining) {
e.preventDefault()
cancelJoinVoiceRoom()
return
}
e.preventDefault()
await goto(makeRoomPath(url, h), {replaceState})
pushModal(VoiceRoomJoinDialog, {url, h})
}
$effect(() => {
for (const p of $participants) {
if (p.pubkey) loadProfile(p.pubkey)
}
})
</script>
<SecondaryNavItem
href={makeRoomPath(url, h)}
{replaceState}
{notification}
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>
@@ -0,0 +1,127 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Volume from "@assets/icons/volume.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {AbortError, TimeoutError} from "$lib/util"
import {displayRoom} from "@app/core/state"
import {joinVoiceRoom} from "@app/voice"
import {popModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
type Props = {
url: string
h: string
}
const {url, h}: Props = $props()
const spaceLabel = $derived(displayRelayUrl(url))
let audioInputs = $state<MediaDeviceInfo[]>([])
let selectedDeviceId = $state("")
let startWithoutMic = $state(false)
const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return
try {
const devices = await navigator.mediaDevices.enumerateDevices()
audioInputs = devices.filter(d => d.kind === "audioinput")
} catch {
audioInputs = []
}
}
$effect(() => {
void loadDevices()
})
const goBack = () => history.back()
const handleJoinError = (e: unknown) => {
if (e instanceof AbortError) return
console.error("Failed to join voice room", e)
let message = "Failed to join voice room"
if (e instanceof TimeoutError)
message = "Connection timed out. Please check your network and try again."
else if (e instanceof Error) message = e.message
pushToast({theme: "error", message})
}
const joinVoice = async () => {
popModal()
await joinVoiceRoom(
url,
h,
startWithoutMic,
startWithoutMic ? undefined : selectedDeviceId || undefined,
).catch(handleJoinError)
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Join voice room?</ModalTitle>
<ModalSubtitle>
<span class="inline-flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1">
<Icon icon={Volume} size={4} class="shrink-0" />
<span class="ellipsize min-w-0">{displayRoom(url, h)}</span>
<span>·</span>
<span>{spaceLabel}</span>
</span>
</ModalSubtitle>
</ModalHeader>
<p class="text-sm opacity-80">Select a microphone to join the call:</p>
<div class="flex flex-col gap-4 pt-2">
<div class="flex items-center gap-2">
<input
id="voice-start-without-mic"
type="checkbox"
class="checkbox"
bind:checked={startWithoutMic} />
<label for="voice-start-without-mic" class="text-sm cursor-pointer">
Join without microphone (you can unmute later)
</label>
</div>
<FieldInline>
{#snippet label()}
<p>Microphone</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={selectedDeviceId}
disabled={startWithoutMic}
aria-label="Microphone">
<option value="">Default microphone</option>
{#each audioInputs as d (d.deviceId)}
<option value={d.deviceId}>
{d.label || `Microphone ${d.deviceId.slice(0, 8)}`}
</option>
{/each}
</select>
{/snippet}
</FieldInline>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={goBack}>
<Icon icon={AltArrowLeft} />
Don't join
</Button>
<Button class="btn btn-primary" onclick={joinVoice}>
Join voice
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</Modal>
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import {readable} from "svelte/store"
import {fly} from "svelte/transition"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.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 Settings from "@assets/icons/settings.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import {
decodeRelay,
deriveRoom,
displayRoom,
getRoomType,
RoomType,
type Room,
} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {makeRoomPath} from "@app/util/routes"
import {
VoiceState,
currentVoiceSession,
currentVoiceRoom,
voiceState,
leaveVoiceRoom,
toggleMute,
cancelJoinVoiceRoom,
} from "@app/voice"
const {relay, h} = $derived($page.params)
const url = $derived(relay ? decodeRelay(relay) : undefined)
const displayedRoomStore = $derived(
url && h && typeof h === "string" ? deriveRoom(url, h) : readable(undefined),
)
const routeDisplayedRoom = $derived($displayedRoomStore)
const targetRoom = $derived.by((): Room | undefined => {
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
return $currentVoiceRoom
}
if ($voiceState === VoiceState.Disconnected) {
if (routeDisplayedRoom) {
if (getRoomType(routeDisplayedRoom) === RoomType.Voice) {
return routeDisplayedRoom
}
return undefined
}
return $currentVoiceRoom
}
return $currentVoiceRoom
})
const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "")
const spaceName = $derived(targetRoom ? displayRelayUrl(targetRoom.url) : "")
const openJoinDialog = async () => {
if (!targetRoom) return
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
}
const openAudioSettings = () => {
pushModal(VoiceCallAudioSettingsDialog)
}
</script>
{#if targetRoom}
<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 === VoiceState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === 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 === 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 === 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="Audio settings"
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
onclick={openAudioSettings}>
<Icon icon={Settings} 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={openJoinDialog}>
<Icon icon={PhoneCallingRounded} size={4} />
</Button>
{/if}
</div>
</div>
{/if}

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