Compare commits

..

159 Commits

Author SHA1 Message Date
mplorentz ea4e1cde31 Show unread indicator on chat icon in VoiceWidget 2026-04-03 10:51:28 -04:00
mplorentz 4f2e494959 Allow clicking voice widget to go back to call 2026-04-03 10:41:05 -04:00
mplorentz fef449be85 rework video + text chat display controls 2026-04-03 10:36:14 -04:00
mplorentz 945e853e3b Style pin icon more better 2026-04-03 10:01:13 -04:00
mplorentz bad96500d5 Style voice widget icons to be less red 2026-04-03 09:58:00 -04:00
mplorentz 148286dc04 Add video settings to VoiceCallAudioSettingsDialog 2026-04-03 09:23:53 -04:00
mplorentz 3decff3cfc Fix merge artifacts 2026-04-03 09:19:32 -04:00
mplorentz b4b8f85e18 Add settings button to configure audio devices in call 2026-04-03 09:13:06 -04:00
mplorentz 6cc21de400 Change screen sharing icon 2026-04-03 09:11:18 -04:00
mplorentz 39e851b735 Improve pinned video layout 2026-04-03 09:11:18 -04:00
mplorentz 81ff1cafdc Add a button to spotlight a video feed 2026-04-03 09:11:18 -04:00
mplorentz 008dd246ef Add basic screen sharing 2026-04-03 09:11:18 -04:00
mplorentz 50ccfa775f add video to livekit calls 2026-04-03 09:11:18 -04: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
Docker / build-and-push-image (push) Successful in 15m54s
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
Docker / build-and-push-image (push) Has been cancelled
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
Docker / build-and-push-image (push) Successful in 22m43s
2026-03-31 11:25:59 -07:00
Jon Staab 97ff8ff802 Bump version
Docker / build-and-push-image (push) Successful in 22m2s
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
Docker / build-and-push-image (push) Successful in 17m16s
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: #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: #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: #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
Docker / build-and-push-image (push) Successful in 15m7s
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: #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: #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: #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: #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: #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: #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
Docker / build-and-push-image (push) Successful in 14m14s
2026-02-17 12:01:12 -08:00
Jon Staab 21f3970ca8 Use explicit image name in workflow file
Docker / build-and-push-image (push) Has been cancelled
2026-02-17 11:48:52 -08:00
153 changed files with 6355 additions and 2518 deletions
+1 -1
View File
@@ -9,4 +9,4 @@ build
# Env files (keep .env for build; exclude local overrides)
.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_BLOSSOM_SERVERS=https://blossom.primal.net/
VITE_POMADE_SIGNERS=
VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
VITE_PLATFORM_URL=https://app.flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
@@ -15,7 +15,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_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com
VITE_DEFAULT_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_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_GLITCHTIP_API_KEY=
+1 -1
View File
@@ -6,7 +6,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
IMAGE_NAME: coracle-social/flotilla
jobs:
build-and-push-image:
+2 -1
View File
@@ -1,6 +1,6 @@
# Env
.env
.env.local
.env.*.local
# Vite
vite.config.js.timestamp-*
@@ -25,6 +25,7 @@ android/app/src/main/assets/public/
# Web/JavaScript
node_modules/
.pnpm-store/
build/
.svelte-kit/
+1
View File
@@ -169,6 +169,7 @@ src/
- 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
- 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):**
+27
View File
@@ -1,5 +1,32 @@
# 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
* 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
RUN apt-get update && apt-get install -y --no-install-recommends curl
RUN npm install -g pnpm@latest
WORKDIR /app
@@ -20,7 +22,6 @@ ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
ENV NODE_OPTIONS=--max_old_space_size=16384
RUN pnpm run build
# Stage 2: Runtime
FROM node:20-alpine
WORKDIR /app
@@ -28,4 +29,4 @@ WORKDIR /app
# Copy only the built output - no source, no .env, no dev deps
COPY --from=builder /app/build ./build
CMD ["npx", "serve", "build"]
CMD ["npx", "serve", "-s", "build"]
+5 -3
View File
@@ -6,7 +6,7 @@ If you would like to be interoperable with Flotilla, please check out this guide
## Environment
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples):
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
- `VITE_PLATFORM_URL` - The url where the app will be hosted
@@ -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_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.
## Development
See [CONTRIBUTING.md](AGENTS.md).
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Deployment
@@ -29,7 +31,7 @@ To run your own Flotilla, it's as simple as:
```sh
pnpm install
pnpm run build
npx serve build
npx serve -s build
```
Or, if you prefer to use a container:
+7 -2
View File
@@ -1,4 +1,5 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
namespace = "social.flotilla"
@@ -7,8 +8,8 @@ android {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 41
versionName "1.6.5"
versionCode 44
versionName "1.7.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// 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.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
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')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
+1
View File
@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':aparajita-capacitor-secure-storage')
implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
+2
View File
@@ -42,4 +42,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
</manifest>
@@ -1,5 +1,15 @@
package social.flotilla;
import android.os.Bundle;
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.
buildscript {
ext.kotlin_version = '2.2.20'
repositories {
google()
@@ -9,6 +10,7 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:8.13.2'
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
// in the individual module build.gradle files
+3
View File
@@ -2,6 +2,9 @@
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')
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'
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)
if [ -f .env.template ]; then
source .env.template
fi
if [ -f .env.local ]; then
source .env.local
if [ -f .env ]; then
source .env
fi
# Avoid overwriting env vars provided directly
+3
View File
@@ -4,6 +4,9 @@ const config: CapacitorConfig = {
appId: "social.flotilla",
appName: "Flotilla",
webDir: "build",
ios: {
scheme: "Flotilla Chat",
},
android: {
adjustMarginsForEdgeToEdge: true,
},
+4 -4
View File
@@ -358,14 +358,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
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\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -385,14 +385,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.6.5;
MARKETING_VERSION = 1.7.2;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+8 -6
View File
@@ -20,8 +20,16 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>Flotilla uses the microphone for voice chat in rooms.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -47,11 +55,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>
+1
View File
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
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 '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 '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'
+14 -12
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "1.6.5",
"version": "1.7.2",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -42,6 +42,7 @@
},
"type": "module",
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@capacitor-community/safe-area": "^8.0.1",
"@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0",
@@ -57,7 +58,7 @@
"@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2",
"@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.1",
"@pomade/core": "^0.2.2",
"@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2",
@@ -65,16 +66,16 @@
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.8.8",
"@welshman/content": "^0.8.8",
"@welshman/editor": "^0.8.8",
"@welshman/feeds": "^0.8.8",
"@welshman/lib": "^0.8.8",
"@welshman/net": "^0.8.8",
"@welshman/router": "^0.8.8",
"@welshman/signer": "^0.8.8",
"@welshman/store": "^0.8.8",
"@welshman/util": "^0.8.8",
"@welshman/app": "^0.8.12",
"@welshman/content": "^0.8.12",
"@welshman/editor": "^0.8.12",
"@welshman/feeds": "^0.8.12",
"@welshman/lib": "^0.8.12",
"@welshman/net": "^0.8.12",
"@welshman/router": "^0.8.12",
"@welshman/signer": "^0.8.12",
"@welshman/store": "^0.8.12",
"@welshman/util": "^0.8.12",
"compressorjs-next": "^1.1.2",
"daisyui": "^4.12.24",
"date-picker-svelte": "^2.17.0",
@@ -83,6 +84,7 @@
"fuse.js": "^7.1.0",
"husky": "^9.1.7",
"idb": "^8.0.3",
"livekit-client": "^2.17.2",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.6.14",
+285 -115
View File
@@ -11,6 +11,9 @@ importers:
.:
dependencies:
'@aparajita/capacitor-secure-storage':
specifier: ^8.0.0
version: 8.0.0
'@capacitor-community/safe-area':
specifier: ^8.0.1
version: 8.0.1(@capacitor/core@8.0.1)
@@ -57,8 +60,8 @@ importers:
specifier: ^1.9.7
version: 1.9.7
'@pomade/core':
specifier: ^0.2.1
version: 0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.2.2
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':
specifier: ^4.2.1
version: 4.2.1(rollup@2.80.0)(svelte@5.48.0)(svgo@3.3.2)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0))
@@ -81,35 +84,35 @@ importers:
specifier: ^0.6.8
version: 0.6.8(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.48.0)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0)))(@vite-pwa/assets-generator@0.2.6)(vite-plugin-pwa@0.21.2(@vite-pwa/assets-generator@0.2.6)(vite@5.4.21(@types/node@25.0.10)(terser@5.46.0))(workbox-build@7.3.0)(workbox-window@7.3.0))
'@welshman/app':
specifier: ^0.8.8
version: 0.8.8(b90dd618d8ad3ba87405490e903259ce)
specifier: ^0.8.12
version: 0.8.12(3074ef6691f94dc03952d8dbc98013a7)
'@welshman/content':
specifier: ^0.8.8
version: 0.8.8(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.8.12
version: 0.8.12(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/editor':
specifier: ^0.8.8
version: 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.8.12
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':
specifier: ^0.8.8
version: 0.8.8(827c582d718d0d373e9315813bab1085)
specifier: ^0.8.12
version: 0.8.12(d5b74f0c83250e052e0b96f7ff5804e8)
'@welshman/lib':
specifier: ^0.8.8
version: 0.8.8
specifier: ^0.8.12
version: 0.8.12
'@welshman/net':
specifier: ^0.8.8
version: 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
specifier: ^0.8.12
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':
specifier: ^0.8.8
version: 0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))
specifier: ^0.8.12
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':
specifier: ^0.8.8
version: 0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.8.12
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':
specifier: ^0.8.8
version: 0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
specifier: ^0.8.12
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':
specifier: ^0.8.8
version: 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
specifier: ^0.8.12
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:
specifier: ^1.1.2
version: 1.1.2
@@ -134,6 +137,9 @@ importers:
idb:
specifier: ^8.0.3
version: 8.0.3
livekit-client:
specifier: ^2.17.2
version: 2.17.3(@types/dom-mediacapture-record@1.0.22)
nostr-signer-capacitor-plugin:
specifier: github:coracle-social/nostr-signer-capacitor-plugin#main
version: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1)
@@ -229,6 +235,10 @@ packages:
'@antfu/utils@0.7.10':
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':
resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==}
engines: {node: '>=10'}
@@ -737,6 +747,9 @@ packages:
'@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
'@bufbuild/protobuf@1.10.1':
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
'@canvas/image-data@1.1.0':
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
@@ -750,6 +763,11 @@ packages:
peerDependencies:
'@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':
resolution: {integrity: sha512-OwzIkUs4w433Bu9WWAEbEYngXEfJXZ9Wmdb8eoaqzYBgB0W9/3Ed/mh6sAYPNBAZlpyarmewgP7Nb+d3Vrh+xA==}
peerDependencies:
@@ -773,6 +791,9 @@ packages:
'@capacitor/core@8.0.1':
resolution: {integrity: sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==}
'@capacitor/core@8.2.0':
resolution: {integrity: sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==}
'@capacitor/filesystem@8.1.0':
resolution: {integrity: sha512-AfawIxQ8xBmKsEn/vEpgurGQB9+hFXRtwEiCXR+SSS0MkTw4bJrvLGnloZ/PblegYefvnay1q079Yz3PQ6y1dA==}
peerDependencies:
@@ -783,6 +804,11 @@ packages:
peerDependencies:
'@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':
resolution: {integrity: sha512-ycPW6iQyFwzDK95jihesj5EGiyyGSfbBqNek11iNp9tBOB7zDeYkUA2S/vPpOETt3dhP6pWr7a9gNVGuEfj11g==}
peerDependencies:
@@ -1098,89 +1124,105 @@ packages:
resolution: {integrity: sha512-HWpu3wRqss0vqze56Y/peCrMOsILjoorwU0ZiqF4dYQIl03dD4k71tHstC2/y+7KqNtgb7+ItSdXJydfwspDyA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.3.0-rc.2':
resolution: {integrity: sha512-tyXAQ0WCfXZf2dwm7F+IN/t/s324EcdpbW3dh8rwh8NHIkijeHGyiAHs45Bs8SnsTM/RjR+uPigxFMF/QYAiTw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.3.0-rc.2':
resolution: {integrity: sha512-lfJrlawp2PjxBu3Nh/2EOsgigNgr2o8MOG3XS2ibkKpJ3K/1YcUu9sTQV0S/n8+ak2R9MmJ3uTJqRVjdYkwWxg==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.3.0-rc.2':
resolution: {integrity: sha512-2WUcL/k7uk6i5ZSXCQmOGgGxwsfKEtJA28vNfFb6gT+Zv7vdAMbnjjGMFRtTT7RUcFHgN2olvhfnEjtIMY49MQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.3.0-rc.2':
resolution: {integrity: sha512-wx8/WmVA+kwLfwEN6UzjFlRz6erSibq4nGItfH3Nv+OITCjx8pH3Sl67T0tbwjU3M24GOcDFBIJ6rB+2oXbMzQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.3.0-rc.2':
resolution: {integrity: sha512-Rk8oOssrmTzhiuhDMPCw5Gadd4/mj5QPqrkxbg0R1VEaQeNo51d4YNbIokEDp2PqSTo+unUs6SHN8prkFKHP5A==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.3.0-rc.2':
resolution: {integrity: sha512-K2TaPlrPox8uf3K01R8S+AfhPqVVWlEK6+RxkJNGasN0k1iFhu9hMhWl7+sEiWj2V30TKcppRqQWUv7H3qym9w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.3.0-rc.2':
resolution: {integrity: sha512-88HtWiP7sBX6rb1Hw7cf3H+1ufkB+YocfeMFtmAg6oOc8hvNcby8MVPyE7GL+YD7LKPBadcScaeVnGzYWYRaMQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.35.0-rc.0':
resolution: {integrity: sha512-6pdCj+H0+sNsX7vpbxF2FhDF+fe7Hc/mfjlG5caFVUrACIMMOMc962xTE0Y1+XE3EWM8hUmGp3y6hISjJDnwYA==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.35.0-rc.0':
resolution: {integrity: sha512-oy0+atKDov9vn9mMVlyS0V2BTKTFzR3cFhZ9ilF98vnGSvrWdevUlfjNdTE5pN/xZq6z0GnkIAUL5KJhwh5yzQ==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.35.0-rc.0':
resolution: {integrity: sha512-SvX6E6h/i/zhhGheJiSHbFb3loglNDi+H6wEpnPdp5SGlyiJabLVco93kHBeo4lkfQAwYACRW+yFhRfNhhZO3Q==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.35.0-rc.0':
resolution: {integrity: sha512-EY+6k/u87hfZgBXJaNNRwEN1ACPHUudcW2ObVmNA1eADnFircVvdPjvKUCc5bVwzjmG1imzNJWoapIB9wZheFQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.35.0-rc.0':
resolution: {integrity: sha512-owTWB3KiGs03QpcHFGNBToB5DIEuCsHugzVS7h8Za6f2W0Nw8qedJF5oSIr71nBj4jF4NmTMd5w7EFOOKn8pFA==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.35.0-rc.0':
resolution: {integrity: sha512-XQoXfEHwz0TTtf6DMT7rLXNc7qb7okjSe+8vzusdTuqVXBi+km+Jwvc9DKL9azMWKR4TR/ArvQ7Y5dFnckb9VA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.35.0-rc.0':
resolution: {integrity: sha512-hfCXVq35g/zA+Lpa3x1gUXHLi0rLWUPbVGpbPox2zyx2byfKXf5Lcq6xoMJUrQkmT+s8BaOP8TbmhG8ZQgUFyw==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.35.0-rc.0':
resolution: {integrity: sha512-TsV3KFF9i2wKHHsXnRz9N5H66swuDqpk+KPiTugbtdTo+um33BoFZC7F+Ty+DBoOWf4TT+j7IiJfpen5bC5FRw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.35.0-rc.0':
resolution: {integrity: sha512-5eRvTRqUbNDEd999tRRwXaEO5CERA1WDiVrNDgh+g0IlhCJ79jQkfTE+/dKEO8VbhUwVT6qWFjse+/3KjXhUKg==}
@@ -1283,6 +1325,12 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@livekit/mutex@1.1.1':
resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
'@livekit/protocol@1.44.0':
resolution: {integrity: sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==}
'@noble/ciphers@0.5.3':
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
@@ -1376,9 +1424,9 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@pomade/core@0.2.1':
resolution: {integrity: sha512-zXpPQPkhVe7OchmRDe2MbHdUxiCSeUuMwrHOyeOBs/xD1EfY093Mwj6Cu/OLfz0wxivBDSp1GMMmxqKbLWam3Q==}
version: 0.2.1
'@pomade/core@0.2.2':
resolution: {integrity: sha512-FoilLsO0gVjiKMW3LV63pmXU7x3gh8YVGVulyR6QJr4h47XrsBg8vPkZtKWr4+sH3sW31e2tNIPUb3ptiuhrMA==}
version: 0.2.2
engines: {node: '>=12.0.0'}
peerDependencies:
'@frostr/bifrost': ^1.0.7
@@ -1488,66 +1536,79 @@ packages:
resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.56.0':
resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.56.0':
resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.56.0':
resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.56.0':
resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.56.0':
resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.56.0':
resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.56.0':
resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.56.0':
resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.56.0':
resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.56.0':
resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.56.0':
resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.56.0':
resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.56.0':
resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==}
@@ -1670,30 +1731,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.10.0':
resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.10.0':
resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.10.0':
resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.10.0':
resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.10.0':
resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==}
@@ -1823,6 +1889,9 @@ packages:
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/dom-mediacapture-record@1.0.22':
resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
'@types/eslint@9.6.1':
resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==}
@@ -1967,83 +2036,83 @@ packages:
'@vite-pwa/assets-generator':
optional: true
'@welshman/app@0.8.8':
resolution: {integrity: sha512-pyySouAJwGZ2RSC29egiFft38Ctuioodon6xWFxB7HvJ9Llsh5b53qjkrQcAYM7lUAzXwtalf2v4Z3EwYdUObg==}
'@welshman/app@0.8.12':
resolution: {integrity: sha512-kRp+AVzn4i3FvZmdlyMknFUAb/5SnUz9A/cFKkDqWHsd+N3PbNcL2ZOlV9v5NI77GtsDF2ez6PEQfsZxWvkS/g==}
peerDependencies:
'@pomade/core': ^0.1.3
'@welshman/feeds': 0.8.8
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8
'@welshman/router': 0.8.8
'@welshman/signer': 0.8.8
'@welshman/store': 0.8.8
'@welshman/util': 0.8.8
'@pomade/core': ^0.2.1
'@welshman/feeds': 0.8.12
'@welshman/lib': 0.8.12
'@welshman/net': 0.8.12
'@welshman/router': 0.8.12
'@welshman/signer': 0.8.12
'@welshman/store': 0.8.12
'@welshman/util': 0.8.12
svelte: ^4.0.0 || ^5.0.0
'@welshman/content@0.8.8':
resolution: {integrity: sha512-5jh2YMoqINzkOEVSDZec6JbAqiC0WThwRuPwJOwiJlAFYQ4LC0MAT1HQ8z9pht/0TXdjYQUu2X+jngqqICNOiw==}
'@welshman/content@0.8.12':
resolution: {integrity: sha512-hviVTXdyGf04Xq7mGo/82fq6lnbyuUYOGPkf8pJqPkfGh0f3i9nKof6gkzPvjZeEYSczneI2GpLIxkaZ3w1/tw==}
peerDependencies:
nostr-tools: ^2.19.4
'@welshman/editor@0.8.8':
resolution: {integrity: sha512-54WD2d6HEEiuoPgl/LeE4eaLtF2/SrYObk+IE9UUrJjoXcK/BK3vt8ltzazvBLR8ntfKOQINc4DhkeuBxiiCpA==}
'@welshman/editor@0.8.12':
resolution: {integrity: sha512-CEXszH6pfM1kZHU9WnB6Z97YlxEOtgao6R3hhnBL/kRXy2tUTLpmFWyMONg2vu8Uzxtwz665eZhucLsju60U6w==}
peerDependencies:
'@welshman/lib': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.12
'@welshman/util': 0.8.12
nostr-editor: ^1.1.1
nostr-tools: ^2.19.4
'@welshman/feeds@0.8.8':
resolution: {integrity: sha512-o5JuptpWSNr6wtbM0RfSxTJgZStaNxPz160tE9u0SZzs1/a9sq/Yzesw7s+g0nKukRjBbl70DOqpTqOqfXAEIw==}
'@welshman/feeds@0.8.12':
resolution: {integrity: sha512-Dp/063qdrbe096z6IpIneazsqscfzSwsg09ZNHB6c3OsVb6iLEXLe7mEpATGcRsZ8memuFNLVLIP51eVkBqPcQ==}
peerDependencies:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8
'@welshman/router': 0.8.8
'@welshman/signer': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.12
'@welshman/net': 0.8.12
'@welshman/router': 0.8.12
'@welshman/signer': 0.8.12
'@welshman/util': 0.8.12
'@welshman/lib@0.8.8':
resolution: {integrity: sha512-77ZfVtodV05276ceR8c+JdDFqhOpmy2W6PkgDYbnKstQzKb5TN6wBvcLKxJppTzWMeWbyi2JADsuOYvW1jpOSQ==}
'@welshman/lib@0.8.12':
resolution: {integrity: sha512-7Y1GjAcABquWF47A1Jni5JdP+k0GH2yRmEbVhIU+0R0TubCwPAKS38J2LTvtuE9CJMX6hPS9IKEZS6qTOAaVuw==}
engines: {node: '>=12.0.0'}
'@welshman/net@0.8.8':
resolution: {integrity: sha512-Rug3GzVzyABG21g++cCLOVXdjAieV6rJUZqstE8i/olZvOEWZpZ9R901DoUSDR07U2HTrAwHQrjgb1HmH4jiDQ==}
'@welshman/net@0.8.12':
resolution: {integrity: sha512-Ba71jwb8BBwUfPPtWHKYLB0HqeDYK64oqwxfo5bYldtPfGYrlD+a7lHFSZvNyOhvyXygCaIMnaye1QxWAHP8ng==}
peerDependencies:
'@welshman/lib': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.12
'@welshman/util': 0.8.12
'@welshman/router@0.8.8':
resolution: {integrity: sha512-j5O7F7KGQtOIvBJctEiUNcLfHBUnhHlYHxUx7ImPPurc1zLzt3JovvJJFubXMQoQ26D01DsK/AA1L5WZNebUhA==}
'@welshman/router@0.8.12':
resolution: {integrity: sha512-Rr7ryBNTvTvjoLsDRMKPuoNJbBv2MgyqN2338p4vVhPMK6MOGM3Nx1og0LpHDGiLlCFuPM8RpParpIWfNnWbKw==}
peerDependencies:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.12
'@welshman/net': 0.8.12
'@welshman/util': 0.8.12
'@welshman/signer@0.8.8':
resolution: {integrity: sha512-rswHrTdc1+yvAno2h3JELzjp+LCfiYfUr8ACvwSSHAqDwrtezppfh0WDEPaYBp2EVSJ6tKMM1sVey0quO63aMw==}
version: 0.8.8
'@welshman/signer@0.8.12':
resolution: {integrity: sha512-eO4mw2QOR2d2oCS4zgptkCgjC1s8X+1vNnXfWDbtlEwhk7PD4ySSCkpNVMeElq+uluLOugmKvgDc4gfnIH3p2A==}
version: 0.8.12
peerDependencies:
'@noble/curves': ^1.9.7
'@noble/hashes': ^2.0.1
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.12
'@welshman/net': 0.8.12
'@welshman/util': 0.8.12
nostr-signer-capacitor-plugin: '*'
nostr-tools: ^2.19.4
'@welshman/store@0.8.8':
resolution: {integrity: sha512-mTFueKZi9CtrtvCZT5eT5QaLMs94LxQg4y7oO5PZp9wv8EGSnB9p7XIflM0OfpKwF7c0pu1RdXcjVlvMDsC6QQ==}
'@welshman/store@0.8.12':
resolution: {integrity: sha512-3IUzPRMMVF6Pcw3DaKkfJ4S6bYzVtk6Ze3FnaHs1xJIxkQj9GyBO9VreiovULPW0djsnrP5+1UEN4mKZsG3hXg==}
peerDependencies:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8
'@welshman/util': 0.8.8
'@welshman/lib': 0.8.12
'@welshman/net': 0.8.12
'@welshman/util': 0.8.12
svelte: ^4.0.0 || ^5.0.0
'@welshman/util@0.8.8':
resolution: {integrity: sha512-SNT1VXab6ce36EVfjs1A2uwWs5elYTI4eXi8SUuj42k8CqNIAtG+bOf/JFIxXNTfl3NSxxZdWzpLLZWBqgpAxQ==}
'@welshman/util@0.8.12':
resolution: {integrity: sha512-lgftFt2moXZdN5fuL0RoAnAARV0n0d2+Q56gt7KrBSevjoCbtJgBVX5idvxL5PCEfh81veovJtty6eHxrhQv5A==}
peerDependencies:
'@noble/curves': ^1.9.7
'@welshman/lib': 0.8.8
'@welshman/lib': 0.8.12
nostr-tools: ^2.19.4
'@xml-tools/parser@1.0.11':
@@ -3334,6 +3403,9 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
jose@6.2.1:
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
@@ -3438,6 +3510,11 @@ packages:
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
livekit-client@2.17.3:
resolution: {integrity: sha512-htwsAL/BMylY/zwdcT/z00U789csbi9DldSW7DO+5tz7Q15pwu++E1X+ZdtZDfkmlysfQLLibdcqlyg9FY7veQ==}
peerDependencies:
'@types/dom-mediacapture-record': ^1
load-json-file@4.0.0:
resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==}
engines: {node: '>=4'}
@@ -3472,6 +3549,10 @@ packages:
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -4273,6 +4354,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
@@ -4302,6 +4386,13 @@ packages:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'}
sdp-transform@2.15.0:
resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==}
hasBin: true
sdp@3.2.1:
resolution: {integrity: sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -4703,6 +4794,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typed-emitter@2.1.0:
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
typescript-eslint@8.53.1:
resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4847,6 +4941,10 @@ packages:
webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
webrtc-adapter@9.0.4:
resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==}
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -5064,6 +5162,14 @@ snapshots:
'@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)':
dependencies:
ajv: 8.18.0
@@ -5733,6 +5839,8 @@ snapshots:
'@braintree/sanitize-url@7.1.1': {}
'@bufbuild/protobuf@1.10.1': {}
'@canvas/image-data@1.1.0': {}
'@capacitor-community/safe-area@8.0.1(@capacitor/core@8.0.1)':
@@ -5743,10 +5851,18 @@ snapshots:
dependencies:
'@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)':
dependencies:
'@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)':
dependencies:
'@capacitor/cli': 5.7.8
@@ -5817,6 +5933,10 @@ snapshots:
dependencies:
tslib: 2.8.1
'@capacitor/core@8.2.0':
dependencies:
tslib: 2.8.1
'@capacitor/filesystem@8.1.0(@capacitor/core@8.0.1)':
dependencies:
'@capacitor/core': 8.0.1
@@ -5826,10 +5946,18 @@ snapshots:
dependencies:
'@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)':
dependencies:
'@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)':
dependencies:
'@capacitor/core': 8.0.1
@@ -6298,6 +6426,12 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@livekit/mutex@1.1.1': {}
'@livekit/protocol@1.44.0':
dependencies:
'@bufbuild/protobuf': 1.10.1
'@noble/ciphers@0.5.3': {}
'@noble/ciphers@1.3.0': {}
@@ -6436,15 +6570,15 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@pomade/core@0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))':
'@pomade/core@0.2.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:
'@frostr/bifrost': 1.0.7(typescript@5.9.3)
'@noble/hashes': 2.0.1
'@peculiar/x509': 1.14.3
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/signer': 0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.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))
cbor-x: 1.6.0
hash-wasm: 4.12.0
nostr-tools: 2.20.0(typescript@5.9.3)
@@ -6859,6 +6993,8 @@ snapshots:
'@types/cookie@0.6.0': {}
'@types/dom-mediacapture-record@1.0.22': {}
'@types/eslint@9.6.1':
dependencies:
'@types/estree': 1.0.8
@@ -7031,26 +7167,26 @@ snapshots:
optionalDependencies:
'@vite-pwa/assets-generator': 0.2.6
'@welshman/app@0.8.8(b90dd618d8ad3ba87405490e903259ce)':
'@welshman/app@0.8.12(3074ef6691f94dc03952d8dbc98013a7)':
dependencies:
'@pomade/core': 0.2.1(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/signer@0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3)))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/feeds': 0.8.8(827c582d718d0d373e9315813bab1085)
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/store': 0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(svelte@5.48.0)
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@pomade/core': 0.2.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.12(d5b74f0c83250e052e0b96f7ff5804e8)
'@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/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.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.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.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
svelte: 5.48.0
throttle-debounce: 5.0.2
'@welshman/content@0.8.8(nostr-tools@2.20.0(typescript@5.9.3))':
'@welshman/content@0.8.12(nostr-tools@2.20.0(typescript@5.9.3))':
dependencies:
'@braintree/sanitize-url': 7.1.1
nostr-tools: 2.20.0(typescript@5.9.3)
'@welshman/editor@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-editor@1.1.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-image@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)))(@tiptap/extension-link@2.27.1(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(linkifyjs@4.3.2)(nostr-tools@2.20.0(typescript@5.9.3))(prosemirror-markdown@1.13.3)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(tiptap-markdown@0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))))(nostr-tools@2.20.0(typescript@5.9.3))':
'@welshman/editor@0.8.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:
'@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/pm': 2.27.2
'@tiptap/suggestion': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)
'@welshman/lib': 0.8.8
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.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)
tippy.js: 6.3.7
'@welshman/feeds@0.8.8(827c582d718d0d373e9315813bab1085)':
'@welshman/feeds@0.8.12(d5b74f0c83250e052e0b96f7ff5804e8)':
dependencies:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/router': 0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))
'@welshman/signer': 0.8.8(@noble/curves@1.9.7)(@noble/hashes@2.0.1)(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1))(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.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/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.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))
trava: 1.2.1
'@welshman/lib@0.8.8':
'@welshman/lib@0.8.12':
dependencies:
'@scure/base': 1.2.6
'@types/events': 3.0.3
events: 3.3.0
'@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)':
'@welshman/net@0.8.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:
'@welshman/lib': 0.8.8
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.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))
events: 3.3.0
isomorphic-ws: 5.0.0(ws@8.18.3)
transitivePeerDependencies:
- ws
'@welshman/router@0.8.8(@welshman/lib@0.8.8)(@welshman/net@0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3))(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))':
'@welshman/router@0.8.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:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.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))':
dependencies:
'@noble/curves': 1.9.7
'@noble/hashes': 2.0.1
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.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)':
dependencies:
'@welshman/lib': 0.8.8
'@welshman/net': 0.8.8(@welshman/lib@0.8.8)(@welshman/util@0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3)))(ws@8.18.3)
'@welshman/util': 0.8.8(@noble/curves@1.9.7)(@welshman/lib@0.8.8)(nostr-tools@2.20.0(typescript@5.9.3))
'@welshman/lib': 0.8.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))':
dependencies:
'@noble/curves': 1.9.7
'@types/ws': 8.18.1
'@welshman/lib': 0.8.8
'@welshman/lib': 0.8.12
js-base64: 3.7.8
nostr-tools: 2.20.0(typescript@5.9.3)
nostr-wasm: 0.1.0
@@ -8530,6 +8666,8 @@ snapshots:
jiti@1.21.7: {}
jose@6.2.1: {}
js-base64@3.7.8: {}
js-tokens@4.0.0: {}
@@ -8605,6 +8743,19 @@ snapshots:
linkifyjs@4.3.2: {}
livekit-client@2.17.3(@types/dom-mediacapture-record@1.0.22):
dependencies:
'@livekit/mutex': 1.1.1
'@livekit/protocol': 1.44.0
'@types/dom-mediacapture-record': 1.0.22
events: 3.3.0
jose: 6.2.1
loglevel: 1.9.2
sdp-transform: 2.15.0
tslib: 2.8.1
typed-emitter: 2.1.0
webrtc-adapter: 9.0.4
load-json-file@4.0.0:
dependencies:
graceful-fs: 4.2.11
@@ -8637,6 +8788,8 @@ snapshots:
lodash@4.17.23: {}
loglevel@1.9.2: {}
lru-cache@10.4.3: {}
lru-cache@11.2.4: {}
@@ -9427,6 +9580,11 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
optional: true
sade@1.8.1:
dependencies:
mri: 1.2.0
@@ -9458,6 +9616,10 @@ snapshots:
sax@1.4.4: {}
sdp-transform@2.15.0: {}
sdp@3.2.1: {}
semver@5.7.2: {}
semver@6.3.1: {}
@@ -9982,6 +10144,10 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typed-emitter@2.1.0:
optionalDependencies:
rxjs: 7.8.2
typescript-eslint@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
@@ -10090,6 +10256,10 @@ snapshots:
webidl-conversions@4.0.2: {}
webrtc-adapter@9.0.4:
dependencies:
sdp: 3.2.1
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
+1 -1
View File
@@ -2,7 +2,7 @@ import dotenv from "dotenv"
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
dotenv.config({path: ".env.local"})
dotenv.config({path: ".env.template"})
dotenv.config({path: ".env"})
export default defineConfig({
preset,
+51
View File
@@ -50,6 +50,7 @@
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
--video-call-panel-bg: #181e24;
}
[data-theme] {
@@ -394,6 +395,35 @@ progress[value]::-webkit-progress-value {
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
}
.cw-video-call-content {
@apply w-full md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
}
/* Voice: desktop split — plain CSS so / in calc is not parsed as Tailwind slash syntax */
.cw-split-video {
width: 100%;
}
.cw-split-chat {
width: 100%;
}
@media (min-width: 768px) {
.cw-split-video {
left: 18.5rem;
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
.cw-split-chat {
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
}
.cw-full {
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
}
@@ -422,6 +452,27 @@ body.keyboard-open .hide-on-keyboard {
@apply cb cw fixed z-compose;
}
.chat__compose-zone {
@apply cb cw fixed z-compose;
}
.chat__compose-zone .chat__compose-inner {
@apply min-w-0;
}
.chat__compose-zone.cw-video-call-content {
@apply md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
}
@media (min-width: 768px) {
.chat__compose-zone.cw-split-chat {
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
right: auto;
width: calc((100vw - 18.5rem - var(--sair)) / 2);
max-width: none;
}
}
.chat__scroll-down {
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
}
+3 -4
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {page} from "$app/stores"
import {pubkey} from "@welshman/app"
import Dialog from "@lib/components/Dialog.svelte"
import Landing from "@app/components/Landing.svelte"
import Toast from "@app/components/Toast.svelte"
import PrimaryNav from "@app/components/PrimaryNav.svelte"
import {modals} from "@app/util/modal"
import {modal} from "@app/util/modal"
interface Props {
children: Snippet
@@ -20,8 +19,8 @@
<PrimaryNav>
{@render children?.()}
</PrimaryNav>
{:else if !$modals[$page.url.hash.slice(1)]}
<Dialog children={{component: Landing, props: {}}} />
{:else if !$modal}
<Dialog noEscape children={{component: Landing, props: {}}} />
{/if}
</div>
<Toast />
+173 -125
View File
@@ -1,10 +1,13 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {
ago,
int,
ms,
partition,
ifLet,
spec,
nthEq,
nthNe,
@@ -32,25 +35,27 @@
messagingRelayListsByPubkey,
} from "@welshman/app"
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 Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMembers from "@app/components/ChatMembers.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state"
import {userSettingsValues, deriveChat} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {prependParent} from "@app/core/commands"
import {makeDelete, prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
type Props = {
@@ -62,13 +67,15 @@
const chat = deriveChat(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 = () =>
others.length === 1
? pushModal(ProfileDetail, {pubkey: others[0]})
: pushModal(ChatMembers, {pubkeys: others})
const back = () => goto("/chat")
const replyTo = (event: TrustedEvent) => {
parent = event
compose?.focus()
@@ -78,73 +85,117 @@
parent = undefined
}
const onSubmit = async (params: EventContent) => {
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
// Remove p tags since they result in forking the conversation
params.tags = params.tags.filter(nthNe(0, "p"))
// Add our reply quote to content
params = prependParent(parent, params)
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
const templates: EventTemplate[] = []
const buffer = []
const addTemplate = (kind: number, content: string, tags: string[][]) => {
content = content.trim()
if (content) {
templates.push(makeEvent(kind, {content, tags: [...tags, ...ptags]}))
}
}
for (const p of parse(params)) {
const imeta = isLink(p)
? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()])))
: undefined
if (isLink(p) && imeta) {
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
addTemplate(
DIRECT_MESSAGE_FILE,
p.value.url.toString(),
imeta.slice(1).filter(nthNe(0, "url")),
)
} else {
buffer.push(p.raw)
}
}
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
// Sleep 1 second between each one to make sure timestamps are distinct
const thunks = await Promise.all(
Array.from(enumerate(templates)).map(([i, event]) =>
sendWrapped({
event,
recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i),
}),
),
)
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk: mergeThunks(thunks)},
},
})
clearParent()
const clearEventToEdit = () => {
eventToEdit = undefined
}
const onSubmit = async (params: EventContent) => {
try {
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
// Remove p tags since they result in forking the conversation
params.tags = params.tags.filter(nthNe(0, "p"))
// Add our reply quote to content
params = prependParent(parent, params)
if (eventToEdit) {
if (eventToEdit.content === params.content) {
return
}
await sendWrapped({
event: makeDelete({event: eventToEdit, protect: false}),
recipients: pubkeys,
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 compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
let chatCompose: HTMLElement | undefined = $state()
let dynamicPadding: HTMLElement | undefined = $state()
@@ -204,75 +255,59 @@
</script>
<PageBar>
{#snippet title()}
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
{#if others.length === 0}
<div class="row-2">
<ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$pubkey!} />
</div>
{:else if others.length === 1}
<div class="row-2">
<ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} />
</div>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<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}
<div class="flex">
<Button onclick={back} class="place-self-start pr-3 md:hidden flex items-center">
<Icon icon={ArrowLeft} size={7} />
</Button>
{/snippet}
{#snippet action()}
{#if remove($pubkey, missingRelayLists).length > 0}
{@const count = remove($pubkey, missingRelayLists).length}
{@const label = count > 1 ? "lists are" : "list is"}
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
data-tip="{count} messaging {label} not configured.">
<Icon icon={Danger} />
{count}
<div class="flex items-center justify-between gap-4">
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
{#if others.length === 0}
<div class="row-2">
<ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$pubkey!} />
</div>
{:else if others.length === 1}
<div class="row-2">
<ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} />
</div>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{/if}
</Button>
</div>
{/if}
{/snippet}
</div>
</div>
</PageBar>
<PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div>
{#if missingRelayLists.includes($pubkey!)}
{#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} />
Your messaging relays are not configured.
Direct messages are not enabled
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
your <Link class="link" href="/settings/relays">relay settings page</Link> to receive messages.
</p>
</div>
</div>
{: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.
Ask
{#each missingRelayLists as pubkey (pubkey)}
<ProfileLink {pubkey} />
{/each}
to enable direct messaging by opening this conversation in their app.
</p>
</div>
</div>
@@ -285,7 +320,9 @@
event={$state.snapshot(value as TrustedEvent)}
{pubkeys}
{showPubkey}
{replyTo} />
{replyTo}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
{/each}
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
@@ -305,6 +342,17 @@
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if eventToEdit}
<ChatComposeEdit clear={clearEventToEdit} />
{/if}
</div>
<ChatCompose bind:this={compose} {onSubmit} />
{#key eventToEdit}
<ChatCompose
bind:this={compose}
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
disabled={Boolean(missingRelayLists.length)} />
{/key}
</div>
+42 -6
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import {onDestroy, onMount} from "svelte"
import {writable} from "svelte/store"
import cx from "classnames"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@@ -10,21 +12,44 @@
import {makeEditor} from "@app/editor"
type Props = {
content?: string
disabled?: boolean
onEscape?: () => void
onEditPrevious?: () => 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 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 canEnterEditPrevious = () =>
editor.then(ed => ed.getText({blockSeparator: "\n"}) === "")
const handleKeyDown = async (event: KeyboardEvent) => {
if (event.key === "Escape") {
onEscape?.()
}
if (event.key === "ArrowUp" && (await canEnterEditPrevious())) {
onEditPrevious?.()
}
}
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
if ($uploading) return
if ($uploading || disabled) return
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
@@ -38,19 +63,30 @@
}
const editor = makeEditor({
content,
autofocus,
submit,
uploading,
aggressive: true,
encryptFiles: true,
})
onMount(async () => {
const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown)
})
onDestroy(async () => {
const ed = await editor
ed?.view?.dom.removeEventListener("keydown", handleKeyDown)
})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<Button
data-tip="Add an image"
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
disabled={$uploading}
disabled={$uploading || disabled}
onclick={uploadFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
@@ -58,13 +94,13 @@
<Icon icon={GallerySend} />
{/if}
</Button>
<div class="chat-editor flex-grow overflow-hidden">
<div class={editorClass} aria-disabled={disabled}>
<EditorContent {editor} />
</div>
<Button
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"
disabled={$uploading}
disabled={$uploading || disabled}
onclick={submit}>
<Icon icon={Plane} />
</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 {pubkey, loadMessagingRelayList} from "@welshman/app"
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 ProfileCircle from "@app/components/ProfileCircle.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"
interface Props {
@@ -24,6 +24,7 @@
const others = uniq(remove($pubkey!, props.pubkeys))
const active = $derived($page.params.chat === props.id)
const path = makeChatPath(props.pubkeys)
const openChat = () => goToChat(props.pubkeys)
onMount(() => {
for (const pk of others) {
@@ -32,7 +33,7 @@
})
</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
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}>
@@ -71,4 +72,4 @@
</p>
</div>
</div>
</Link>
</Button>
+13 -6
View File
@@ -23,11 +23,13 @@
interface Props {
event: TrustedEvent
replyTo: (event: TrustedEvent) => void
canEdit?: (event: TrustedEvent) => boolean
onEdit?: (event: TrustedEvent) => void
pubkeys: string[]
showPubkey?: boolean
}
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props()
const isOwn = event.pubkey === $pubkey
const profileDisplay = deriveProfileDisplay(event.pubkey)
@@ -35,16 +37,21 @@
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
const reply = () => replyTo(event)
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
const deleteReaction = (event: TrustedEvent) =>
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys})
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys, pow: 16})
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 showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply, edit})
const togglePopover = () => {
if (popoverIsVisible) {
@@ -71,7 +78,7 @@
<Tippy
bind:popover
component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo}}
props={{event, pubkeys, popover, replyTo, edit}}
params={{
interactive: true,
trigger: "manual",
@@ -93,7 +100,7 @@
{/if}
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
<TapTarget
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl min-w-[100px]"
onTap={showMobileMenu}>
{#if showPubkey}
<div class="flex items-center gap-2">
@@ -18,6 +18,7 @@
sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys,
pow: 16,
})
</script>
+8 -1
View File
@@ -4,12 +4,14 @@
import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {pushModal} from "@app/util/modal"
import Pen from "@assets/icons/pen.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
const {event, pubkeys, popover, replyTo} = $props()
const {event, pubkeys, popover, replyTo, edit} = $props()
const reply = () => replyTo(event)
const onEdit = () => edit?.()
const showInfo = () => {
popover.hide()
@@ -24,6 +26,11 @@
<Icon size={4} icon={Reply} />
</Button>
{/if}
{#if edit}
<Button class="btn join-item btn-xs" onclick={onEdit}>
<Icon size={4} icon={Pen} />
</Button>
{/if}
<Button class="btn join-item btn-xs" onclick={showInfo}>
<Icon size={4} icon={Code2} />
</Button>
@@ -3,6 +3,7 @@
import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
@@ -20,15 +21,17 @@
pubkeys: string[]
event: TrustedEvent
reply: () => void
edit?: () => void
}
const {event, pubkeys, reply}: Props = $props()
const {event, pubkeys, reply, edit}: Props = $props()
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back()
sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys,
pow: 16,
})
}).bind(undefined, event, pubkeys)
@@ -39,6 +42,11 @@
reply()
}
const sendEdit = () => {
history.back()
edit?.()
}
const copyText = () => {
history.back()
clip(event.content)
@@ -62,6 +70,12 @@
<Icon size={4} icon={Reply} />
Send Reply
</Button>
{#if edit}
<Button class="btn btn-neutral w-full" onclick={sendEdit}>
<Icon size={4} icon={Pen} />
Edit Message
</Button>
{/if}
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} />
Send Reaction
+2 -3
View File
@@ -2,7 +2,6 @@
import * as nip19 from "nostr-tools/nip19"
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {goto} from "$app/navigation"
import {tryCatch, uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util"
import {loadMessagingRelayList} from "@welshman/app"
@@ -19,11 +18,11 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.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 onSubmit = () => goto(makeChatPath(pubkeys))
const onSubmit = () => goToChat(pubkeys)
const addPubkey = (pubkey: string) => {
pubkeys = uniq([...pubkeys, pubkey])
+1 -1
View File
@@ -20,7 +20,7 @@
const title = getTagValue("title", 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) || []
</script>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import IconPicker from "@app/components/IconPicker.svelte"
type Props = {
onSelect: (iconUrl: string) => void
}
const {onSelect}: Props = $props()
</script>
<Modal>
<ModalBody>
<IconPicker {onSelect} />
</ModalBody>
</Modal>
+1 -1
View File
@@ -82,7 +82,7 @@
<p>Your recovery codes have been sent!</p>
<p>
For security reasons, you may receive three or more emails with recovery codes in them. Please
paste <strong>all</strong> recovery codes into the text box below, on separate lines.
paste <strong>all</strong> recovery codes into the text box below.
</p>
<StringMultiInput bind:value={otps} placeholder="Enter your recovery codes..." />
</ModalBody>
+1 -1
View File
@@ -3,7 +3,7 @@
import {Capacitor} from "@capacitor/core"
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
import Widget from "@assets/icons/widget-2.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl"
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
import Compass from "@assets/icons/compass-big.svg?dataurl"
+1
View File
@@ -77,6 +77,7 @@
controller.stop()
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
setChecked("*")
} else {
return pushToast({
theme: "error",
+1 -1
View File
@@ -86,7 +86,7 @@
<p>Your login codes have been sent!</p>
<p>
For security reasons, you may receive three or more emails with login codes in them. Please
paste <strong>all</strong> login codes into the text box below, on separate lines.
paste <strong>all</strong> login codes into the text box below.
</p>
<StringMultiInput bind:value={otps} placeholder="Enter your login codes..." />
</ModalBody>
+54 -108
View File
@@ -1,12 +1,9 @@
<script lang="ts">
import {Capacitor} from "@capacitor/core"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import {pubkey} from "@welshman/app"
import Server from "@assets/icons/server.svg?dataurl"
import Moon from "@assets/icons/moon.svg?dataurl"
import Settings from "@assets/icons/settings-minimalistic.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
import GalleryMinimalistic from "@assets/icons/gallery-minimalistic.svg?dataurl"
import Shield from "@assets/icons/shield-minimalistic.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import Wallet from "@assets/icons/wallet.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@@ -14,120 +11,69 @@
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import Profile from "@app/components/Profile.svelte"
import LogOut from "@app/components/LogOut.svelte"
import {PLATFORM_NAME} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {theme} from "@app/util/theme"
const back = () => history.back()
const logout = () => pushModal(LogOut)
const toggleTheme = () => theme.set($theme === "dark" ? "light" : "dark")
</script>
<Modal>
<ModalBody>
<div class="flex flex-col gap-2">
<Link replaceState href="/settings/profile">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={UserRounded} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Profile</div>
{/snippet}
{#snippet info()}
<div>Customize your user profile</div>
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/alerts">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Bell} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Alerts</div>
{/snippet}
{#snippet info()}
<div>Set up email digests and push notifications</div>
{/snippet}
</CardButton>
</Link>
{#if Capacitor.getPlatform() !== "ios"}
<Link replaceState href="/settings/wallet">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Wallet} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Wallet</div>
{/snippet}
{#snippet info()}
<div>Connect a bitcoin wallet for sending social tips</div>
{/snippet}
</CardButton>
<div class="flex flex-col gap-8 items-center py-12 max-w-[16rem] m-auto w-full">
{#if $pubkey}
<Link replaceState href="/settings/profile">
<Profile inert pubkey={$pubkey} />
</Link>
{/if}
<Link replaceState href="/settings/relays">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Server} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Relays</div>
{/snippet}
{#snippet info()}
<div>Control how {PLATFORM_NAME} talks to the network</div>
{/snippet}
</CardButton>
</Link>
<Link replaceState href="/settings/content">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Settings} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Settings</div>
{/snippet}
{#snippet info()}
<div>Get into the details about how {PLATFORM_NAME} works</div>
{/snippet}
</CardButton>
</Link>
<Button onclick={toggleTheme}>
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Moon} size={7} /></div>
{/snippet}
{#snippet title()}
<div>Theme</div>
{/snippet}
{#snippet info()}
<div>Switch between light and dark mode</div>
{/snippet}
</CardButton>
</Button>
<Link replaceState href="/settings/about">
<CardButton class="btn-neutral">
{#snippet icon()}
<div><Icon icon={Code2} size={7} /></div>
{/snippet}
{#snippet title()}
<div>About</div>
{/snippet}
{#snippet info()}
<div>Learn about {PLATFORM_NAME} and support the developer</div>
{/snippet}
</CardButton>
</Link>
<Button onclick={logout} class="btn btn-neutral">
<Icon icon={Exit} /> Log Out
</Button>
<Button class="btn btn-link w-full md:hidden" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<div class="grid grid-cols-3 gap-3 w-full">
<Link
replaceState
href="/settings/alerts"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Bell} size={5} />
Alerts
</Link>
{#if Capacitor.getPlatform() !== "ios"}
<Link
replaceState
href="/settings/wallet"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Wallet} size={5} />
Wallet
</Link>
{/if}
<Link
replaceState
href="/settings/relays"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Server} size={5} />
Relays
</Link>
<Link
replaceState
href="/settings/content"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={GalleryMinimalistic} size={5} />
Content
</Link>
<Link
replaceState
href="/settings/privacy"
class="aspect-square btn h-[unset] btn-neutral flex flex-col gap-2 text-center">
<Icon icon={Shield} size={5} />
Privacy
</Link>
</div>
<div class="flex gap-3 items-center opacity-75 text-sm">
<Button onclick={toggleTheme}>Theme</Button>
/
<Link replaceState href="/settings/about">About</Link>
/
<Button onclick={logout}>Log Out</Button>
</div>
</div>
</ModalBody>
</Modal>
-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 Drawer from "@lib/components/Drawer.svelte"
import Dialog from "@lib/components/Dialog.svelte"
import {modal, clearModals} from "@app/util/modal"
import {modal, modalStack, popModal} from "@app/util/modal"
const closeModals = () => {
const closeModal = () => {
if ($modal && !$modal.options.noEscape) {
clearModals()
popModal()
}
}
const onKeyDown = (e: any) => {
if (e.code === "Escape" && e.target === document.body) {
closeModals()
closeModal()
}
}
let element: HTMLElement
let instance: any | undefined
const instances: Record<string, any> = {}
onMount(() => {
return modal.subscribe($modal => {
if (instance) {
unmount(instance, {outro: true})
instance = undefined
return modalStack.subscribe($modalStack => {
const ids = $modalStack.map(({id}) => id)
for (const [id, instance] of Object.entries(instances)) {
if (!ids.includes(id)) {
unmount(instance, {outro: true})
delete instances[id]
}
}
if ($modal) {
const {options, component, props} = $modal
for (const item of $modalStack) {
if (instances[item.id]) {
continue
}
const {options, component, props} = item
const wrapper = options.drawer ? Drawer : Dialog
instance = mount(wrapper as any, {
instances[item.id] = mount(wrapper as any, {
target: element,
props: {
onClose: closeModals,
onClose: closeModal,
noEscape: options.noEscape,
fullscreen: options.fullscreen,
children: {component, props},
},
@@ -1,7 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {notificationSettings} from "@app/core/state"
import {onNotification} from "@app/util/notifications"
import {onNotification} from "@app/util/push"
let audioElement: HTMLAudioElement
+1 -1
View File
@@ -89,7 +89,7 @@
<p>Let's start by confirming your email.</p>
<p>
For security reasons, you may receive three or more emails with confirmation codes in them.
Please paste <strong>all</strong> confirmation codes into the text box below, on separate lines.
Please paste <strong>all</strong> confirmation codes into the text box below.
</p>
<StringMultiInput bind:value={otps} placeholder="Enter your confirmation codes..." />
</ModalBody>
+17 -47
View File
@@ -1,24 +1,20 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {splitAt} from "@welshman/lib"
import {userProfile} from "@welshman/app"
import Widget from "@assets/icons/widget.svg?dataurl"
import Compass from "@assets/icons/compass.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Settings from "@assets/icons/settings.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import {userSpaceUrls, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {notifications} from "@app/util/notifications"
import {goToLastChat} from "@app/util/routes"
import {goToChat, makeSpacePath} from "@app/util/routes"
type Props = {
children?: Snippet
@@ -26,46 +22,19 @@
const {children}: Props = $props()
const chatHandler = () => goToChat()
const showSettingsMenu = () => pushModal(MenuSettings)
let windowHeight = $state(0)
const itemHeight = 56
const navPadding = 8 * itemHeight
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
const anySpaceNotifications = $derived($userSpaceUrls.some(p => $notifications.has(p)))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
const anySpaceNotifications = $derived(
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
)
</script>
<svelte:window bind:innerHeight={windowHeight} />
<div
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
<div>
{#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} />
{:else}
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
</PrimaryNavItem>
<Divider />
{#each primarySpaceUrls as url (url)}
<PrimaryNavItemSpace {url} />
{/each}
<PrimaryNavItem
href="/spaces"
title="All Spaces"
class="tooltip-right"
notification={otherSpaceNotifications}>
<ImageIcon alt="All Spaces" src={Widget} size={8} />
</PrimaryNavItem>
<PrimaryNavItem title="Add a Space" href="/discover" class="tooltip-right">
<ImageIcon alt="Add a Space" src={Compass} size={8} />
</PrimaryNavItem>
{/each}
</div>
<PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
<Divider />
{/if}
@@ -83,7 +52,7 @@
</PrimaryNavItem>
<PrimaryNavItem
title="Messages"
onclick={goToLastChat}
onclick={chatHandler}
class="tooltip-right"
notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} />
@@ -105,18 +74,19 @@
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">
<div class="content-padding-x content-sizing flex justify-between px-2">
<div class="flex gap-2 sm:gap-6">
<PrimaryNavItem title="Home" href="/home">
<ImageIcon alt="Home" src={HomeSmile} size={8} />
<PrimaryNavItem title="Search" href="/people">
<ImageIcon alt="Search" src={Magnifier} size={8} />
</PrimaryNavItem>
<PrimaryNavItem
title="Messages"
onclick={goToLastChat}
href="/chat"
onclick={chatHandler}
notification={$notifications.has("/chat")}>
<ImageIcon alt="Messages" src={Letter} size={8} />
</PrimaryNavItem>
{#if PLATFORM_RELAYS.length !== 1}
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
<ImageIcon alt="Spaces" src={SettingsMinimalistic} size={8} />
<ImageIcon alt="Spaces" src={Widget} size={8} />
</PrimaryNavItem>
{/if}
</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((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
showPubkey?: boolean
avatarSize?: number
inert?: boolean
}
const {pubkey, url, showPubkey, avatarSize = 10}: Props = $props()
const {pubkey, url, showPubkey, inert, avatarSize = 10}: Props = $props()
const relays = removeUndefined([url])
const profileDisplay = deriveProfileDisplay(pubkey, relays)
const handle = deriveHandleForPubkey(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
const openProfile = () => {
pushModal(ProfileDetail, {pubkey, url})
}
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
</script>
<div class="flex max-w-full items-start gap-3">
<Button onclick={openProfile} class="py-1">
<ProfileCircle {pubkey} size={avatarSize} />
</Button>
{#if inert}
<span class="py-1">
<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 items-center gap-2">
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
</Button>
{#if inert}
<span class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
</span>
{:else}
<Button onclick={openProfile} class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
</Button>
{/if}
<WotScore {pubkey} />
</div>
{#if $handle}
+1 -1
View File
@@ -6,7 +6,7 @@
import ImageIcon from "@lib/components/ImageIcon.svelte"
type Props = {
pubkey: string
pubkey?: string
class?: string
size?: number
url?: string
+4 -9
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {removeUndefined} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util"
import {
@@ -30,9 +29,10 @@
import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {addSpaceMembers} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {makeChatPath} from "@app/util/routes"
import {goToChat} from "@app/util/routes"
export type Props = {
pubkey: string
@@ -51,11 +51,9 @@
const back = () => history.back()
const chatPath = makeChatPath([pubkey])
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
const openChat = () => goto(chatPath)
const openChat = () => goToChat([pubkey])
const toggleMenu = (pubkey: string) => {
showMenu = !showMenu
@@ -85,10 +83,7 @@
})
const restoreMember = async () => {
const {error} = await manageRelay(url!, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
const error = await addSpaceMembers(url!, [pubkey])
if (error) {
pushToast({theme: "error", message: error})
+65 -13
View File
@@ -1,30 +1,70 @@
<script lang="ts">
import {onMount} from "svelte"
import {SvelteSet} from "svelte/reactivity"
import type {Readable} from "svelte/store"
import {tryCatch} from "@welshman/lib"
import {isShareableRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {relaySearch} from "@welshman/app"
import {isShareableRelayUrl, isIPAddress, normalizeRelayUrl} from "@welshman/util"
import type {Thunk} from "@welshman/app"
import {waitForThunkError, relaySearch} from "@welshman/app"
import {createScroller} from "@lib/html"
import {errorMessage} from "@lib/util"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RelayItem from "@app/components/RelayItem.svelte"
import {pushToast} from "@app/util/toast"
interface Props {
relays: Readable<string[]>
addRelay: (url: string) => void
addRelay: (url: string) => Promise<Thunk>
matchRelay?: (url: string) => boolean
}
const {relays, addRelay}: Props = $props()
const {relays, addRelay, matchRelay}: Props = $props()
const back = () => history.back()
const customUrl = $derived(tryCatch(() => normalizeRelayUrl(term)))
const add = async (url: string) => {
loading.add(url)
try {
const error = await waitForThunkError(await addRelay(url))
if (error) {
pushToast({
theme: "error",
message: `Failed to add relay: ${errorMessage(error)}`,
})
}
} finally {
loading.delete(url)
}
}
let term = $state("")
let limit = $state(20)
let element: Element | undefined = $state()
const customUrl = $derived(tryCatch(() => normalizeRelayUrl(term)))
const loading = $state(new SvelteSet<string>())
const searchResults = $derived(
$relaySearch
.searchValues(term)
.filter(url => {
if (matchRelay?.(url) === false) return false
if ($relays.includes(url)) return false
if (isIPAddress(url)) return false
return true
})
.slice(0, limit),
)
onMount(() => {
const scroller = createScroller({
@@ -52,23 +92,35 @@
<RelayItem url={term}>
<Button
class="btn btn-outline btn-sm flex items-center"
onclick={() => addRelay(customUrl)}>
<Icon icon={AddCircle} />
disabled={loading.has(customUrl)}
onclick={() => add(customUrl)}>
{#if loading.has(customUrl)}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={AddCircle} />
{/if}
Add Relay
</Button>
</RelayItem>
{/if}
{#each $relaySearch
.searchValues(term)
.filter(url => !$relays.includes(url))
.slice(0, limit) as url (url)}
{#each searchResults as url (url)}
<RelayItem {url}>
<Button class="btn btn-outline btn-sm flex items-center" onclick={() => addRelay(url)}>
<Icon icon={AddCircle} />
<Button
class="btn btn-outline btn-sm flex items-center"
disabled={loading.has(url)}
onclick={() => add(url)}>
{#if loading.has(url)}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={AddCircle} />
{/if}
Add Relay
</Button>
</RelayItem>
{/each}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-primary flex-grow" onclick={back}>Done</Button>
</ModalFooter>
</Modal>
+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 = {
url: string
hideFavorites?: boolean
}
const {url}: Props = $props()
const {url, hideFavorites}: Props = $props()
const rooms = deriveUserRooms(url)
const favorited = deriveGroupListPubkeys(url)
</script>
@@ -34,7 +35,7 @@
</div>
{/if}
</div>
<div>
<div class="min-w-0">
<h2 class="ellipsize whitespace-nowrap text-xl">
<RelayName {url} />
</h2>
@@ -43,7 +44,7 @@
</div>
<RelayDescription {url} />
</div>
{#if $favorited.size > 0}
{#if !hideFavorites && $favorited.size > 0}
<div class="row-2 card2 card2-sm bg-alt">
Favorited By:
<ProfileCircles pubkeys={Array.from($favorited)} />
+2 -2
View File
@@ -26,7 +26,7 @@
const back = () => history.back()
const onDelete = () => {
const onResolved = () => {
if ($reports.size === 0) {
back()
}
@@ -40,7 +40,7 @@
<ModalSubtitle>All reports for this event are shown below.</ModalSubtitle>
</ModalHeader>
{#each $reports.values() as report (report.id)}
<ReportItem {url} event={report} {onDelete} />
<ReportItem {url} event={report} {onResolved} />
{/each}
</ModalBody>
<ModalFooter>
+3 -3
View File
@@ -15,10 +15,10 @@
type Props = {
url: string
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 ptag = getTag("p", event.tags)
@@ -45,7 +45,7 @@
{/if}
</span>
</div>
<ReportMenu {url} {event} {onDelete} />
<ReportMenu {url} {event} {onResolved} />
</div>
{#if event.content}
<div class="border-l-2 border-primary pl-3">
+6 -6
View File
@@ -20,10 +20,10 @@
type Props = {
url: string
event: TrustedEvent
onDelete?: () => void
onResolved?: () => void
}
const {url, event, onDelete}: Props = $props()
const {url, event, onResolved}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
@@ -40,7 +40,7 @@
const deleteReport = async () => {
publishDelete({event, relays: [url], protect: await shouldProtect})
onDelete?.()
onResolved?.()
}
const dismissReport = async () => {
@@ -54,7 +54,7 @@
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id)
onDelete?.()
onResolved?.()
}
}
@@ -77,7 +77,7 @@
repository.removeEvent(event.id)
repository.removeEvent(id)
history.back()
setTimeout(() => onDelete?.())
setTimeout(() => onResolved?.())
}
},
})
@@ -101,7 +101,7 @@
pushToast({message: "User has successfully been banned!"})
repository.removeEvent(event.id)
history.back()
setTimeout(() => onDelete?.())
setTimeout(() => onResolved?.())
}
},
})
+2 -2
View File
@@ -16,7 +16,7 @@
import Lock from "@assets/icons/lock.svg?dataurl"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -255,7 +255,7 @@
<strong class="text-lg">Room Settings</strong>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon={VolumeLoud} />
<Icon icon={Bell} />
<span>Notifications</span>
</div>
<input
+35 -3
View File
@@ -5,6 +5,7 @@
import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Volume from "@assets/icons/volume.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import {preventDefault} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte"
@@ -15,6 +16,7 @@
import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast"
import {uploadFile} from "@app/core/commands"
import {deriveHasLivekit, getRoomType, RoomType} from "@app/core/state"
type Props = {
url: string
@@ -27,12 +29,25 @@
const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props()
const values = $state(initialValues)
const relayHasLivekit = deriveHasLivekit(url)
const submit = async () => {
const room = $state.snapshot(values)
if (roomType === RoomType.Voice && !$relayHasLivekit) {
return pushToast({
theme: "error",
message: "This relay does not support voice rooms.",
})
}
room.livekit = roomType === RoomType.Voice
if (imageFile) {
const {error, result} = await uploadFile(imageFile, {maxWidth: 256, maxHeight: 256})
const {error, result} = await uploadFile(imageFile, {
maxWidth: 256,
maxHeight: 256,
})
if (error) {
return pushToast({theme: "error", message: error})
@@ -76,6 +91,7 @@
let loading = $state(false)
let imageFile = $state<File | undefined>()
let imagePreview = $state(initialValues.picture)
let roomType = $state(getRoomType(initialValues))
const handleImageUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
@@ -145,7 +161,7 @@
{#if imagePreview}
<ImageIcon src={imagePreview} alt="" class="rounded-lg" />
{:else}
<Icon icon={Hashtag} />
<Icon icon={roomType === RoomType.Voice ? Volume : Hashtag} />
{/if}
<input bind:value={values.name} class="grow" type="text" />
</label>
@@ -161,6 +177,22 @@
</label>
{/snippet}
</FieldInline>
{#if $relayHasLivekit}
<FieldInline>
{#snippet label()}
<p>Room type</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={roomType}
aria-label="Room type">
<option value={RoomType.Text}>Text</option>
<option value={RoomType.Voice}>Voice</option>
</select>
{/snippet}
</FieldInline>
{/if}
<strong class="md:hidden">Permissions</strong>
<div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isRestricted} />
@@ -176,7 +208,7 @@
</div>
<div class="flex items-center gap-2">
<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>
</ModalBody>
{@render footer({loading})}
+14 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Volume from "@assets/icons/volume.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import {deriveRoom} from "@app/core/state"
@@ -8,15 +9,25 @@
h: string
url: string
size?: number
fallbackIcon?: string
}
const {url, h, size = 5}: Props = $props()
const {url, h, size = 5, fallbackIcon = Hashtag}: Props = $props()
const room = deriveRoom(url, h)
const isVoiceRoom = $derived($room.livekit)
</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" />
{:else}
<Icon icon={Hashtag} {size} />
<Icon icon={fallbackIcon} {size} />
{/if}
+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 {setKey, popKey} from "@lib/implicit"
import {sleep} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util"
import {manageRelay} from "@welshman/app"
import {addRoomMember, displayProfileByPubkey, waitForThunkError} from "@welshman/app"
import {displayProfileByPubkey} from "@welshman/app"
import type {PublishedRoomMeta} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@@ -22,6 +21,7 @@
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
import {deriveRoom, deriveSpaceMembers} from "@app/core/state"
import {addRoomMembers} from "@app/core/commands"
interface Props {
url: string
@@ -42,35 +42,14 @@
// Show loading for auto submit callback
await sleep(500)
const results = await Promise.all(
pubkeys
.filter(pubkey => !$spaceMembers.includes(pubkey))
.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
const error = await addRoomMembers(url, $room as PublishedRoomMeta, pubkeys)
for (const {error} of results) {
if (error) {
return pushToast({theme: "error", message: error})
}
if (error) {
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 {
loading = false
}
+1 -1
View File
@@ -12,6 +12,6 @@
const room = deriveRoom(url, h)
</script>
<span class="ellipsize {props.class}">
<span class="ellipsize min-w-0 {props.class}">
{$room?.name || h}
</span>
@@ -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">
import Login from "@assets/icons/login-3.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 Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
@@ -14,12 +13,6 @@
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
import {pushModal} from "@app/util/modal"
type Props = {
hideDiscover?: boolean
}
const {hideDiscover}: Props = $props()
const startJoin = () => pushModal(SpaceInviteAccept)
</script>
@@ -30,23 +23,8 @@
<ModalSubtitle
>Spaces are places where communities come together to work, play, and hang out.</ModalSubtitle>
</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}>
<CardButton class={hideDiscover ? "btn-primary" : "btn-neutral"}>
<CardButton class="btn-primary">
{#snippet icon()}
<div><Icon icon={Login} size={7} /></div>
{/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 {manageRelay, forceLoadRelay} from "@welshman/app"
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import {preventDefault} from "@lib/html"
@@ -164,7 +164,7 @@
{#if imagePreview}
<ImageIcon src={imagePreview} alt="" />
{:else}
<Icon icon={SettingsMinimalistic} />
<Icon icon={Widget} />
{/if}
<input bind:value={values.name} class="grow" type="text" />
</label>
+1 -1
View File
@@ -60,7 +60,7 @@
} else {
const permissions = await Push.request()
if (permissions === "granted") {
if (permissions.startsWith("granted")) {
await setSpaceNotifications(url, true)
}
}
+1 -1
View File
@@ -48,7 +48,7 @@
} else {
const permissions = await Push.request()
if (permissions === "granted") {
if (permissions.startsWith("granted")) {
await setSpaceNotifications(url, true)
}
}
+6 -4
View File
@@ -159,9 +159,11 @@
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} />
Add members
</Button>
{#if $userIsAdmin}
<Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} />
Add members
</Button>
{/if}
</ModalFooter>
</Modal>
+8 -17
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
import {manageRelay} from "@welshman/app"
import {displayRelayUrl} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@@ -13,6 +12,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {addSpaceMembers} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
interface Props {
@@ -27,23 +27,14 @@
loading = true
try {
const results = await Promise.all(
pubkeys.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
const error = await addSpaceMembers(url, pubkeys)
for (const {error} of results) {
if (error) {
return pushToast({theme: "error", message: error})
}
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Members have successfully been added!"})
back()
}
pushToast({message: "Members have successfully been added!"})
back()
} finally {
loading = false
}
+2 -4
View File
@@ -17,6 +17,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
import {deriveSpaceBannedPubkeyItems, deriveSupportedMethods} from "@app/core/state"
import {addSpaceMembers} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
interface Props {
@@ -55,10 +56,7 @@
}
const restoreMember = async (pubkey: string) => {
const {error} = await manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
const error = await addSpaceMembers(url, [pubkey])
if (error) {
pushToast({theme: "error", message: error})
+65 -48
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
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 {deriveRelay, createSearch, pubkey} from "@welshman/app"
import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@@ -20,8 +19,8 @@
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
@@ -35,9 +34,10 @@
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.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 SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {
ENABLE_ZAPS,
@@ -45,18 +45,21 @@
deriveSpaceMembers,
deriveUserRooms,
deriveOtherRooms,
deriveOtherVoiceRooms,
userSpaceUrls,
hasNip29,
deriveUserCanCreateRoom,
deriveUserIsSpaceAdmin,
deriveEventsForUrl,
deriveSpaceActionItems,
notificationSettings,
deriveShouldNotify,
displayRoom,
} from "@app/core/state"
import {setSpaceNotifications} from "@app/core/commands"
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()
@@ -68,9 +71,10 @@
const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const otherVoiceRooms = deriveOtherVoiceRooms(url)
const members = deriveSpaceMembers(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
const actionItems = deriveSpaceActionItems(url)
const spaceKinds = derived(
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
@@ -95,21 +99,23 @@
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 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)
@@ -125,25 +131,24 @@
let term = $state("")
let showMenu = $state(false)
let replaceState = $state(false)
let element: Element | undefined = $state()
onMount(() => {
replaceState = Boolean(element?.closest(".drawer"))
})
</script>
<div bind:this={element} class="flex h-full flex-col justify-between">
<SecondaryNavSection class="pb-0">
<div>
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
<div class="flex-shrink-0">
<Button
class="flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
onclick={openMenu}>
<div class="flex items-center justify-between">
<strong class="ellipsize flex items-center gap-1">
<RelayName {url} />
<strong class="flex items-center gap-1 relative">
<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}
<Icon icon={VolumeCross} size={3} class="opacity-50" />
<Icon icon={BellOff} size={3} class="opacity-50" />
{/if}
</strong>
<Icon icon={AltArrowDown} />
@@ -175,29 +180,32 @@
</li>
{#if $userIsAdmin}
<li>
<Button onclick={showReports}>
<Button onclick={showActionItems}>
<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>
</li>
{/if}
{#if $relay?.pubkey && $relay.pubkey !== $pubkey}
<li>
<Link href={makeChatPath([$relay.pubkey])}>
<Button onclick={contactOwner}>
<Icon icon={Letter} />
Contact Owner
</Link>
</Button>
</li>
{/if}
<li>
{#if $notificationSettings.push}
<Button onclick={toggleSpaceNotifications}>
<Icon icon={$shouldNotify ? VolumeLoud : VolumeCross} />
<Icon icon={$shouldNotify ? Bell : BellOff} />
{$shouldNotify ? "Turn off" : "Turn on"} notifications
</Button>
{:else}
<Link href="/settings/alerts">
<Icon icon={VolumeLoud} />
<Icon icon={Bell} />
Enable notifications
</Link>
{/if}
@@ -219,47 +227,46 @@
</Popover>
{/if}
</div>
<div
class="flex max-h-[calc(100vh-150px)] min-h-0 flex-col gap-1 overflow-auto overflow-x-hidden">
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden">
{#if hasNip29($relay)}
<SecondaryNavItem {replaceState} href={makeSpacePath(url, "recent")}>
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity
</SecondaryNavItem>
{:else}
<SecondaryNavItem {replaceState} href={chatPath}>
<SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}>
<Icon icon={ChatRound} /> Chat
</SecondaryNavItem>
{/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem {replaceState} href={goalsPath}>
<SecondaryNavItem href={goalsPath}>
<Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(THREAD)}
<SecondaryNavItem {replaceState} href={threadsPath}>
<SecondaryNavItem href={threadsPath}>
<Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(CLASSIFIED)}
<SecondaryNavItem {replaceState} href={classifiedsPath}>
<SecondaryNavItem href={classifiedsPath}>
<Icon icon={CaseMinimalistic} /> Classifieds
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(EVENT_TIME)}
<SecondaryNavItem {replaceState} href={calendarPath}>
<SecondaryNavItem href={calendarPath}>
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{/if}
{#if hasNip29($relay)}
{#if $userRooms.length > 0}
<div class="h-2"></div>
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h, i (h)}
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
{#each $userRooms as h (h)}
<SpaceMenuRoomItem {url} {h} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
@@ -274,19 +281,29 @@
<input bind:value={term} onblur={clearTerm} class="grow" />
</label>
{/if}
{#each $roomSearch.searchValues(term) as h, i (h)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
{#each $roomSearch.searchValues(term) as h (h)}
<SpaceMenuRoomItem {url} {h} />
{/each}
{#if $otherVoiceRooms.length > 0}
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {url} {h} />
{/each}
{/if}
{#if $canCreateRoom}
<SecondaryNavItem {replaceState} onclick={addRoom}>
<SecondaryNavItem onclick={addRoom}>
<Icon icon={AddCircle} />
Create room
</SecondaryNavItem>
{/if}
{/if}
<div class="h-5 flex-shrink-0"></div>
</div>
</SecondaryNavSection>
<div class="flex flex-col gap-2 pb-2 p-4 pt-0">
<div
class="flex flex-shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
<VoiceWidget />
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
<SocketStatusIndicator {url} />
</Button>
-27
View File
@@ -1,27 +0,0 @@
<script lang="ts">
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceMenu from "@app/components/SpaceMenu.svelte"
import {notifications} from "@app/util/notifications"
import {makeSpacePath} from "@app/util/routes"
import {pushDrawer} from "@app/util/modal"
import {deriveSocketStatus} from "@app/core/state"
const {url} = $props()
const path = makeSpacePath(url) + ":mobile"
const status = deriveSocketStatus(url)
const openMenu = () => pushDrawer(SpaceMenu, {url})
</script>
<Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden btn-square">
<Icon icon={MenuDots} />
{#if $status.theme !== "success"}
<div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-{$status.theme}"></div>
{:else if $notifications.has(path)}
<div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</Button>
+18 -14
View File
@@ -1,34 +1,38 @@
<script lang="ts">
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import VoiceRoomItem from "@app/components/VoiceRoomItem.svelte"
import {deriveRoom, deriveShouldNotify, getRoomType, RoomType} from "@app/core/state"
import {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes"
import {deriveShouldNotify} from "@app/core/state"
interface Props {
url: any
h: any
notify?: 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 shouldNotifyForSpace = deriveShouldNotify(url)
const shouldNotifyForRoom = deriveShouldNotify(url, h)
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
</script>
<SecondaryNavItem
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
<RoomNameWithImage {url} {h} />
{#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? VolumeLoud : VolumeCross} size={4} class="opacity-50" />
{/if}
</SecondaryNavItem>
{#if roomType === RoomType.Voice}
<VoiceRoomItem {url} {h} {replaceState} {notification} />
{:else}
<SecondaryNavItem href={path} {replaceState} {notification}>
<RoomNameWithImage {url} {h} />
{#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
{/if}
</SecondaryNavItem>
{/if}
+2 -2
View File
@@ -23,7 +23,7 @@
const back = () => history.back()
const onDelete = () => {
const onResolved = () => {
if ($reports.length === 0) {
back()
}
@@ -38,7 +38,7 @@
</ModalHeader>
<div class="flex flex-col gap-2">
{#each $reports as event (event.id)}
<ReportItem {url} {event} {onDelete} />
<ReportItem {url} {event} {onResolved} />
{:else}
<p class="py-12 text-center">No reports found.</p>
{/each}
+58 -20
View File
@@ -1,15 +1,16 @@
<script lang="ts">
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 type {TrustedEvent} from "@welshman/util"
import {MESSAGE} from "@welshman/util"
import type {TrustedEvent, Filter} from "@welshman/util"
import {sortEventsDesc} from "@welshman/util"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {fly} from "@lib/transition"
import Button from "@lib/components/Button.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"
type Props = {
@@ -19,14 +20,16 @@
const {url, h}: Props = $props()
const spaceMessages = deriveEventsForUrl(
url,
h ? [{kinds: [MESSAGE], "#h": [h]}] : [{kinds: [MESSAGE]}],
)
let term = $state("")
let show = $state(false)
let results = $state<TrustedEvent[]>([])
let loading = $state(false)
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 = () => {
show = true
@@ -40,21 +43,53 @@
const clear = () => {
term = ""
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 = () => {
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 getAgeSection = (createdAt: number) => {
@@ -122,10 +157,13 @@
oninput={onInput} />
</label>
<div class="max-h-[65vh] overflow-y-auto">
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
{#if !term}
<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>
{:else if loading}
<p class="text-sm opacity-70">Searching...</p>
{:else if eventsByAge.size === 0}
<p class="text-sm opacity-70">No results found.</p>
{:else}
+257
View File
@@ -0,0 +1,257 @@
<script lang="ts">
import cx from "classnames"
import {Track} from "livekit-client"
import {displayProfileByPubkey, loadProfile} from "@welshman/app"
import Pin from "@assets/icons/pin.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import VideoCallVideo from "@app/components/VideoCallVideo.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import {
currentVoiceSession,
currentVoiceRoom,
videoCallLayoutRevision,
videoPrimaryTileKey,
toggleVideoPrimaryTile,
pubkeyFromLiveKitIdentity,
} from "@app/voice"
type Variant = "mobile" | "desktop-split" | "desktop-full"
type Props = {
variant: Variant
url: string
h: string
visible?: boolean
class?: string
}
type Tile = {
identity: string
isLocal: boolean
trackSid: string
attachable: Track | undefined
source: Track.Source.Camera | Track.Source.ScreenShare
}
type TileLayout = "spotlight" | "default" | "strip"
const {variant, url, h, visible = true, class: className = ""}: Props = $props()
const roomMatches = $derived($currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h)
const showPanel = $derived(visible && roomMatches)
const tiles = $derived.by(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes
$videoCallLayoutRevision
const session = $currentVoiceSession
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
return []
}
const room = session.room
const out: Tile[] = []
const lp = room.localParticipant
if (session.cameraOn) {
const localPub = lp.getTrackPublication(Track.Source.Camera)
out.push({
identity: lp.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-camera",
attachable: localPub?.track,
source: Track.Source.Camera,
})
}
if (session.screenShareOn) {
const localPub = lp.getTrackPublication(Track.Source.ScreenShare)
out.push({
identity: lp.identity,
isLocal: true,
trackSid: localPub?.trackSid ?? "local-screen",
attachable: localPub?.track,
source: Track.Source.ScreenShare,
})
}
for (const rp of room.remoteParticipants.values()) {
const camPub = rp.getTrackPublication(Track.Source.Camera)
if (camPub?.isSubscribed && camPub.track) {
out.push({
identity: rp.identity,
isLocal: false,
trackSid: camPub.trackSid,
attachable: camPub.track,
source: Track.Source.Camera,
})
}
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
if (screenPub?.isSubscribed && screenPub.track) {
out.push({
identity: rp.identity,
isLocal: false,
trackSid: screenPub.trackSid,
attachable: screenPub.track,
source: Track.Source.ScreenShare,
})
}
}
return out
})
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
const tileKey = (t: Tile) => `${t.identity}\x1f${t.source}`
const primaryTile = $derived.by(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return undefined
return tiles.find(t => tileKey(t) === k)
})
const secondaryTiles = $derived.by(() => {
const p = primaryTile
if (p === undefined) return tiles
const pk = tileKey(p)
return tiles.filter(t => tileKey(t) !== pk)
})
const useSpotlightLayout = $derived(primaryTile !== undefined)
const useMultiGrid = $derived(!useSpotlightLayout && tiles.length > 2)
$effect(() => {
const k = $videoPrimaryTileKey
if (k === undefined) return
if (!tiles.some(t => tileKey(t) === k)) {
videoPrimaryTileKey.set(undefined)
}
})
$effect(() => {
for (const t of tiles) {
const pk = pubkeyFromLiveKitIdentity(t.identity)
if (pk) loadProfile(pk)
}
})
const labelFor = (identity: string, source: Tile["source"]) => {
const pk = pubkeyFromLiveKitIdentity(identity)
const name = pk ? displayProfileByPubkey(pk) : "Unknown"
return source === Track.Source.ScreenShare ? `${name} · screen` : name
}
const showTileGrid = $derived(tiles.length > 0)
const spotlightHandlerFor = (key: string) => () => {
toggleVideoPrimaryTile(key)
}
const panelChrome = $derived(
cx(
variant === "mobile" &&
"cb top-[calc(var(--sait)+6rem)] cw z-compose bg-[var(--video-call-panel-bg)] fixed inset-x-0 flex min-h-0 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2 pt-1 md:hidden",
variant === "desktop-split" &&
"cb ct cw-split-video z-compose bg-[var(--video-call-panel-bg)] fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
variant === "desktop-full" &&
"cb ct cw z-compose bg-[var(--video-call-panel-bg)] fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
className,
),
)
</script>
{#snippet videoTile(tile: Tile, layout: TileLayout)}
<div
class={cx(
"relative isolate overflow-hidden rounded-box shadow-sm",
layout === "spotlight" && "min-h-0 flex-1",
layout === "default" && "aspect-video w-full min-h-0",
layout === "strip" && "aspect-video w-44 shrink-0",
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-base-100",
)}>
{#if tile.attachable}
<VideoCallVideo
track={tile.attachable}
muted={tile.isLocal}
fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"}
class="pointer-events-none absolute inset-0" />
{:else}
<div class="absolute inset-0 flex items-center justify-center">
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
</div>
{/if}
<span
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
</span>
{#if tiles.length > 1}
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
<Button
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
aria-pressed={pinned}
class={cx(
"absolute right-1 top-1 z-20 btn btn-xs btn-square btn-ghost",
pinned ? "btn-active bg-primary/25 text-primary" : "bg-base-100/70",
)}
onclick={spotlightHandlerFor(tileKey(tile))}>
<Icon icon={Pin} size={3} />
</Button>
{/if}
</div>
{/snippet}
{#snippet videoPanelBody()}
{#if showTileGrid}
{#if useSpotlightLayout && primaryTile}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{@render videoTile(primaryTile, "spotlight")}
{#if secondaryTiles.length > 0}
<div
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
{#each secondaryTiles as tile (tileKey(tile))}
{@render videoTile(tile, "strip")}
{/each}
</div>
{/if}
</div>
{:else if useMultiGrid}
<div
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{:else}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{#each tiles as tile (tileKey(tile))}
{@render videoTile(tile, "default")}
{/each}
</div>
{/if}
{:else}
<div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-200/50 p-4 text-center text-sm opacity-80">
<p>No camera or screen share yet.</p>
<p class="text-xs">Use the camera or screen share control to share video.</p>
</div>
{/if}
{/snippet}
{#if showPanel}
<div class={panelChrome}>
{#if variant === "mobile"}
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="min-h-0 flex-1 overflow-hidden">
{@render videoPanelBody()}
</div>
<div class="shrink-0">
<VoiceWidget />
</div>
</div>
{:else}
{@render videoPanelBody()}
{/if}
</div>
{/if}
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import type {Track} from "livekit-client"
import cx from "classnames"
type Props = {
track: Track
muted?: boolean
fit?: "cover" | "contain"
class?: string
}
const {track, muted = true, fit = "cover", class: className = ""}: Props = $props()
let el = $state<HTMLVideoElement | undefined>()
$effect(() => {
const v = el
const t = track
if (!v) return
t.attach(v)
return () => {
t.detach(v)
}
})
</script>
<video
bind:this={el}
class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)}
playsinline
{muted}></video>
@@ -0,0 +1,163 @@
<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 videoInputs = $state<MediaDeviceInfo[]>([])
let selectedInput = $state("")
let selectedOutput = $state("")
let selectedVideo = $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")
videoInputs = devices.filter(d => d.kind === "videoinput")
} catch {
audioInputs = []
audioOutputs = []
videoInputs = []
}
}
$effect(() => {
void loadDevices()
const md = navigator.mediaDevices
if (!md?.addEventListener) return
const onDeviceChange = () => {
void loadDevices()
}
md.addEventListener("devicechange", onDeviceChange)
return () => {
md.removeEventListener("devicechange", onDeviceChange)
}
})
$effect(() => {
const session = $currentVoiceSession
if (!session) {
popModal()
return
}
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
})
const onInputChange = () => {
void switchVoiceActiveDevice(DeviceKind.AudioInput, selectedInput)
}
const onOutputChange = () => {
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
}
const onVideoChange = () => {
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
}
const onDone = () => {
popModal()
}
// Output not support in Safari
const canPickOutput = supportsAudioOutputSelection()
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Call settings</ModalTitle>
<ModalSubtitle>Microphone, speaker, and camera 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}
<FieldInline>
{#snippet label()}
<p>Camera</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={selectedVideo}
onchange={onVideoChange}
aria-label="Camera">
<option value="">Default camera</option>
{#each videoInputs as d (d.deviceId)}
<option value={d.deviceId}>
{d.label || `Camera ${d.deviceId.slice(0, 8)}`}
</option>
{/each}
</select>
{/snippet}
</FieldInline>
</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>
+253
View File
@@ -0,0 +1,253 @@
<script lang="ts">
import {readable} from "svelte/store"
import {fade, fly} from "svelte/transition"
import {browser} from "$app/environment"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import cx from "classnames"
import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import Videocamera from "@assets/icons/videocamera.svg?dataurl"
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
import Monitor from "@assets/icons/monitor.svg?dataurl"
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.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 {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes"
import {
VoiceState,
currentVoiceSession,
currentVoiceRoom,
voiceState,
voiceMobileRoomPanel,
voiceDesktopRoomPanel,
isLocalSpeaking,
leaveVoiceRoom,
toggleMute,
toggleCamera,
toggleScreenShare,
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 goToRoom = () => {
if (!targetRoom) return
const path = makeRoomPath(targetRoom.url, targetRoom.h)
if ($page.url.pathname !== path) {
void goto(path)
}
}
const openCallSettings = () => {
pushModal(VoiceCallAudioSettingsDialog)
}
let isMd = $state(
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches,
)
$effect(() => {
if (!browser) return
const mq = window.matchMedia("(min-width: 768px)")
const sync = () => {
isMd = mq.matches
}
sync()
mq.addEventListener("change", sync)
return () => mq.removeEventListener("change", sync)
})
const showVoiceLayoutToggle = $derived(
$voiceState === VoiceState.Connected &&
targetRoom !== undefined &&
getRoomType(targetRoom) === RoomType.Voice &&
typeof h === "string" &&
relay !== undefined &&
decodeRelay(relay) === targetRoom.url &&
h === targetRoom.h,
)
const layoutToggleActive = $derived(
showVoiceLayoutToggle &&
((!isMd && $voiceMobileRoomPanel === "chat") || (isMd && $voiceDesktopRoomPanel === "split")),
)
const onLayoutToggle = () => {
if (!showVoiceLayoutToggle) return
if (isMd) {
voiceDesktopRoomPanel.update(p => (p === "split" ? "chat" : "split"))
} else {
voiceMobileRoomPanel.update(p => (p === "chat" ? "video" : "chat"))
}
}
const chatUnread = $derived(
targetRoom !== undefined && $notifications.has(makeRoomPath(targetRoom.url, targetRoom.h)),
)
const mediaToggleClass = "center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
</script>
{#snippet mutedSlash(show: boolean)}
{#if show}
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
aria-hidden="true">
<span class="h-[1.3px] w-[150%] max-w-none shrink-0 -rotate-45 rounded-full bg-current"
></span>
</span>
{/if}
{/snippet}
{#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 items-start justify-between gap-2">
<button
type="button"
class="min-w-0 flex-1 rounded-lg px-1 py-0.5 text-left outline-none hover:bg-base-200/60 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-base-100"
onclick={goToRoom}
aria-label="Open room {roomName}">
<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>
</button>
{#if showVoiceLayoutToggle}
<Button
data-tip="Toggle Chat"
class={cx(
mediaToggleClass,
"relative shrink-0 overflow-visible",
layoutToggleActive && "text-primary",
)}
onclick={onLayoutToggle}>
<span class="relative inline-flex">
<Icon icon={ChatRound} size={4} />
{#if chatUnread}
<span
transition:fade={{duration: 150}}
class="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary ring-2 ring-base-100"
aria-hidden="true"></span>
{/if}
</span>
</Button>
{/if}
</div>
<div class="flex flex-wrap items-center gap-2">
{#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={cx(
mediaToggleClass,
"overflow-visible",
!$currentVoiceSession.muted && $isLocalSpeaking && "text-primary",
$currentVoiceSession.muted &&
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
)}
onclick={toggleMute}>
<span class="relative inline-flex items-center justify-center overflow-visible">
<Icon icon={Microphone} size={4} />
{@render mutedSlash($currentVoiceSession.muted)}
</span>
</Button>
<Button
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
onclick={toggleCamera}>
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
</Button>
<Button
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")}
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4} />
</Button>
<Button
data-tip="Call settings"
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
onclick={openCallSettings}>
<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}
+69 -9
View File
@@ -17,7 +17,7 @@ import {
} from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor"
import type {TrustedEvent, EventContent, Profile} from "@welshman/util"
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
import {
DELETE,
REPORT,
@@ -52,6 +52,7 @@ import {
editProfile,
createProfile,
uniqTags,
ManagementMethod,
} from "@welshman/util"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
@@ -72,6 +73,9 @@ import {
getPubkeyRelays,
userBlossomServerList,
getThunkError,
addRoomMember,
manageRelay,
getRelay,
} from "@welshman/app"
import {compressFile} from "@lib/html"
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
@@ -89,6 +93,7 @@ import {
stripPrefix,
relaysMostlyRestricted,
deriveSocket,
deriveSpaceMembers,
} from "@app/core/state"
// Utils
@@ -220,8 +225,7 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
}
}
const thunk = publishJoinRequest({url, claim})
const error = await waitForThunkError(thunk)
const error = await waitForThunkError(publishJoinRequest({url, claim}))
if (shouldIgnoreError(error)) return
if (!claim && error.includes("invite code size")) return
@@ -408,12 +412,9 @@ export const toggleRoomNotifications = async (url: string, h: string) => {
let updated: typeof alerts
if (!existing) {
// No space settings yet, create one with this room as an exception (default is notify: true)
updated = [...alerts, {url, notify: true, exceptions: [h]}]
} else {
// Toggle exception status
const hasException = existing.exceptions.includes(h)
const exceptions = hasException
const exceptions = existing.exceptions.includes(h)
? remove(h, existing.exceptions)
: append(h, existing.exceptions)
@@ -550,6 +551,12 @@ export const createInvoice = async ({
export const normalizeBlossomUrl = (url: string) => normalizeUrl(url.replace(/^ws/, "http"))
export const fetchHasBlossomSupport = async (url: string) => {
const relay = getRelay(url)
if (relay?.supported_nips?.map(String).includes("BUD-02")) {
return true
}
const server = normalizeBlossomUrl(url)
const $signer = signer.get() || Nip01Signer.ephemeral()
const headers: Record<string, string> = {
@@ -639,13 +646,19 @@ export const uploadFile = async (file: File, options: UploadFileOptions = {}) =>
const res = await uploadBlob(server, file, {authEvent})
const text = await res.text()
let {uploaded, url, ...task} = parseJson(text) || {}
let task
try {
task = parseJson(text)
} catch (e) {
return {error: text}
}
if (!uploaded) {
if (!task?.uploaded) {
return {error: text || `Failed to upload file (HTTP ${res.status})`}
}
// Always append correct file extension if we encrypted the file, or if it's missing
let url = task.url
if (options.encrypt) {
url = url.replace(/\.\w+$/, "") + ext
} else if (new URL(url).pathname.split(".").length === 1) {
@@ -699,3 +712,50 @@ export const updateProfile = ({
return publishThunk({event, relays})
}
// Admin actions
export const addSpaceMembers = async (
url: string,
pubkeys: string[],
): Promise<string | undefined> => {
const spaceMembers = get(deriveSpaceMembers(url))
const results = await Promise.all(
pubkeys
.filter(pubkey => !spaceMembers.includes(pubkey))
.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
for (const {error} of results) {
if (error) {
return error
}
}
}
export const addRoomMembers = async (
url: string,
room: PublishedRoomMeta,
pubkeys: string[],
): Promise<string | undefined> => {
const error = await addSpaceMembers(url, pubkeys)
if (error) {
return error
}
const errors = await Promise.all(
pubkeys.map(pubkey => waitForThunkError(addRoomMember(url, room, pubkey))),
)
for (const error of errors) {
if (error) {
return error
}
}
}
+16 -12
View File
@@ -4,7 +4,7 @@ import {
uniq,
int,
YEAR,
DAY,
WEEK,
insertAt,
sortBy,
now,
@@ -22,6 +22,7 @@ import {
getAddress,
isShareableRelayUrl,
getRelaysFromList,
sortEventsDesc,
} from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util"
import {load, request} from "@welshman/net"
@@ -47,11 +48,11 @@ export const makeFeed = ({
onForwardExhausted?: () => void
at?: number
}) => {
const interval = int(DAY)
const controller = new AbortController()
const events = writable<TrustedEvent[]>([])
let buffer: TrustedEvent[] = []
let interval = int(WEEK)
let buffer = sortEventsDesc(getEventsForUrl(url, filters))
let backwardWindow = [at - interval, at]
let forwardWindow = [at, at + interval]
@@ -111,13 +112,20 @@ export const makeFeed = ({
}),
]
const loadTimeframe = (since: number, until: number) => {
request({
const loadTimeframe = async (since: number, until: number) => {
const events = await request({
relays: [url],
autoClose: true,
signal: controller.signal,
filters: filters.map(filter => ({...filter, since, until})),
})
// If we found nothing, accelerate
if (events.length === 0) {
interval = Math.round(interval * 1.1)
} else {
interval = int(WEEK)
}
}
const backwardScroller = createScroller({
@@ -129,7 +137,7 @@ export const makeFeed = ({
backwardWindow = [since - interval, since]
for (const event of buffer.splice(0)) {
for (const event of buffer.splice(0, 30)) {
insertEvent(event)
}
@@ -152,7 +160,7 @@ export const makeFeed = ({
forwardWindow = [until, until + interval]
for (const event of buffer.splice(0)) {
for (const event of buffer.splice(0, 30)) {
insertEvent(event)
}
@@ -165,10 +173,6 @@ export const makeFeed = ({
},
})
for (const event of getEventsForUrl(url, filters)) {
insertEvent(event)
}
return {
events,
cleanup: () => {
@@ -191,7 +195,7 @@ export const makeCalendarFeed = ({
element: HTMLElement
onExhausted?: () => void
}) => {
const interval = int(5, DAY)
const interval = int(5, WEEK)
const controller = new AbortController()
let exhaustedScrollers = 0
+159 -27
View File
@@ -14,6 +14,7 @@ import {
uniqBy,
sortBy,
append,
reject,
sort,
uniq,
indexBy,
@@ -30,6 +31,7 @@ import {
groupBy,
remove,
simpleCache,
removeUndefined,
} from "@welshman/lib"
import type {Override} from "@welshman/lib"
import type {RepositoryUpdate} from "@welshman/net"
@@ -98,6 +100,7 @@ import {
REPOST,
GENERIC_REPOST,
asDecryptedEvent,
getTagValue,
getGroupTags,
getListTags,
getPubkeyTagValues,
@@ -110,6 +113,7 @@ import {
readRoomMeta,
makeRoomMeta,
ManagementMethod,
sortEventsAsc,
sortEventsDesc,
getAddress,
Address,
@@ -124,6 +128,7 @@ import type {
RelayProfile,
PublishedList,
PublishedRoomMeta,
RoomMeta,
List,
Filter,
} from "@welshman/util"
@@ -145,6 +150,7 @@ import {
displayProfileByPubkey,
getProfile,
} from "@welshman/app"
import {checkRelayHasLivekit} from "$lib/livekit"
import {readFeed} from "@lib/feeds"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
@@ -185,7 +191,9 @@ export const PLATFORM_TERMS = import.meta.env.VITE_PLATFORM_TERMS
export const PLATFORM_PRIVACY = import.meta.env.VITE_PLATFORM_PRIVACY
export const PLATFORM_LOGO = PLATFORM_URL + "/logo.png"
export const PLATFORM_LOGO = import.meta.env.PROD
? PLATFORM_URL + "/logo.png"
: import.meta.env.VITE_PLATFORM_LOGO.replace(/^static/, "") || PLATFORM_URL + "/logo.png"
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
@@ -284,7 +292,7 @@ export const deriveRelaySignedEvents = (url: string, filters: Filter[] = [{}]) =
derived(
[deriveRelay(url), deriveEventsForUrl(url, filters)],
([relay, events]) => events,
// khatru doesn't support relay.self, uncomment when it's ready
// TODO: khatru doesn't support relay.self, uncomment when it's ready
// filter(spec({pubkey: relay.self}), events)
)
@@ -425,10 +433,11 @@ export type PushSubscription = {
export type PushState = {
token?: string
useFallback?: boolean
subscription?: PushSubscription
}
export const notificationState = withGetter(writable<PushState>({}))
export const pushState = withGetter(writable<PushState>({}))
// Chats
@@ -457,7 +466,7 @@ export const splitChatId = (id: string) => getChatPubkeys(id.split(","))
export const chatsById = call(() => {
const chatsById = new Map<string, Chat>()
const chatsByPubkey = new Map<string, Chat[]>()
const chatsByPubkey = new Map<string, string[]>()
const addSearchText = (chat: Override<Chat, {search_text?: string}>) => {
chat.search_text =
@@ -469,6 +478,12 @@ export const chatsById = call(() => {
}
return readable(chatsById, set => {
const indexChatByPubkeys = (chat: Chat) => {
for (const pubkey of chat.pubkeys) {
chatsByPubkey.set(pubkey, uniq(append(chat.id, chatsByPubkey.get(pubkey) || [])))
}
}
const addEvents = (events: TrustedEvent[]) => {
let dirty = false
for (const event of events) {
@@ -484,21 +499,19 @@ export const chatsById = call(() => {
const updatedChat = addSearchText({id, pubkeys, messages, last_activity})
chatsById.set(id, updatedChat)
for (const pubkey of pubkeys) {
const pubkeyChats = chatsByPubkey.get(pubkey) || []
const uniqueChats = uniqBy(chat => chat.id, append(updatedChat, pubkeyChats))
chatsByPubkey.set(pubkey, uniqueChats)
}
indexChatByPubkeys(updatedChat)
dirty = true
}
if (event.kind === PROFILE) {
for (const chat of chatsByPubkey.get(event.pubkey) || []) {
addSearchText(chat)
dirty = true
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
const chat = chatsById.get(chatId)
if (chat) {
addSearchText(chat)
dirty = true
}
}
}
}
@@ -508,10 +521,39 @@ export const chatsById = call(() => {
}
}
addEvents(repository.query([{kinds: [...DM_KINDS, PROFILE]}]))
const removeEvents = (removed: Set<string>) => {
let dirty = false
for (const id of removed) {
const event = repository.getEvent(id)
if (event && DM_KINDS.includes(event.kind)) {
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
const chat = chatsById.get(chatId)
if (chat) {
chat.messages = reject(spec({id: event.id}), chat.messages)
dirty = true
}
}
}
}
if (dirty) {
set(chatsById)
}
}
addEvents(repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
const unsubscribers = [
on(repository, "update", ({added}: RepositoryUpdate) => addEvents(added)),
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
// Do this async so that profiles are populated
setTimeout(() => {
addEvents(added)
removeEvents(removed)
}, 50)
}),
]
return () => unsubscribers.forEach(call)
@@ -536,17 +578,25 @@ export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => {
// Rooms
export enum RoomType {
Text = "text",
Voice = "voice",
}
export type Room = PublishedRoomMeta & {
id: string
url: string
}
export const getRoomType = (room: RoomMeta): RoomType =>
room.livekit ? RoomType.Voice : RoomType.Text
export const makeRoomId = (url: string, h: string) => `${url}'${h}`
export const splitRoomId = (id: string) => id.split("'")
export const hasNip29 = (relay?: RelayProfile) =>
relay?.supported_nips?.map?.(String)?.includes?.("29")
Boolean(relay?.supported_nips?.map?.(String)?.includes?.("29"))
export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({
tracker,
@@ -624,7 +674,7 @@ export const deriveRoom = call(() => {
return (url: string, h: string) =>
derived(
_deriveRoom(makeRoomId(url, h)),
room => room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})},
room => (room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}) as Room,
)
})
@@ -632,6 +682,30 @@ export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
export const deriveVoiceRooms = (url: string) =>
derived(roomsById, $roomsById => {
const set = new Set<string>()
for (const room of $roomsById.values()) {
if (room.url === url && room.livekit) {
set.add(room.h)
}
}
return set
})
export const deriveOtherVoiceRooms = (url: string) =>
derived([deriveVoiceRooms(url), deriveUserRooms(url)], ([$roomsWithLivekit, $userRooms]) => {
const rooms: string[] = []
for (const h of $roomsWithLivekit) {
if (!$userRooms.includes(h)) {
rooms.push(h)
}
}
return sortBy(roomComparator(url), uniq(rooms))
})
// User space/room lists
export const groupListsByPubkey = deriveItemsByKey({
@@ -721,17 +795,20 @@ export const deriveUserRooms = (url: string) =>
})
export const deriveOtherRooms = (url: string) =>
derived([deriveUserRooms(url), roomsByUrl], ([$userRooms, $roomsByUrl]) => {
const rooms: string[] = []
derived(
[deriveUserRooms(url), deriveVoiceRooms(url), roomsByUrl],
([$userRooms, voiceRooms, $roomsByUrl]) => {
const rooms: string[] = []
for (const {h} of $roomsByUrl.get(url) || []) {
if (!$userRooms.includes(h)) {
rooms.push(h)
for (const {h} of $roomsByUrl.get(url) || []) {
if (!$userRooms.includes(h) && !voiceRooms.has(h)) {
rooms.push(h)
}
}
}
return sortBy(roomComparator(url), uniq(rooms))
})
return sortBy(roomComparator(url), uniq(rooms))
},
)
// Space/room memberships
@@ -800,7 +877,7 @@ export const deriveRoomMembers = (url: string, h: string) => {
const members = new Set<string>()
for (const event of sortBy(e => -e.created_at, $events)) {
for (const event of sortEventsAsc($events)) {
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === ROOM_ADD_MEMBER) {
@@ -834,6 +911,50 @@ export const deriveRoomAdmins = (url: string, h: string) => {
})
}
// Action items (admin review queue)
// const pendingJoins: TrustedEvent[] = []
export const deriveSpaceActionItems = (url: string) =>
derived(
deriveEventsForUrl(url, [
{
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS],
},
]),
$events => {
const getRoomId = (e: TrustedEvent) =>
getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
const reports = $events.filter(e => e.kind === REPORT)
const pendingJoins: TrustedEvent[] = []
// Room-level join requests — most recent per pubkey+h
for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
if (!h) continue
const roomJoins = roomEvents.filter(spec({kind: ROOM_JOIN}))
const roomLeaves = roomEvents.filter(spec({kind: ROOM_LEAVE}))
const roomMembersEvent = roomEvents.find(spec({kind: ROOM_MEMBERS}))
const roomMembers = getTagValues("p", roomMembersEvent?.tags ?? [])
pendingJoins.push(
...removeUndefined(
Array.from(groupBy(e => e.pubkey, roomJoins).values())
.map(sortEventsDesc)
.map(first),
).filter(({pubkey, created_at}) => {
if (roomMembers.includes(pubkey)) return false
if (gt(roomMembersEvent?.created_at, created_at)) return false
if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false
return true
}),
)
}
return sortEventsDesc([...reports, ...pendingJoins])
},
)
// User membership status
export enum MembershipStatus {
@@ -1134,6 +1255,12 @@ export const deriveSupportedMethods = simpleCache(([url]: [string]) => {
})
})
export const deriveHasLivekit = simpleCache(([url]: [string]) =>
readable<boolean | undefined>(undefined, set => {
checkRelayHasLivekit(url).then(has => set(has))
}),
)
export const deriveTimeout = (timeout: number) => {
const store = writable<boolean>(false)
@@ -1193,3 +1320,8 @@ export const shouldNotify = (url: string, h?: string) => getShouldNotify(getSett
export const deriveShouldNotify = (url: string, h?: string) =>
derived(userSettingsValues, $settings => getShouldNotify($settings, url, h))
// Whatever who cares
export const hasNip50 = (relay?: RelayProfile) =>
Boolean(relay?.supported_nips?.map?.(String)?.includes?.("50"))
+52 -17
View File
@@ -1,6 +1,6 @@
import {call} from "@welshman/lib"
import {SecureStorage} from "@aparajita/capacitor-secure-storage"
import {Preferences} from "@capacitor/preferences"
import {Filesystem, Directory} from "@capacitor/filesystem"
import {IDB} from "@lib/indexeddb"
export const kv = call(() => {
@@ -31,22 +31,57 @@ export const kv = call(() => {
return {get, set, clear}
})
export const db = new IDB({name: "flotilla-9gl", version: 1})
export const ss = call(() => {
let p = Promise.resolve()
// Migration - we used to use capacitor's filesystem for storage, clear it out since we're
// going back to indexeddb
call(async () => {
const res = await Filesystem.readdir({
path: "",
directory: Directory.Data,
})
const get = async <T>(key: string): Promise<T | undefined> => {
let value = await SecureStorage.getItem(key)
await Promise.all(
res.files.map(file =>
Filesystem.deleteFile({
path: file.name,
directory: Directory.Data,
}),
),
)
if (!value) {
const legacy = await Preferences.get({key})
if (legacy.value) {
value = legacy.value
await SecureStorage.setItem(key, legacy.value)
await Preferences.remove({key})
}
}
if (!value) return undefined
try {
return JSON.parse(value)
} catch (e) {
return undefined
}
}
const set = async <T>(key: string, value: T): Promise<void> => {
p = p.then(() => SecureStorage.setItem(key, JSON.stringify(value)))
await p
}
const clear = async () => {
p = p.then(() => SecureStorage.clear())
await p
}
return {get, set, clear}
})
export const db = new IDB({
name: "flotilla-9gl",
version: 1,
stores: [
{name: "events", keyPath: "id"},
{name: "tracker", keyPath: "id"},
{name: "relays", keyPath: "url"},
{name: "relayStats", keyPath: "url"},
{name: "handles", keyPath: "nip05"},
{name: "zappers", keyPath: "lnurl"},
{name: "plaintext", keyPath: "key"},
{name: "wrapManager", keyPath: "id"},
],
})
+68 -70
View File
@@ -1,7 +1,7 @@
import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store"
import {derived, get} from "svelte/store"
import {last, call, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
import {
getListTags,
getRelayTagValues,
@@ -18,8 +18,9 @@ import {
RELAY_REMOVE_MEMBER,
isSignedEvent,
unionFilters,
getTagValue,
} from "@welshman/util"
import type {Filter} from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util"
import {request, requestOne, Difference, DifferenceEvent} from "@welshman/net"
import {
pubkey,
@@ -55,6 +56,7 @@ import {
loadFeedsForPubkey,
} from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands"
import {LIVEKIT_PARTICIPANTS} from "@app/voice"
// Utils
@@ -62,12 +64,24 @@ type SyncOpts = {
url: string
signal: AbortSignal
filters: Filter[]
onEvent?: (event: TrustedEvent) => void
}
const pullOneWithFallback = async (url: string, filter: Filter, signal: AbortSignal) => {
const pullOneWithFallback = async (
url: string,
filter: Filter,
signal: AbortSignal,
onEvent?: (event: TrustedEvent) => void,
) => {
const cachedEvents = repository.query([filter]).filter(isSignedEvent)
const since = last(cachedEvents.slice(10))?.created_at || 0
if (onEvent) {
for (const event of cachedEvents) {
onEvent(event)
}
}
const shouldFallback =
!hasNegentropy(url) ||
(await new Promise(resolve => {
@@ -79,7 +93,7 @@ const pullOneWithFallback = async (url: string, filter: Filter, signal: AbortSig
diff.on(DifferenceEvent.Close, () => {
for (const ids of chunk(100, Array.from(diff.need))) {
requestOne({relay: url, signal, autoClose: true, filters: [{ids}]})
requestOne({relay: url, signal, autoClose: true, filters: [{ids}], onEvent})
}
resolve(false)
@@ -87,29 +101,29 @@ const pullOneWithFallback = async (url: string, filter: Filter, signal: AbortSig
}))
if (shouldFallback && !signal.aborted) {
request({relays: [url], signal, autoClose: true, filters: [{...filter, since}]})
request({relays: [url], signal, autoClose: true, filters: [{since, ...filter}], onEvent})
}
}
export const pullWithFallback = async ({url, signal, filters}: SyncOpts) => {
export const pullWithFallback = async ({url, signal, filters, onEvent}: SyncOpts) => {
await loadRelay(url)
if (signal.aborted) return
for (const filter of filters) {
pullOneWithFallback(url, filter, signal)
pullOneWithFallback(url, filter, signal, onEvent)
}
}
const listen = ({url, signal, filters}: SyncOpts) => {
const listen = ({url, signal, filters, onEvent}: SyncOpts) => {
const relays = [url]
request({relays, signal, filters: unionFilters(filters.map(assoc("limit", 0)))})
request({relays, signal, filters: unionFilters(filters.map(assoc("limit", 0))), onEvent})
}
const pullAndListen = ({url, filters, signal}: SyncOpts) => {
pullWithFallback({url, signal, filters})
listen({url, signal, filters})
const pullAndListen = (options: SyncOpts) => {
pullWithFallback(options)
listen(options)
}
// Relays
@@ -254,68 +268,52 @@ const syncUserData = () => {
// Spaces
const syncSpace = (url: string, rooms: string[]) => {
const since = ago(WEEK)
const seen = new Set<string>()
const controller = new AbortController()
// Relay-level kinds don't need #h tags
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
})
// Room metadata uses #d tags, not #h, so no filtering needed
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS]}],
})
// Room-scoped kinds: add #h tags when we know which rooms the user is in.
// This avoids sending broad filters that picky relays reject.
const roomKinds = [ROOM_DELETE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]
const since = ago(WEEK)
if (rooms.length > 0) {
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: roomKinds, "#h": rooms}],
})
pullAndListen({
url,
signal: controller.signal,
filters: [
{kinds: MESSAGE_KINDS, "#h": rooms, since},
makeCommentFilter(CONTENT_KINDS, {"#h": rooms, since}),
],
})
listen({
url,
signal: controller.signal,
filters: [{kinds: REACTION_KINDS, "#h": rooms}],
})
} else {
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: roomKinds}],
})
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: MESSAGE_KINDS, since}, makeCommentFilter(CONTENT_KINDS, {since})],
})
listen({
url,
signal: controller.signal,
filters: [{kinds: REACTION_KINDS}],
})
const pullRoomContent = (room: string) => {
if (!seen.has(room)) {
seen.add(room)
pullAndListen({
url,
signal: controller.signal,
filters: [
{kinds: MESSAGE_KINDS, since, "#h": [room]},
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
],
})
}
}
for (const room of rooms) {
pullRoomContent(room)
}
const relayKinds = [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
const roomMemberKinds = [ROOM_DELETE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]
pullAndListen({
url,
signal: controller.signal,
filters: [
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
makeCommentFilter(CONTENT_KINDS, {since}),
],
onEvent: event => {
if (event.kind === ROOM_META) {
ifLet(getTagValue("d", event.tags), pullRoomContent)
}
},
})
listen({
url,
signal: controller.signal,
filters: [{kinds: REACTION_KINDS}],
})
return () => controller.abort()
}
+107
View File
@@ -0,0 +1,107 @@
import {derived, get} from "svelte/store"
import {not, ifLet, sample} from "@welshman/lib"
import {getRelaysFromList, RelayMode} from "@welshman/util"
import {
getRelay,
setWriteRelays,
setReadRelays,
setSearchRelays,
setMessagingRelays,
userRelayList,
userSearchRelayList,
userMessagingRelayList,
} from "@welshman/app"
import {hasNip50, DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/core/state"
export type HealthCheckContext = {
readRelays: string[]
writeRelays: string[]
messagingRelays: string[]
searchRelays: string[]
}
export type HealthCheck = {
title: string
description: string
action: string
isPending: (context: HealthCheckContext) => boolean
apply: (context: HealthCheckContext) => unknown
}
export const healthCheckContext = derived(
[userRelayList, userSearchRelayList, userMessagingRelayList],
([$userRelayList, $userSearchRelayList, $userMessagingRelayList]) => {
return {
readRelays: getRelaysFromList($userRelayList, RelayMode.Read),
writeRelays: getRelaysFromList($userRelayList, RelayMode.Write),
searchRelays: getRelaysFromList($userSearchRelayList),
messagingRelays: getRelaysFromList($userMessagingRelayList),
}
},
)
const healthChecks: HealthCheck[] = [
{
title: "Missing Inbox Relays",
description: "Other people aren't currently able to reliably tag you in public notes.",
action: "Update",
isPending: context => context.readRelays.length <= 1,
apply: () => setReadRelays(DEFAULT_RELAYS),
},
{
title: "Missing Outbox Relays",
description: "Other people aren't currently able to reliably find your public notes.",
action: "Update",
isPending: context => context.writeRelays.length <= 1,
apply: () => setWriteRelays(DEFAULT_RELAYS),
},
{
title: "Missing DM Relays",
description: "You aren't currently able to reliably send or receive direct messages.",
action: "Update",
isPending: context => context.messagingRelays.length <= 1,
apply: () => setMessagingRelays(DEFAULT_MESSAGING_RELAYS),
},
{
title: "Too Many Inbox Relays",
description:
"You have more inbox relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
isPending: context => context.readRelays.length > 8,
apply: context => setReadRelays(sample(5, context.readRelays)),
},
{
title: "Too Many Outbox Relays",
description:
"You have more outbox relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
isPending: context => context.writeRelays.length > 8,
apply: context => setWriteRelays(sample(5, context.writeRelays)),
},
{
title: "Too Many DM Relays",
description:
"You have more DM relays than is really necessary, which can affect resource usage.",
action: "Prune Selections",
isPending: context => context.messagingRelays.length > 8,
apply: context => setMessagingRelays(sample(5, context.messagingRelays)),
},
{
title: "Invalid Search Relays",
description: "Some of your search relays don't support search.",
action: "Remove Invalid",
isPending: context => context.searchRelays.some(url => not(ifLet(getRelay(url), hasNip50))),
apply: context =>
setSearchRelays(context.searchRelays.filter(url => ifLet(getRelay(url), hasNip50))),
},
]
export const isHealthCheckPending = (healthCheck: HealthCheck) =>
healthCheck.isPending(get(healthCheckContext))
export const applyHealthCheck = (healthCheck: HealthCheck) =>
healthCheck.apply(get(healthCheckContext))
export const pendingHealthChecks = derived(healthCheckContext, ctx =>
healthChecks.filter(hc => hc.isPending(ctx)),
)
+1
View File
@@ -8,6 +8,7 @@ export const setupHistory = () =>
if ($page.params.relay) {
lastPageBySpaceUrl.set($page.params.relay, $page.url.pathname)
}
if ($page.params.chat) {
lastChatUrl = $page.url.pathname
}
+2 -1
View File
@@ -1,4 +1,4 @@
import {kv, db} from "@app/core/storage"
import {db, kv, ss} from "@app/core/storage"
import {Push} from "@app/util/notifications"
import {deactivateCurrentPomadeSession} from "@app/util/pomade"
@@ -6,6 +6,7 @@ export const logout = async () => {
await deactivateCurrentPomadeSession()
await Push.disable()
await kv.clear()
await ss.clear()
await db.clear()
localStorage.clear()
+33 -3
View File
@@ -1,5 +1,5 @@
import type {Component} from "svelte"
import {writable} from "svelte/store"
import {get, writable} from "svelte/store"
import {randomId, always, assoc, Emitter} from "@welshman/lib"
import {deriveDeduplicated} from "@welshman/store"
import {goto} from "$app/navigation"
@@ -7,6 +7,7 @@ import {page} from "$app/stores"
export type ModalOptions = {
drawer?: boolean
nested?: boolean
noEscape?: boolean
fullscreen?: boolean
replaceState?: boolean
@@ -24,8 +25,18 @@ export const emitter = new Emitter()
export const modals = writable<Record<string, Modal>>({})
const getIdsFromHash = (hash: string) => hash.slice(1).split(",").filter(Boolean)
export const modalStack = deriveDeduplicated([page, modals], ([$page, $modals]) => {
return getIdsFromHash($page.url.hash)
.map(id => $modals[id])
.filter(Boolean)
})
export const modal = deriveDeduplicated([page, modals], ([$page, $modals]) => {
return $modals[$page.url.hash.slice(1)]
const ids = getIdsFromHash($page.url.hash)
return $modals[ids.at(-1) || ""]
})
export const pushModal = (
@@ -35,10 +46,12 @@ export const pushModal = (
) => {
const id = randomId()
const path = options.path || ""
const existingIds = getIdsFromHash(get(page).url.hash)
const ids = options.nested ? [...existingIds, id] : [id]
modals.update(assoc(id, {id, component, props, options}))
goto(path + "#" + id, {replaceState: options.replaceState})
goto(path + "#" + ids.join(","), {replaceState: options.replaceState})
return id
}
@@ -49,7 +62,24 @@ export const pushDrawer = (
options: ModalOptions = {},
) => pushModal(component, props, {...options, drawer: true})
export const popModal = () => {
const url = get(page).url
const ids = getIdsFromHash(url.hash)
if (ids.length === 0) {
return
}
const next = ids.slice(0, -1).join(",")
const hash = next ? `#${next}` : ""
goto(url.pathname + url.search + hash, {replaceState: true})
}
export const clearModals = () => {
const url = get(page).url
goto(url.pathname + url.search, {replaceState: true})
modals.update(always({}))
emitter.emit("close")
}
+29 -521
View File
@@ -1,87 +1,24 @@
import type {Unsubscriber, Readable, Subscriber} from "svelte/store"
import {derived, get} from "svelte/store"
import {Capacitor} from "@capacitor/core"
import {derived} from "svelte/store"
import {Badge} from "@capawesome/capacitor-badge"
import {PushNotifications} from "@capacitor/push-notifications"
import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications"
import {synced, throttled, withGetter} from "@welshman/store"
import {load, LOCAL_RELAY_URL} from "@welshman/net"
import {
pubkey,
tracker,
repository,
publishThunk,
loadRelay,
relaysByUrl,
waitForThunkError,
userMessagingRelayList,
} from "@welshman/app"
import {
on,
call,
find,
assoc,
poll,
prop,
hash,
spec,
first,
identity,
now,
maybe,
throttle,
} from "@welshman/lib"
import type {TrustedEvent, Filter} from "@welshman/util"
import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app"
import {assoc, prop, first, identity, groupBy, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {deriveEventsByIdByUrl} from "@welshman/store"
import {sortEventsDesc, getTagValue} from "@welshman/util"
import {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/util/routes"
import {
DELETE,
getTagValue,
getPubkeyTagValues,
getRelaysFromList,
matchFilter,
matchFilters,
getIdFilters,
sortEventsDesc,
makeEvent,
Address,
} from "@welshman/util"
import {buildUrl} from "@lib/util"
import {
makeSpacePath,
makeRoomPath,
makeSpaceChatPath,
makeChatPath,
getEventPath,
goToEvent,
} from "@app/util/routes"
import {
DM_KINDS,
CONTENT_KINDS,
MESSAGE_KINDS,
PUSH_BRIDGE,
PUSH_SERVER,
notificationSettings,
notificationState,
chatsById,
userSettingsValues,
userGroupList,
getSpaceUrlsFromGroupList,
getSpaceRoomsFromGroupList,
makeCommentFilter,
userSpaceUrls,
shouldNotify,
hasNip29,
device,
} from "@app/core/state"
import {kv} from "@app/core/storage"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
// Temporarily copied from welshman
type Stores = Readable<any> | [Readable<any>, ...Array<Readable<any>>] | Array<Readable<any>>
const merged = <S extends Stores>(stores: S) => derived(stores, identity)
export {Push} from "@app/util/push"
// Checked state
@@ -108,13 +45,9 @@ export const syncChecked = () => {
.map((_, i, segments) => segments.slice(0, i + 1).join("/"))
.slice(1)
// Set checked when we enter and when we leave a given page
return page.subscribe($page => {
// Set checked when we leave a given page
checked.update($checked => {
for (const path of getPaths($page.url.pathname)) {
$checked[path] = now()
}
for (const path of getPaths(prev)) {
$checked[path] = now()
}
@@ -122,6 +55,17 @@ export const syncChecked = () => {
return $checked
})
// Set checked when we visit a given page - but delay it a tad
setTimeout(() => {
checked.update($checked => {
for (const path of getPaths($page.url.pathname)) {
$checked[path] = now()
}
return $checked
})
}, 300)
prev = $page.url.pathname
})
}
@@ -130,7 +74,7 @@ export const syncChecked = () => {
export const allNotifications = derived(
throttled(
2000,
1000,
derived(
[
pubkey,
@@ -180,30 +124,23 @@ export const allNotifications = derived(
for (const url of getSpaceUrlsFromGroupList($userGroupList)) {
const spacePath = makeSpacePath(url)
const spacePathMobile = spacePath + ":mobile"
const eventsById = eventsByIdByUrl.get(url) || new Map()
const latestEvent = first(sortEventsDesc(eventsById.values()))
if (hasNotification(spacePath, latestEvent)) {
paths.add(spacePath)
}
const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values())
if (hasNip29($relaysByUrl.get(url))) {
for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) {
const roomPath = makeRoomPath(url, h)
const latestEvent = find(e => e.tags.some(spec(["h", h])), eventsById.values())
for (const [h, [latestEvent]] of groupBy(e => getTagValue("h", e.tags), events)) {
if (h) {
const roomPath = makeRoomPath(url, h)
if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePathMobile)
paths.add(spacePath)
paths.add(roomPath)
if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePath)
paths.add(roomPath)
}
}
}
} else {
const messagesPath = makeSpaceChatPath(url)
if (hasNotification(messagesPath, first(eventsById.values()))) {
paths.add(spacePathMobile)
if (hasNotification(messagesPath, first(events))) {
paths.add(spacePath)
paths.add(messagesPath)
}
@@ -218,51 +155,6 @@ export const notifications = derived([page, allNotifications], ([$page, $allNoti
return new Set([...$allNotifications].filter(p => !$page.url.pathname.startsWith(p)))
})
export const onNotification = call(() => {
const allFilters = [{kinds: [...MESSAGE_KINDS, ...DM_KINDS]}, makeCommentFilter(MESSAGE_KINDS)]
const filters = allFilters.map(assoc("since", now()))
const subscribers: Subscriber<TrustedEvent>[] = []
let unsubscribe: Unsubscriber | undefined
return (f: (event: TrustedEvent) => void) => {
subscribers.push(f)
if (!unsubscribe) {
unsubscribe = on(repository, "update", ({added}) => {
const $pubkey = pubkey.get()
for (const event of added) {
if (event.pubkey == $pubkey) {
continue
}
const h = getTagValue("h", event.tags)
if (Array.from(tracker.getRelays(event.id)).every(url => !shouldNotify(url, h))) {
continue
}
if (matchFilters(filters, event)) {
for (const f of subscribers) {
f(event)
}
}
}
})
}
return () => {
subscribers.splice(subscribers.indexOf(f), 1)
if (subscribers.length === 0) {
unsubscribe?.()
unsubscribe = undefined
}
}
}
})
// Badges
export const syncBadges = () =>
@@ -287,387 +179,3 @@ export const clearBadges = async () => {
// pass - firefox doesn't support this
}
}
// Push notifications
interface IPushAdapter {
request: (prompt?: boolean) => Promise<string>
disable: () => Promise<void>
enable: () => Promise<void>
}
class CapacitorNotifications implements IPushAdapter {
_controller = maybe<AbortController>()
async request(prompt = true) {
let status = await PushNotifications.checkPermissions()
if (prompt && ["prompt", "prompt-with-rationale"].includes(status.receive)) {
status = await PushNotifications.requestPermissions()
}
if (status.receive !== "granted") {
return status.receive
}
let {token} = notificationState.get()
if (!token) {
const listeners = [
PushNotifications.addListener("registration", ({value}: Token) => {
token = value
}),
PushNotifications.addListener("registrationError", (error: RegistrationError) => {
console.error(error)
}),
]
await Promise.all([
PushNotifications.register(),
poll({
condition: () => Boolean(token),
signal: AbortSignal.timeout(5000),
}),
])
listeners.forEach(p => p.then(listener => listener.remove()))
notificationState.update(assoc("token", token))
}
return token ? "granted" : "denied"
}
async _syncServer(signal: AbortSignal) {
const {token, subscription} = notificationState.get()
if (!token) {
throw new Error("Attempted to sync push server without a token")
}
if (!subscription) {
try {
const channel = Capacitor.getPlatform() === "ios" ? "apns" : "fcm"
const url = buildUrl(PUSH_SERVER, "subscription", channel)
const res = await fetch(url, {
signal,
method: "POST",
body: JSON.stringify({token}),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
})
if (!res.ok) {
console.warn(`Failed to register with push server (status ${res.status})`)
} else {
const json = await res.json()
if (json?.callback && json?.key) {
notificationState.update(assoc("subscription", json))
} else {
console.warn("Failed to register with push server (bad response)")
}
}
} catch (e) {
console.warn("Failed to register with push server:", e)
}
}
}
_getSubscriptionIdentifier = (relay: string, key: string) =>
String(hash(relay + key + device.get()))
_getPushUrl = async (url: string) => {
for (const candidate of [url, PUSH_BRIDGE]) {
const relay = await loadRelay(candidate)
if (relay?.supported_nips?.map(String)?.includes("9a")) {
return candidate
}
}
}
_syncRelay = async (relay: string, key: string, filters: Filter[], ignore: Filter[] = []) => {
const {subscription} = notificationState.get()
if (!subscription) {
console.warn(`Failed to subscribe ${relay} to notifications: no subscription`)
return
}
const url = await this._getPushUrl(relay)
if (!url) {
console.warn(`Failed to subscribe ${relay} to notifications: unsupported`)
return
}
const identifier = this._getSubscriptionIdentifier(relay, key)
const thunk = publishThunk({
relays: [url],
event: makeEvent(30390, {
tags: [
["d", identifier],
["relay", relay],
["callback", subscription.callback],
...ignore.map(filter => ["ignore", JSON.stringify(filter)]),
...filters.map(filter => ["filter", JSON.stringify(filter)]),
],
}),
})
const error = await waitForThunkError(thunk)
if (error) {
console.warn(`Failed to subscribe ${relay} to ${key} notifications:`, error)
}
}
_unsyncRelay = async (relay: string, key: string) => {
const url = await this._getPushUrl(relay)
if (!url) {
console.warn(`Failed to unsubscribe ${relay} from notifications: unsupported`)
return
}
const relays = [url]
const identifier = this._getSubscriptionIdentifier(relay, key)
const address = new Address(30390, pubkey.get()!, identifier).toString()
const event = makeEvent(DELETE, {tags: [["a", address]]})
const error = await waitForThunkError(publishThunk({relays, event}))
if (error) {
console.warn(`Failed to unsubscribe ${relay} from notifications:`, error)
}
}
async _syncSpaceSubscription(signal: AbortSignal) {
signal.addEventListener(
"abort",
merged([userSpaceUrls, notificationSettings, userSettingsValues]).subscribe(
throttle(3000, ([$userSpaceUrls, {spaces, mentions}, {alerts}]) => {
const baseFilters = [{kinds: MESSAGE_KINDS}, makeCommentFilter(CONTENT_KINDS)]
for (const url of $userSpaceUrls) {
const {notify = true, exceptions = []} = alerts.find(spec({url})) || {}
const filters: Filter[] = []
const ignore: Filter[] = []
// Build filters based on spaces setting
if (spaces) {
if (notify) {
// notify=true: exceptions are opt-out (exclude those rooms)
if (exceptions.length > 0) {
ignore.push({"#h": exceptions})
}
// Include all other content
filters.push(...baseFilters)
} else {
// notify=false: exceptions are opt-in (only include those rooms)
if (exceptions.length > 0) {
filters.push(...baseFilters.map(f => ({...f, "#h": exceptions})))
}
}
}
// Build filters for mentions - always notify for p-tagged content
if (mentions) {
filters.push(...baseFilters.map(f => ({...f, "#p": [pubkey.get()!]})))
}
// Sync or unsync based on whether we have filters
if (filters.length > 0) {
this._syncRelay(url, "spaces", filters, ignore)
} else {
this._unsyncRelay(url, "spaces")
}
}
}),
),
)
}
async _syncMessageSubscription(signal: AbortSignal) {
signal.addEventListener(
"abort",
merged([userMessagingRelayList, notificationSettings]).subscribe(
throttle(3000, ([$userMessagingRelayList, {messages}]) => {
for (const url of getRelaysFromList($userMessagingRelayList)) {
if (messages) {
this._syncRelay(url, "messages", [{kinds: DM_KINDS, "#p": [pubkey.get()!]}])
} else {
this._unsyncRelay(url, "messages")
}
}
}),
),
)
}
async enable() {
if (!this._controller) {
this._controller = new AbortController()
PushNotifications.addListener(
"pushNotificationActionPerformed",
async (action: ActionPerformed) => {
const {relay, id} = action.notification.data
const [event] = await load({
relays: [relay, LOCAL_RELAY_URL],
filters: getIdFilters([id]),
})
if (event) {
goto(await getEventPath(event, [relay]))
} else {
goto(makeSpacePath(relay))
}
},
)
this._controller.signal.addEventListener("abort", () => {
PushNotifications.removeAllListeners()
})
try {
await this._syncServer(this._controller.signal)
await this._syncSpaceSubscription(this._controller.signal)
await this._syncMessageSubscription(this._controller.signal)
} catch (e) {
console.error(e)
}
}
}
async disable() {
this._controller?.abort()
this._controller = undefined
const {subscription} = notificationState.get()
if (subscription) {
const res = await fetch(buildUrl(PUSH_SERVER, "subscription", subscription.key), {
method: "delete",
})
if (!res.ok) {
console.warn("Failed to delete push subscription")
}
}
notificationState.set({})
await Promise.all(get(userSpaceUrls).map(url => this._unsyncRelay(url, "spaces")))
await Promise.all(
getRelaysFromList(get(userMessagingRelayList)).map(url => this._unsyncRelay(url, "messages")),
)
}
}
class WebNotifications implements IPushAdapter {
_unsubscriber = maybe<Unsubscriber>()
async request(prompt = true) {
if (prompt && Notification?.permission === "default") {
await Notification.requestPermission()
}
return Notification?.permission || "denied"
}
_notify(event: TrustedEvent, title: string, body: string) {
console.log("notify:", event)
const notification = new Notification(title, {
body,
tag: event.id,
icon: "/icon.png",
badge: "/icon.png",
})
notification.onclick = () => {
window.focus()
goToEvent(event)
notification.close()
}
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
notification.close()
document.removeEventListener("visibilitychange", onVisibilityChange)
}
}
document.addEventListener("visibilitychange", onVisibilityChange)
}
async enable() {
if (!this._unsubscriber) {
this._unsubscriber = onNotification(event => {
const {push, messages, mentions, spaces} = notificationSettings.get()
if (push && document.hidden && Notification?.permission === "granted") {
if (messages && matchFilter({kinds: DM_KINDS}, event)) {
this._notify(event, "New direct message", "Someone sent you a direct message.")
} else if (
mentions &&
event.pubkey !== pubkey.get() &&
getPubkeyTagValues(event.tags).includes(pubkey.get()!)
) {
this._notify(event, "Someone mentioned you", "Someone tagged you in a message.")
} else if (spaces) {
this._notify(event, "New activity", "Someone posted a new message.")
}
}
})
}
}
async disable() {
this._unsubscriber?.()
this._unsubscriber = undefined
}
}
export class Push {
static _adapter: IPushAdapter | undefined
static _getAdapter() {
if (!Push._adapter) {
if (Capacitor.isNativePlatform()) {
Push._adapter = new CapacitorNotifications()
} else {
Push._adapter = new WebNotifications()
}
}
return Push._adapter
}
static request() {
return Push._getAdapter().request()
}
static disable() {
return Push._getAdapter().disable()
}
static enable() {
return Push._getAdapter().enable()
}
static sync() {
return notificationSettings.subscribe(({push}) => {
if (push) {
Push.enable()
} else {
Push.disable()
}
})
}
}
+92
View File
@@ -0,0 +1,92 @@
import {throttle} from "throttle-debounce"
import {App} from "@capacitor/app"
import {registerPlugin} from "@capacitor/core"
import {session} from "@welshman/app"
import type {Session} from "@welshman/app"
import {maybe, now} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {pushState} from "@app/core/state"
import type {IPushAdapter} from "@app/util/push/adapters/common"
import {requestPermissions, syncRelaySubscriptions} from "@app/util/push/adapters/common"
type AndroidFallbackSubscription = {
relay: string
key: string
filters: Array<Filter>
ignore: Array<Filter>
}
type AndroidPushFallbackState = {
session?: Session
activeSince?: number
subscriptions?: Array<AndroidFallbackSubscription>
}
type AndroidPushFallbackPlugin = {
syncState: (args: {state: AndroidPushFallbackState}) => Promise<void>
}
const AndroidPushFallback = registerPlugin<AndroidPushFallbackPlugin>("AndroidPushFallback")
export class AndroidFallbackNotifications implements IPushAdapter {
_controller = maybe<AbortController>()
_subscriptions = new Map<string, AndroidFallbackSubscription>()
_activeSince = now()
async request() {
return requestPermissions()
}
async enable() {
if (!this._controller) {
this._controller = new AbortController()
const doSync = throttle(1000, () => {
AndroidPushFallback.syncState({
state: {
session: session.get(),
activeSince: this._activeSince,
subscriptions: Array.from(this._subscriptions.values()),
},
})
})
let appStateListener: Awaited<ReturnType<typeof App.addListener>> | undefined
App.addListener("appStateChange", ({isActive}) => {
if (!isActive) {
this._activeSince = now()
doSync()
}
}).then(handle => {
appStateListener = handle
})
this._controller.signal.addEventListener("abort", () => {
appStateListener?.remove()
})
syncRelaySubscriptions(this._controller.signal, async (relay, key, filters, ignore) => {
if (filters.length > 0) {
this._subscriptions.set(`${relay}:${key}`, {relay, key, filters, ignore})
} else {
this._subscriptions.delete(`${relay}:${key}`)
}
doSync()
})
pushState.set({useFallback: true})
}
}
async disable() {
this._controller?.abort()
this._controller = undefined
this._subscriptions.clear()
await AndroidPushFallback.syncState({state: {}})
pushState.set({})
}
}

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