Compare commits

...

136 Commits

Author SHA1 Message Date
Jon Staab 448854b3cd Update pomade 2026-06-12 15:00:14 -07:00
Jon Staab 80370b51cd Add welshman skill 2026-06-12 15:00:14 -07:00
userAdityaa 2e8304e851 fix: replace zap slider with common amount pills (#296)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-06-10 20:00:59 +00:00
Jon Staab 7bfbb17479 Join default spaces on signup 2026-06-09 17:19:44 -07:00
Jon Staab 397179d550 Don't show recent screen when nip 29 is not enabled 2026-06-09 16:58:17 -07:00
Jon Staab 926b31de78 Split app/core up into domain-oriented files 2026-06-08 17:07:39 -07:00
Jon Staab ea6b63de53 Rename app utils 2026-06-08 17:07:17 -07:00
Jon Staab 879ba5c37f Make join rejections due to an empty claim more forgiving 2026-06-08 17:05:21 -07:00
userAdityaa f633612207 feat: show voice room participants before joining (#294)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-06-09 00:04:54 +00:00
userAdityaa 8ba76a60e7 feat: prompt SpaceJoin when opening unjoined space via direct link (#291)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-06-08 21:15:36 +00:00
Jon Staab b6b8145901 Fix some bottom padding stuff 2026-06-04 14:31:07 -07:00
Jon Staab cdc9f927b5 Don't return 404, just return the index (some routes look like asset files since tlds look like file extensions) 2026-06-04 14:28:10 -07:00
userAdityaa 4d57e4e6ed feat: show per-relay publish status on outgoing messages (#290)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-06-04 21:10:00 +00:00
Jon Staab 1b8d6e50e2 Fix link_deps, normalize relays less aggressively 2026-06-03 11:08:08 -07:00
Jon Staab e3e13563d5 bump pnpm version 2026-06-02 15:27:15 -07:00
userAdityaa ee3da3893c fix: resync voice state after LiveKit reconnect (#289)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-06-02 16:00:11 +00:00
Jon Staab 91145c38fb Scaffold playwright 2026-06-01 17:00:47 -07:00
Jon Staab 1dd0270f4f Bump pomade 2026-05-29 16:31:29 -07:00
userAdityaa 77256462c5 feat: sync checked read state to Dufflepud for cross-device badges (#288)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-29 21:15:56 +00:00
Jon Staab ae071fefaa Fail more gracefully when svelte sneezes 2026-05-29 08:30:42 -07:00
userAdityaa 152d35f92a Fix deleted rooms persisting in navigation (#285)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-29 15:20:21 +00:00
userAdityaa 8dd278f47c fix: turn on notification defaults and prompt on first DM visit (#284)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-29 15:13:10 +00:00
Jon Staab 045d6983dc Fix some voice room bugs 2026-05-28 12:17:17 -07:00
Jon Staab 2f8861be62 Bump welshman, update pnpm config 2026-05-28 12:14:40 -07:00
userAdityaa 6dbe9c0ebb chore: show space relay icon in mobile topbar headers (#283)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-25 19:18:51 +00:00
Jon Staab 45df132dc6 Remove tauri, bump deps 2026-05-25 10:41:34 -07:00
Jon Staab c42a285f0b Tweak wording 2026-05-25 09:04:14 -07:00
userAdityaa 1e3211ae74 chore: uppdate signup modal copy to focus on joining Flotilla (#282)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-25 16:00:28 +00:00
npub15skvhry ec507b05d6 minimize container size and caching 2026-05-22 15:36:28 -07:00
Jon Staab 339bb1afac Tweak page bar style 2026-05-21 15:17:28 -07:00
userAdityaa c441012e02 fix(video): restyle spotlight pin button on video tiles (#281)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-21 20:59:25 +00:00
userAdityaa 0d61278c56 chore: show call participant mute and camera-off state (#279)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-21 20:58:53 +00:00
userAdityaa ffd06ab561 fix: video blink when toggling mic mute in calls (#277)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-20 16:44:38 +00:00
userAdityaa eb8dd330b6 fix(video): use single-column tile grid when chat is open (#278)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-20 16:42:16 +00:00
userAdityaa 6267e52bdf feat: show unread chat badges during video-only voice calls (#276)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-19 15:36:22 +00:00
Jon Staab ab21008f34 Add etag for immutable assets
Docker / build-and-push-image (push) Successful in 11m19s
2026-05-12 08:46:50 -07:00
Jon Staab 0998639d59 Push to gitea package registry
Docker / build-and-push-image (push) Successful in 11m31s
2026-05-11 13:49:37 -07:00
Jon Staab eccde07d06 Fix dockerfile again
Docker / build-and-push-image (push) Successful in 12m2s
2026-05-11 13:20:47 -07:00
Jon Staab 770cdc5f13 Reduce extra space on android when keyboard is open 2026-05-11 12:44:44 -07:00
Jon Staab 6bafb62414 Fix docker build
Docker / build-and-push-image (push) Successful in 12m9s
2026-05-11 12:28:42 -07:00
Jon Staab 6ce0fbbbe6 Make recommended ios changes
Docker / build-and-push-image (push) Failing after 56s
2026-05-11 10:02:14 -07:00
Jon Staab 8fe42e6f22 Update version 2026-05-11 09:54:22 -07:00
Jon Staab 47a6209730 Bump welshman 2026-05-11 09:20:17 -07:00
Jon Staab 24d3f867f8 Improve space search 2026-05-07 12:53:49 -07:00
Jon Staab 9db60374e4 Make sure to always show date on calendar events when embedded in chat 2026-05-06 17:24:29 -07:00
Jon Staab 8ef4b21dab Allow sharing something to chat without a message 2026-05-06 17:17:55 -07:00
Jon Staab 8f56812dd1 Fix undefined chat draft key 2026-05-06 16:56:31 -07:00
Jon Staab 3833cb093d Attempt to fix wrapping on relay summary on ios 2026-05-06 16:39:01 -07:00
Jon Staab 94db65b85e Bump welshman, add email rendering support 2026-05-06 13:47:30 -07:00
Jon Staab 6f731e48d2 Hide keyboard on app resume 2026-05-06 12:48:15 -07:00
Jon Staab 99fe0e543c Avoid capturing stale cleanup function in chat 2026-05-06 09:31:28 -07:00
Jon Staab c6b0799b2a Remove cv class from chat since new messages weren't rendering in Safari 2026-05-06 09:25:29 -07:00
Jon Staab 861f2286db Remove unnecessary tooltip, fix chat padding on mobile 2026-05-06 09:01:01 -07:00
Jon Staab 9af3e3b2e9 Fix relay badge overflow 2026-05-05 09:11:12 -07:00
Jon Staab 341c1b45b2 Stop publishing join requests every time we open a space 2026-05-05 09:09:28 -07:00
Jon Staab 89f5d8cdf5 Fix pasting into event summary 2026-05-04 16:15:21 -07:00
Jon Staab ca3270437d Highlight active space 2026-05-04 16:11:30 -07:00
Khushvendra bbbc6f7363 fix(metadata): add case-insensitive HTML title fallback parsing for invite links (#248)
Co-authored-by: 1amKhush <khushvendras99@gmail.com>
Co-committed-by: 1amKhush <khushvendras99@gmail.com>
2026-05-04 21:02:56 +00:00
Jon Staab 8a0abacf6f Fix padding on page content on small screens 2026-05-01 13:24:42 -07:00
Jon Staab 976ccdabd4 fix: include MESSAGE kind and local matches in space search 2026-04-28 14:44:16 -07:00
Jon Staab 99b26680b6 feat: rework hosting page to 2+1 architecture (#231) 2026-04-28 14:42:09 -07:00
Jon Staab c5be477855 fix: bundle emoji-picker data locally for Capacitor Android
The emoji grid wasn't rendering on Android because emoji-picker-element
defaults to fetching its data.json from jsdelivr, and CapacitorHttp's
patched fetch breaks the library's ETag-based revalidation flow. Bundle
emoji-picker-element-data via Vite's ?url import so the JSON ships as a
same-origin asset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:15:12 -07:00
deveshanim3 32c1501e9c feat: add progress bar to signup flow (#234)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-23 15:35:59 +00:00
deveshanim3 463837e7d4 fix: restore consistent input field sizing and alignment in FieldInline (#235) (#238)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-20 16:26:44 +00:00
Shreyas2004wagh d74f142cdd Fix relay auth privacy toggle (#240)
Co-authored-by: Shreyas2004wagh <shreyaswagh2004@gmail.com>
Co-committed-by: Shreyas2004wagh <shreyaswagh2004@gmail.com>
2026-04-20 16:25:52 +00:00
Jon Staab 53954aae89 classnames tweak 2026-04-17 16:06:00 -07:00
userAdityaa 24aa62a503 chore: carify Pomade login errors with actionable invalid vs network messaging (#233)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 23:00:03 +00:00
Jon Staab 2618bb9c63 Fix centered layout 2026-04-17 14:58:59 -07:00
Prat_09 32a31045ef fix: Improve toggle switch placement in settings screen (#208) (#232)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-17 21:58:52 +00:00
deveshanim3 56edad77a8 fix: added logic for password requirements on signup (#230)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-17 19:43:27 +00:00
priyanshu_bharti fdb604e350 Use type=email for signup/login email inputs (#225) (#228)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-17 18:55:04 +00:00
deveshanim3 3c66dfd83c fix/wrong-message-offline (#222)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-17 18:24:55 +00:00
userAdityaa 81633b0a1e fix: vertical alignment of emoji and overflow buttons in shared event action row (#219)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 15:22:40 +00:00
Khushvendra 4a967de184 fix(chat): suppress programmatic scroll while user is scrolling (#132) (#216)
Co-authored-by: 1amKhush <khushvendras99@gmail.com>
Co-committed-by: 1amKhush <khushvendras99@gmail.com>
2026-04-16 23:20:17 +00:00
deveshanim3 59961cbdb5 fix: supported nip overflow in SpaceRelayStatus.svelte (#215)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-16 21:36:14 +00:00
Jon Staab 95d9d8bf23 Bump version 2026-04-16 14:10:50 -07:00
Jon Staab 2fd9741a2b Fix safe area inset for chat fab
Docker / build-and-push-image (push) Successful in 16m42s
2026-04-16 14:08:25 -07:00
Jon Staab fe9c325580 Update universal links 2026-04-16 13:50:13 -07:00
Jon Staab 61e93d4071 Update changelog, bump version
Docker / build-and-push-image (push) Successful in 19m16s
2026-04-16 11:40:24 -07:00
Jon Staab 1e4a4e43dc remove dead virtualization code 2026-04-16 11:39:11 -07:00
Jon Staab e1a7b051bd Use welshman kinds 2026-04-16 11:34:59 -07:00
sakshamjain 7a7af58f5c feat: add native share support for space invites 2026-04-16 10:16:12 -07:00
Jon Staab 016ae86d50 Stop sending duplicate requests per room 2026-04-16 10:03:01 -07:00
Jon Staab 2bff060a5e Add thumbnail url 2026-04-16 10:03:01 -07:00
userAdityaa 68231504d0 fix: modal close button stacking above emoji picker on mobile (#211)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 15:38:25 +00:00
deveshanim3 0658a8ee44 bug: fixed calender modal stacking issue (#209)
Co-authored-by: deveshanim3 <deveshsingh6986@gmail.com>
Co-committed-by: deveshanim3 <deveshsingh6986@gmail.com>
2026-04-16 14:55:37 +00:00
priyanshu_bharti 43fb3d35e6 Fix #202 slow-network invite timeout handling (#207)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-15 22:01:00 +00:00
Khushvendra 4cc1cc95ca Fix voice call cold-start timeout and preserve custom timeout message (#174) (#203)
Co-authored-by: 1amKhush <khushvendras99@gmail.com>
Co-committed-by: 1amKhush <khushvendras99@gmail.com>
2026-04-15 20:37:03 +00:00
Prat_09 964ef441ec Update relay description (#195) (#197)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-14 15:09:46 +00:00
priyanshu_bharti 796f37d320 Make space reordering discoverable with smoother drag animation (#171)
Co-authored-by: Priyanshu Bharti <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshu Bharti <bhartipriyanshustm@gmail.com>
2026-04-13 22:38:02 +00:00
Nayan Patidar b46fd94578 Use relay-provided member lists as source of truth (#191)
Co-authored-by: Nayan Patidar <nayan9617@noreply.coracle.social>
Co-committed-by: Nayan Patidar <nayan9617@noreply.coracle.social>
2026-04-13 21:12:49 +00:00
Jon Staab bdc8e75640 Fix search input width 2026-04-13 12:08:11 -07:00
Jon Staab ef08821796 remove VirtualItem 2026-04-13 10:35:26 -07:00
Jon Staab 9f386f6968 remove redundant room syncing logic 2026-04-11 10:20:50 -07:00
Khushvendra ec0b6a99e2 add room mentions and clickable room/relay refs 2026-04-11 10:13:23 -07:00
Khushvendra f6d9e52c6e fix: support native clipboard image paste on mobile (#181)
Co-authored-by: Khushvendra <khushvendras99@gmail.com>
Co-committed-by: Khushvendra <khushvendras99@gmail.com>
2026-04-11 16:38:06 +00:00
Jon Staab 90f86b833d Handle quotes in RoomItem. Fixes #188 2026-04-10 15:27:36 -07:00
userAdityaa 29bb33c26c publish kind 9 quote after room content creation for cross-client interoperability 2026-04-10 14:20:24 -07:00
Jon Staab c740bd21d4 Fix space page layout on Android by adding visible prop to SecondaryNav 2026-04-10 13:26:14 -07:00
Jon Staab 1d92709c76 perf: task-fix-list-virtualization changes 2026-04-10 12:44:01 -07:00
Jon Staab a42e1df1a7 Fix feed pagination logic 2026-04-10 12:40:28 -07:00
Jon Staab e33beee17d perf: task-fix-raf-derived-to-effect changes 2026-04-10 12:30:25 -07:00
Jon Staab b10ea04cb3 Fix Android push fallback: show all notifications, retry on failure 2026-04-10 12:23:32 -07:00
priyanshu_bharti e8c94177ca Support Aegis URL scheme for NIP-46 login (#161)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-10 19:04:34 +00:00
Jon Staab f1f2083c88 Remove unnecessary snapshots, format 2026-04-10 11:09:26 -07:00
Jon Staab f42889c3c2 Improve performance #182:
increase profile timer and chat search throttle delays
reduce GC pressure in derived stores
use requestIdleCallback for non-critical storage writes
batch repository update processing in feeds
2026-04-10 10:39:38 -07:00
Jon Staab a75e1f96eb Add .claude to gitignore 2026-04-10 10:14:01 -07:00
priyanshu_bharti 85c5293082 Raise message size limit in chat (#186)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-10 16:37:23 +00:00
Jon Staab 37efa6a62c Bump pomade 2026-04-10 09:24:22 -07:00
userAdityaa 1d5f91fb6c fix: realtime updates for room members and admins (#178)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-09 21:05:30 +00:00
userAdityaa ef18655776 make close button / backdrop work on direct invite link page (#177)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-09 20:03:08 +00:00
sakshamjain b786e858d9 correct inverted arrow icon in advanced section toggle (#180)
Co-authored-by: Saksham Jain <reach2saksham2004@gmail.com>
Co-committed-by: Saksham Jain <reach2saksham2004@gmail.com>
2026-04-09 19:57:15 +00:00
mplorentz f4ebc4e99e Video in calls (#135)
#135

This PR adds basic video functionality to our voice rooms. Again I followed the Discord UX for inspiration, so all video calls start as voice-only calls that gracefully upgrade (and downgrade) when someone turns on a video or starts screen sharing.

When a video feed is detected the Room page will change to display a grid of feeds. The grid logic is very basic, that's definitely an area to improve in the future. You can open the chat part of the room with a new button on the VoiceWidget - on the desktop layout this creates a split view with video on the left and chat on the right, but on mobile it switches to chat fullscreen. I also added a little pin icon you can use to focus on a single video feed (useful for screen sharing). There is a lot of tailwind I don't understand here, but it seems to work well enough.

I moved voice.ts into a new `call` folder and moved some of its stores into `call/stores.ts` which allowed me to keep most of the video logic in `call/video.ts`. It's not a perfect encapsulation as voice.ts does subscribe to some of the hooks for the livekit calls and passes some of the signals onto `video.ts`. This could probably be broken up better but for this PR I'd rather not focus on making it perfect if that's ok. Partly for the sake of time but also because I envision another PR that renames/reorganizes things and I think a larger UX evaluation is necessary and should include real user feedback. I'm not confident tha""t the Voice Room concept as a whole will stick going forward. Maybe all rooms in a livekit enabled server should be able to host a call (like a slack huddle), maybe users want to be able to schedule calls as events, or even have them start with an ad-hoc set of participants completely outside of a NIP-29 group, etc.

Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: #135
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-04-08 17:10:20 +00:00
Jon Staab 65ca8a7fd8 Remove follow graph building 2026-04-08 09:46:56 -07:00
nayan9617 7f1e98dcb2 Fix fallback pull race after abort (#167)
Co-authored-by: nayan9617 <nayanp4925@gmail.com>
Co-committed-by: nayan9617 <nayanp4925@gmail.com>
2026-04-08 16:43:04 +00:00
priyanshu_bharti 4c19ee823b 73-video-thumbnails (#142)
This PR implements video thumbnails for `.mov`, `.webm`, and `.mp4` files in the `ContentLinkBlock` component.

Changes:
- Added the `poster` attribute to the `<video>` tag.
- Set the poster source to `{url}#t=1` to capture a clear preview frame at the 1-second mark.
- Verified locally that thumbnails are now correctly displayed instead of a black/empty box.

Closes #73

Reviewed-on: #142
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-08 16:07:11 +00:00
Jon Staab 8e2dd8b278 Upgrade daisyui/tailwind 2026-04-07 15:31:35 -07:00
Jon Staab 8d35b3aad2 Chat tweaks 2026-04-07 10:40:45 -07:00
Prat_09 613cad31c0 add start chat FAB (#152)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-07 17:02:40 +00:00
Jon Staab 3779a90f26 Tweak to chat item menu buttons 2026-04-07 09:51:25 -07:00
theAnuragMishra 7470f28f31 fix spacing around messages (#159)
Co-authored-by: theAnuragMishra <theanuragmishra@noreply.coracle.social>
Co-committed-by: theAnuragMishra <theanuragmishra@noreply.coracle.social>
2026-04-07 16:50:53 +00:00
hodlbod 17fb4e780b Clean up drafts implementation (#164) 2026-04-07 13:06:29 +00:00
userAdityaa 30c2a6ef79 persist drafts in memory (#155)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-07 12:06:29 +00:00
Jon Staab 0547e9513f Small css tweak 2026-04-06 09:21:59 -07:00
nayan9617 70e5172f1b fix/tooltip-clipping (#156)
Co-authored-by: nayan9617 <nayanp4925@gmail.com>
Co-committed-by: nayan9617 <nayanp4925@gmail.com>
2026-04-06 16:14:08 +00:00
Jon Staab 61c568a112 Formatting 2026-04-06 09:09:01 -07:00
Jon Staab ae2ba6f44d Tweak toast close button 2026-04-06 09:08:26 -07:00
Jon Staab f84006fbe4 Tweak button on profile page 2026-04-06 09:08:26 -07:00
priyanshu_bharti fed34a2747 show space name on hover in primary nav (#129) (#136)
Co-authored-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
2026-04-06 16:08:02 +00:00
userAdityaa 80df16f97b feat: redesign toast notifications for UX (#148)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-04 16:45:49 +00:00
junaiddshaukat 18cb245599 Remove room/space leave indications (#149)
Co-authored-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
Co-committed-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
2026-04-04 16:28:11 +00:00
Jon Staab fd6cc84be6 Simplify chat compose layout 2026-04-04 09:02:52 -07:00
Jon Staab 9311cab3b2 Move away from fixed positioned page elements because they act squirrely on android 2026-04-03 17:16:47 -07:00
userAdityaa fceccf47be fix(ui): hide report badge for non-admin users (#147)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-03 23:54:20 +00:00
Jon Staab fe20fbfd28 Add polls 2026-04-03 10:56:00 -07:00
junaiddshaukat 4f3a2a1660 Add space search to recent activity page (#59) (#119)
Co-authored-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
Co-committed-by: junaiddshaukat <junaiddshaukat@noreply.coracle.social>
2026-04-03 16:58:35 +00:00
408 changed files with 17043 additions and 13245 deletions
+634
View File
@@ -0,0 +1,634 @@
---
name: welshman-app
description: "Use this skill when working with @welshman/app: high-level Svelte stores for nostr apps, session management, WoT (web of trust), making requests, publishing events, user data, or relay selection at the app layer."
---
# welshman/app — Application Layer Stores
`@welshman/app` is the top-level application framework in the welshman stack. It wires together `@welshman/net` (subscriptions/publishing), `@welshman/store` (reactive collections), `@welshman/router` (relay selection), and `@welshman/signer` (key management) into ready-to-use Svelte stores and high-level utilities. It powers production apps like Coracle and Flotilla.
## Installation
```bash
npm install @welshman/app
# or
pnpm add @welshman/app
yarn add @welshman/app
```
## Key Exports
### Core Singletons
| Export | Description |
|---|---|
| `repository` | Singleton `Repository` from `@welshman/net`; non-DVM, non-ephemeral events received from the pool are stored here. WRAP (NIP-59) events are handled separately via `unwrapAndStore` and require `shouldUnwrap` to be set to `true` to process. |
| `tracker` | Singleton `Tracker`; maps event IDs to the relays they were seen on |
### Session Management
| Export | Description |
|---|---|
| `pubkey` | `Writable<string \| undefined>` — active session's pubkey |
| `session` | `Readable<Session \| undefined>` — derived from `pubkey` + `sessions` |
| `sessions` | `Writable<Record<string, Session>>` — all loaded sessions |
| `signer` | `Readable<ISigner \| undefined>` — signer for the active session |
| `signerLog` | `WritableWithGetter<SignerLogEntry[]>` — writable store that the session layer appends signer-operation entries to (useful for UI feedback during remote signing) |
| `SessionMethod` | Enum: `Nip01`, `Nip07`, `Nip46`, `Nip55`, `Pomade`, `Pubkey`, `Anonymous` |
**Login functions** (all call `addSession` internally):
```typescript
loginWithNip01(secret: string): void
loginWithNip07(pubkey: string): void
loginWithNip46(pubkey, clientSecret, signerPubkey, relays): void
loginWithNip55(pubkey: string, signerPackageName: string): void
loginWithPomade(pubkey, email, clientOptions): void
loginWithPubkey(pubkey: string): void // read-only
```
**Session utilities**:
```typescript
addSession(session: Session): void // add and activate
dropSession(pubkey: string): void // remove and clean up signer
getSession(pubkey: string): Session | undefined
updateSession(pubkey, fn): void
clearSessions(): void
nip46Perms: string // default NIP-46 permission string
```
**Gift wrap (NIP-59)**:
```typescript
shouldUnwrap: Writable<boolean> // must be true to process incoming wraps
wrapManager: WrapManager // tracks wrap↔rumor mappings
unwrapAndStore(wrap: SignedEvent): Promise<void>
```
### Publishing — Thunks
| Export | Description |
|---|---|
| `publishThunk(options: ThunkOptions): Thunk` | Create, enqueue, and optimistically publish an event |
| `Thunk` | Class representing a single in-flight publish |
| `MergedThunk` | Aggregates multiple `Thunk`s (used by `sendWrapped`) |
| `thunks` | `Writable<Thunk[]>` — all active thunks |
| `abortThunk(thunk)` | Abort all constituent thunks |
| `retryThunk(thunk)` | Re-publish with original options |
| `mergeThunks(thunks[])` | Combine into a `MergedThunk` |
**Thunk status helpers**:
```typescript
thunkIsComplete(thunk): boolean
getThunkError(thunk): string | undefined
getThunkUrlsWithStatus(statuses, thunk): string[]
getCompleteThunkUrls(thunk): string[]
getFailedThunkUrls(thunk): string[]
waitForThunkError(thunk): Promise<string>
waitForThunkCompletion(thunk): Promise<void>
```
`ThunkOptions`:
```typescript
type ThunkOptions = {
event: EventTemplate // unsigned — will be signed lazily
relays: string[]
recipient?: string // if set, event is NIP-59 gift-wrapped
delay?: number // ms to wait before sending (abort window)
pow?: number // proof-of-work difficulty target
timeout?: number // ms per relay before marking as timed out
// PublishOptions callbacks: onSuccess, onFailure, onPending, onTimeout, onAborted, onComplete
}
```
### Commands (Higher-level Thunk Factories)
Most return a `Thunk` (or `Promise<Thunk>`). They automatically load the relevant user list before modifying it. Exception: `manageRelay` returns `Promise<Response>` (an HTTP response from the NIP-86 management endpoint), not a Thunk.
| Export | Description |
|---|---|
| `setProfile(profile: Profile)` | Publish NIP-01 profile metadata |
| `follow(tag: string[])` | Add to NIP-02 follow list |
| `unfollow(value: string)` | Remove from follow list |
| `mutePublicly(tag)` | Add to public mute list |
| `mutePrivately(tag)` | Add to private (encrypted) mute list |
| `unmute(value)` | Remove from mute list |
| `setMutes({publicTags?, privateTags?})` | Replace entire mute list |
| `pin(tag)` / `unpin(value)` | Manage pin list |
| `addRelay(url, mode)` / `removeRelay(url, mode)` | NIP-65 relay list management |
| `setRelays(tags)` / `setReadRelays(urls)` / `setWriteRelays(urls)` | Bulk relay list updates |
| `addMessagingRelay(url)` / `removeMessagingRelay(url)` | NIP-17 messaging relay list |
| `addBlockedRelay(url)` / `removeBlockedRelay(url)` | Blocked relay list |
| `addSearchRelay(url)` / `removeSearchRelay(url)` | Search relay list |
| `sendWrapped({event, recipients, ...options})` | NIP-59 gift-wrap to multiple recipients |
| `manageRelay(url, request)` | NIP-86 relay management |
| `createRoom` / `editRoom` / `deleteRoom` / `joinRoom` / `leaveRoom` | NIP-29 group room management |
### Profiles
```typescript
profilesByPubkey: Readable<Map<string, Profile>>
profiles: Readable<Profile[]>
getProfile(pubkey: string): Profile | undefined
loadProfile(pubkey, relayHints?): Promise<void>
forceLoadProfile(pubkey, relayHints?): Promise<void>
deriveProfile(pubkey: string): Readable<Profile | undefined> // auto-loads
deriveProfileDisplay(pubkey: string): Readable<string> // display name with fallback
displayProfileByPubkey(pubkey: string): string // synchronous
```
### Follow / Mute / Pin Lists
Each list type follows the same pattern (`follow` shown, `mute` and `pin` are identical):
```typescript
followListsByPubkey: Readable<Map<string, List>>
followLists: Readable<List[]>
getFollowList(pubkey): List | undefined
loadFollowList(pubkey, relayHints?): Promise<void>
forceLoadFollowList(pubkey, relayHints?): Promise<void>
deriveFollowList(pubkey): Readable<List | undefined>
```
### Relay Lists
```typescript
// NIP-65 relay lists
relayListsByPubkey / relayLists / getRelayList / loadRelayList / deriveRelayList
// NIP-17 messaging relay lists
messagingRelayListsByPubkey / messagingRelayLists / getMessagingRelayList / loadMessagingRelayList / deriveMessagingRelayList
// Blocked relay lists
blockedRelayListsByPubkey / getBlockedRelayList / loadBlockedRelayList / deriveBlockedRelayList
// Search relay lists (internal only — not exported from @welshman/app)
// Use userSearchRelayList / loadUserSearchRelayList / forceLoadUserSearchRelayList from user.ts instead
```
### Outbox Loading
`makeOutboxLoader` creates a loader function for any event kind. It looks up the target pubkey's relay list (fetching it if needed), then fetches events from their write relays using the outbox model. This is the internal mechanism used by all built-in `loadX` helpers.
```typescript
import {makeOutboxLoader} from '@welshman/app'
// Signature: makeOutboxLoader(kind, filter?, limit?)
// Returns: (pubkey: string, relayHints?: string[]) => Promise<void>
// Results are stored in repository — read via the derived store/getter, not the return value.
// Loader for kind 1 notes (default limit = 1)
const loadNote = makeOutboxLoader(1)
await loadNote('target-pubkey')
// With extra filter constraints
const loadRecentNotes = makeOutboxLoader(1, {since: Math.floor(Date.now() / 1000) - 86400})
await loadRecentNotes('target-pubkey')
// Override the limit via the third positional argument (not inside the filter object)
const loadMany = makeOutboxLoader(1, {}, 20)
await loadMany('target-pubkey')
// With relay hints to seed the lookup
await loadNote('target-pubkey', ['wss://relay.damus.io/'])
```
**Relay URL helpers** (exported from index):
```typescript
getPubkeyRelays(pubkey: string, mode?: RelayMode): string[]
derivePubkeyRelays(pubkey: string, mode?: RelayMode): Readable<string[]>
```
### User Data Stores (current session)
These automatically derive from the active `pubkey` and trigger a load on first access:
```typescript
userProfile: Readable<Profile | undefined>
userFollowList: Readable<List | undefined>
userMuteList: Readable<List | undefined>
userPinList: Readable<List | undefined>
userRelayList: Readable<List | undefined>
userMessagingRelayList: Readable<List | undefined>
userSearchRelayList: Readable<List | undefined>
userBlockedRelayList: Readable<List | undefined>
userBlossomServerList: Readable<List | undefined>
```
Corresponding loaders (operate on the current session's pubkey):
```typescript
loadUserProfile(relays?)
forceLoadUserProfile(relays?)
loadUserFollowList / forceLoadUserFollowList
loadUserMuteList / forceLoadUserMuteList
loadUserPinList / forceLoadUserPinList
loadUserRelayList / forceLoadUserRelayList
loadUserMessagingRelayList / forceLoadUserMessagingRelayList
// ...etc for each list type
```
### Router
```typescript
import {Router, routerContext, addMaximalFallbacks, addMinimalFallbacks} from '@welshman/router'
// The index.ts wires up routerContext automatically:
// routerContext.getUserPubkey, getPubkeyRelays, getRelayQuality, getDefaultRelays, etc.
Router.get() // singleton with app-wired context
Router.get().FromUser() // relays to publish from the current user
Router.get().ForPubkey(pubkey) // relays to read a pubkey's events
Router.get().Event(event) // best relay for a specific event
Router.get().Index() // indexer/bootstrap relays
Router.get().FromRelays(urls) // relay set from explicit URLs
.policy(addMaximalFallbacks) // add fallback relays
.limit(8)
.getUrls() // string[]
.getUrl() // string | undefined (first)
```
`routerContext` settings (configure before using router):
```typescript
import {routerContext} from '@welshman/router' // from @welshman/router, not @welshman/app
routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"]
```
### Tag Utilities
```typescript
tagPubkey(pubkey: string): string[] // ["p", pubkey, relayHint, displayName]
tagEvent(event, url?, mark?): string[][] // e-tag (+ a-tag if replaceable)
tagEventPubkeys(event): string[][] // p-tags for all mentioned pubkeys (excl. self)
tagEventForQuote(event, relay?): string[] // q-tag
tagEventForReply(event, relay?): string[][] // full reply thread tags
tagEventForComment(event, relay?): string[][]// NIP-22 comment tags
tagEventForReaction(event, relay?): string[][]// reaction tags
tagZapSplit(pubkey, split?): string[] // zap tag
```
### Web of Trust (WoT)
```typescript
// Reactive stores
followersByPubkey: Readable<Map<string, Set<string>>>
mutersByPubkey: Readable<Map<string, Set<string>>>
wotGraph: Writable<Map<string, number>> // pubkey → score; rebuilt on follow/mute changes
maxWot: Readable<number>
// Synchronous getters
getFollows(pubkey): string[]
getMutes(pubkey): string[]
getFollowers(pubkey): string[]
getMuters(pubkey): string[]
getNetwork(pubkey): string[] // follows-of-follows (excludes direct follows)
getFollowsWhoFollow(pubkey, target): string[]
getFollowsWhoMute(pubkey, target): string[]
getWotScore(pubkey, target): number // follows-who-follow minus follows-who-mute
// Per-user reactive score
getUserWotScore(tpk: string): number
deriveUserWotScore(tpk: string): Readable<number>
```
### Handles & Zappers
```typescript
handlesByNip05: Writable<Map<string, Handle>>
deriveHandle(nip05: string): Readable<Handle | undefined> // auto-loads
loadHandle(nip05): Promise<void>
zappersByLnurl: Writable<Map<string, Zapper>>
deriveZapper(lnurl: string): Readable<Zapper | undefined> // auto-loads
loadZapper(lnurl): Promise<void>
```
### Feeds
```typescript
import {makeFeedController} from '@welshman/app'
import {makeKindFeed} from '@welshman/feeds'
// makeFeedController wraps FeedController with app-level scope/WoT helpers
const ctrl = makeFeedController({
feed: makeKindFeed(NOTE),
useWindowing: true,
signal: abortController.signal,
onEvent: (e) => { /* handle event */ },
onExhausted: () => { /* no more events */ },
})
ctrl.load(100)
abortController.abort()
```
WoT-scoped feed helpers (passed automatically to `FeedController`):
```typescript
getPubkeysForScope(scope: string): string[] // Scope.Self|Follows|Network|Followers
getPubkeysForWOTRange(min: number, max: number): string[] // fractional of maxWot
```
### Sync (Negentropy)
```typescript
import {pull, push, hasNegentropy} from '@welshman/app'
// pull/push use negentropy if the relay supports it, falling back to plain requests
await pull({relays, filters})
await push({relays, filters})
hasNegentropy(url: string): boolean
```
### Application Context
```typescript
import {appContext} from '@welshman/app'
appContext.dufflepudUrl = 'https://my-dufflepud.example.com'
```
[Dufflepud](https://github.com/coracle-social/dufflepud) is an optional proxy server for NIP-05 lookups, zapper resolution, relay metadata, and link previews. Not required but helps bypass CORS.
---
## Common Patterns
### Login and publish a note
```typescript
import {makeSecret} from '@welshman/util'
import {loginWithNip07, publishThunk, signer} from '@welshman/app'
import {Router} from '@welshman/router'
import {NOTE, makeEvent} from '@welshman/util'
import {Nip07Signer} from '@welshman/signer'
// NIP-07 login
const nip07 = new Nip07Signer()
const pubkey = await nip07.getPubkey()
loginWithNip07(pubkey)
// Publish with optimistic local update and 3s undo window
const thunk = publishThunk({
event: makeEvent(NOTE, {content: 'Hello Nostr!'}),
relays: Router.get().FromUser().getUrls(),
delay: 3000,
})
// Subscribe to per-relay status
thunk.subscribe($thunk => {
for (const [url, result] of Object.entries($thunk.results)) {
console.log(url, result.status, result.detail)
}
})
// Soft-undo within delay window
setTimeout(() => thunk.controller.abort(), 1000)
// Wait for all relays to finish (thunk.complete is a Deferred<void>)
await thunk.complete
```
### Derive a reactive profile
```typescript
import {deriveProfile, deriveProfileDisplay} from '@welshman/app'
const targetPubkey = '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'
// Reactive store — loads the profile in the background on first subscribe
const profile = deriveProfile(targetPubkey)
// Reactive display name with npub fallback
const name = deriveProfileDisplay(targetPubkey)
// In Svelte
// $: displayName = $name
```
### Reply to an event
```typescript
import {publishThunk, tagEventForReply, tagPubkey, signer} from '@welshman/app'
import {Router} from '@welshman/router'
import {NOTE, makeEvent} from '@welshman/util'
import type {TrustedEvent} from '@welshman/util'
async function replyTo(parent: TrustedEvent, content: string) {
const tags = tagEventForReply(parent)
return publishThunk({
event: makeEvent(NOTE, {content, tags}),
relays: Router.get().PublishEvent(parent).getUrls(),
})
}
```
### Send a NIP-59 gift-wrapped DM
```typescript
import {sendWrapped} from '@welshman/app'
import {DIRECT_MESSAGE, makeEvent} from '@welshman/util'
const mergedThunk = await sendWrapped({
event: makeEvent(DIRECT_MESSAGE, {content: 'secret message'}),
recipients: [recipientPubkey],
})
// Monitor combined status
mergedThunk.subscribe($t => {
for (const [url, result] of Object.entries($t.results)) {
console.log(url, result.status)
}
})
```
### Follow/unfollow
```typescript
import {follow, unfollow} from '@welshman/app'
// tag format: ["p", pubkey] or ["p", pubkey, relayHint, petname]
await follow(["p", "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"])
await unfollow("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322")
```
### Web of Trust filtering
```typescript
import {deriveUserWotScore, getWotScore, wotGraph, maxWot} from '@welshman/app'
import {get} from 'svelte/store'
// Filter a list of pubkeys to those with positive WoT score
const $graph = get(wotGraph)
const trusted = pubkeys.filter(pk => ($graph.get(pk) ?? 0) > 0)
// Reactive score for a single user
const score = deriveUserWotScore(somePubkey)
// Normalize by max score (01 range)
const $max = get(maxWot)
const normalized = ($graph.get(somePubkey) ?? 0) / ($max || 1)
```
### Load a feed of notes
```typescript
import {makeFeedController, getPubkeysForScope} from '@welshman/app'
import {makeKindFeed} from '@welshman/feeds'
import {NOTE} from '@welshman/util'
const abort = new AbortController()
const ctrl = makeFeedController({
feed: makeKindFeed(NOTE),
useWindowing: true,
signal: abort.signal,
onEvent: event => console.log(event),
onExhausted: () => console.log('no more events'),
})
ctrl.load(50)
// cleanup
abort.abort()
```
---
## Integration Notes
- `@welshman/app` **re-exports nothing** from `@welshman/net`, `@welshman/router`, etc. Import those directly when you need low-level primitives (`load`, `request`, `publish`, `Router` scenarios beyond `FromUser`).
- The `index.ts` bootstrap code runs on import and automatically wires `routerContext` (pubkey relays, relay quality, default/indexer/search relays) and hooks `Pool` to store incoming events in `repository`. **Import `@welshman/app` early in your app entry point** so this runs before any requests. The canonical side-effect import pattern is:
```typescript
// app entry point — must be first, before any @welshman/net or @welshman/router imports
import "@welshman/app"
// Then optionally override defaults
import {routerContext} from "@welshman/router"
routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"]
```
- `repository` and `tracker` are singletons shared across the whole app. All subscriptions made through `@welshman/net` that pass through the pool will populate `repository` automatically.
- `Router` is imported from `@welshman/router` but `routerContext` is configured by `@welshman/app/index.ts`. Use `Router.get()` (not `new Router(...)`) to get the app-configured singleton.
- `deriveProfile`, `deriveFollowList`, etc. use `makeLoadItem` under the hood: they fire a network request on first subscribe if data is not already in the repository, then resolve immediately on subsequent subscribes.
- `userFollowList`, `userMuteList`, etc. are derived from `pubkey`. They automatically re-derive when the active session changes (multi-account support).
---
## Using Welshman Stores Outside Svelte
All welshman stores implement the Svelte store contract: a `subscribe(callback) → unsubscribe` method where the callback fires **synchronously** with the current value on first call, then again on every change. This makes them trivially adaptable to any reactive framework — no Svelte runtime required, only the type imports.
### React
```typescript
import {useState, useEffect} from 'react'
import type {Readable, Writable} from 'svelte/store'
// Returns the current store value; re-renders when it changes.
function useReadable<T>(store: Readable<T>): T {
const [value, setValue] = useState<T>(() => {
// subscribe fires synchronously — capture the initial value then unsub immediately
let initial!: T
store.subscribe(v => { initial = v })()
return initial
})
useEffect(() => store.subscribe(setValue), [store])
return value
}
// Returns [currentValue, setter] — setter calls store.set directly.
function useWritable<T>(store: Writable<T>): [T, (value: T) => void] {
return [useReadable(store), store.set]
}
```
Usage:
```tsx
import {userProfile, pubkey} from '@welshman/app'
function ProfileHeader() {
const profile = useReadable(userProfile)
const [currentPubkey, setPubkey] = useWritable(pubkey)
return <div>{profile?.name ?? currentPubkey}</div>
}
```
### SolidJS
```typescript
import {createSignal, onCleanup} from 'solid-js'
import type {Readable, Writable} from 'svelte/store'
// Returns a SolidJS accessor (getter function); updates reactively.
function useReadable<T>(store: Readable<T>): () => T {
let initial!: T
store.subscribe(v => { initial = v })() // sync capture then unsubscribe
const [value, setValue] = createSignal<T>(initial)
onCleanup(store.subscribe(v => setValue(() => v)))
return value
}
// Returns [accessor, setter].
function useWritable<T>(store: Writable<T>): [() => T, (value: T) => void] {
return [useReadable(store), store.set]
}
```
Usage:
```tsx
import {userProfile} from '@welshman/app'
function ProfileHeader() {
const profile = useReadable(userProfile)
return <div>{profile()?.name}</div>
}
```
### Vue
```typescript
import {ref, onUnmounted} from 'vue'
import type {Readable, Writable} from 'svelte/store'
function useReadable<T>(store: Readable<T>) {
let initial!: T
store.subscribe(v => { initial = v })()
const value = ref<T>(initial)
const unsub = store.subscribe(v => { value.value = v as any })
onUnmounted(unsub)
return value // use as a readonly ref
}
```
### Notes
- **No Svelte runtime needed.** Only `svelte/store` types are imported. The store objects themselves ship with `@welshman/app`.
- **Welshman stores with `.get()`** (created via `withGetter`) can be read synchronously without subscribing — useful in event handlers and callbacks outside any reactive context. Most writable stores in `@welshman/app` expose `.get()`.
- **`subscribe` always fires immediately.** Unlike many observable libraries, the initial emission is synchronous, so the `useState` / `createSignal` initial value is always populated on first render.
---
## Gotchas & Tips
- **Thunks sign lazily.** `publishThunk` returns synchronously and immediately writes an unsigned/hashed event to `repository` for optimistic UI. Actual signing happens in a background queue. Do not assume the event has an `id` suitable for embedding in other events until signing completes.
- **`delay` is an undo window, not a debounce.** The thunk starts the delay timer immediately; if not aborted before `delay` ms, it signs and publishes. Calling `thunk.controller.abort()` after the delay has elapsed does nothing.
- **`sendWrapped` uses `recipients`, not `pubkeys`.** The docs example uses `pubkeys` but the actual type is `recipients: string[]`.
- **Gift wrap processing requires opt-in.** Set `shouldUnwrap.set(true)` to enable automatic NIP-59 unwrapping of incoming `kind:1059` events. Without this, wrapped events are silently discarded.
- **`commands` force-load lists before modifying them.** `follow()`, `unfollow()`, etc. call `forceLoadUserFollowList` to ensure they have the latest list before adding/removing, preventing accidental list truncation. Do not call these in rapid succession without awaiting each one.
- **WoT graph is rebuilt at most once per second** (throttled). Do not expect `wotGraph` to reflect a `follow()` call immediately; subscribe to the store instead.
- **`routerContext.getDefaultRelays` is throttled** with a 200 ms window by default in `index.ts`. It returns up to the 5 highest-quality known relays. Override it before any relay connections if you want a fixed bootstrap list.
- **Multiple sessions are supported.** Call `loginWith*` multiple times to add sessions. Switch the active session with `pubkey.set(otherPubkey)`. Remove a session with `dropSession(pubkey)` — this also cleans up the cached signer.
- **Stores have `.get()` via `withGetter`.** `pubkey.get()`, `signer.get()`, `session.get()`, `signerLog.get()`, `shouldUnwrap.get()` all work without `get()` from `svelte/store`. Use this for synchronous reads outside of reactive contexts.
- **`appContext.dufflepudUrl` must be set before first handle/zapper load.** There is no lazy re-fetch; set it at app startup.
+209
View File
@@ -0,0 +1,209 @@
---
name: welshman-content
description: "Use this skill when working with @welshman/content: parsing nostr note content, extracting mentions/links/media/topics, or rendering parsed content to HTML or custom formats."
---
# welshman/content — Note Content Parsing
`@welshman/content` parses raw nostr event content strings into structured typed elements (links, profiles, events, topics, media, etc.) and renders them back to text or HTML. It is a standalone package with no welshman sibling dependencies — it sits at the bottom of the stack and can be used independently or alongside `@welshman/app`, `@welshman/net`, etc.
## Installation
```bash
npm install @welshman/content
# or
pnpm add @welshman/content
yarn add @welshman/content
```
## Key Exports
### Parsing
| Export | Description |
|--------|-------------|
| `parse({ content?, tags? })` | Main entry point. Parses a content string (and optional event tags) into a `Parsed[]` array. Falls back to the `alt` tag if content is empty. |
| `truncate(content, opts?)` | Truncates a `Parsed[]` array, appending an `Ellipsis` element. Leaves content unchanged if it fits within `maxLength`. |
| `reduceLinks(content)` | Collapses consecutive block-level links (each on its own line) into a single `LinkGrid` element for gallery rendering. |
| `urlIsMedia(url)` | Returns `true` if the URL has a media file extension (jpg, png, gif, webp, mp4, etc.). |
### Parsed Types
`ParsedType` enum values and their corresponding type shapes:
| `ParsedType` | `value` type | Notes |
|---|---|---|
| `Text` | `string` | Plain text |
| `Newline` | `string` | One or more `\n` characters |
| `Topic` | `string` | Hashtag text without the `#`; numeric-only tags are skipped |
| `Link` | `{ url: URL, meta: Record<string, string> }` | URLs with any scheme (http, https, ftp, ws, wss, etc.) and bare domains without a protocol; `meta` is populated from `imeta` tags or URL hash params |
| `LinkGrid` | `{ links: ParsedLinkValue[] }` | Produced by `reduceLinks`; a collection of adjacent block links |
| `Profile` | `ProfilePointer` (`{ pubkey, relays? }`) | nostr:npub / nostr:nprofile / @nostr:npub / @nostr:nprofile references (the `nostr:` prefix is required) |
| `Event` | `EventPointer` (`{ id, relays?, author?, kind? }`) | note / nevent references |
| `Address` | `AddressPointer` (`{ identifier, pubkey, kind, relays? }`) | naddr references |
| `Emoji` | `{ name: string, url?: string }` | `:shortcode:``url` resolved from `emoji` tags |
| `Code` | `string` | Backtick inline code or triple-backtick blocks |
| `Cashu` | `string` | cashu: token strings |
| `Invoice` | `string` | Bare lightning invoice string (without `lightning:` prefix); the `lightning:` prefix is in `raw` |
| `Email` | `string` | Email addresses (with or without `mailto:`) |
| `Ellipsis` | `string` | Appended by `truncate` to indicate truncated content |
Every `Parsed` element also has a `raw: string` field holding the original matched text (empty string for synthetic elements like `LinkGrid` and `Ellipsis`).
### Type Guards
All guards narrow the union type:
```
isAddress isCashu isCode isEllipsis isEmail
isEmoji isEvent isImage isInvoice isLink
isLinkGrid isNewline isProfile isText isTopic
```
`isImage(parsed)` — special guard: true only for `ParsedLink` elements whose URL ends in `.jpg/.jpeg/.png/.gif/.webp`.
### Rendering
| Export | Description |
|--------|-------------|
| `renderAsText(parsed, options?)` | Renders `Parsed \| Parsed[]` to a `Renderer`; call `.toString()` to get a string. Text rendering shows links as full raw URLs and entities as their full bech32 string (the `renderEntity` truncation is discarded in text mode since `renderLink` returns the href). |
| `renderAsHtml(parsed, options?)` | Same, but produces sanitized HTML with `<a>` tags. Default `entityBase` is `https://njump.me/`. |
| `render(parsed, renderer)` | Low-level: renders into an existing `Renderer` instance. |
| `makeTextRenderer(options?)` | Creates a `Renderer` pre-configured for text output. |
| `makeHtmlRenderer(options?)` | Creates a `Renderer` pre-configured for HTML output. |
| `Renderer` | Class with `addText`, `addLink`, `addEntityLink`, `addNewlines`, `toString`. |
`RenderOptions` fields (all optional when using the convenience functions):
| Field | Default (HTML) | Description |
|---|---|---|
| `newline` | `"\n"` | String emitted for each newline character |
| `entityBase` | `"https://njump.me/"` | Base URL prepended to bech32 entity strings |
| `renderLink(href, display)` | `<a href=... target=_blank>display</a>` | Custom link HTML/text |
| `renderEntity(entity)` | `entity.slice(0, 16) + "…"` | Display text for entity links |
| `createElement(tag)` | `document.createElement(tag)` | DOM element factory; override for SSR/non-browser |
Individual per-type render helpers are also exported (`renderText`, `renderLink`, `renderProfile`, `renderEvent`, `renderAddress`, `renderTopic`, `renderEmoji`, `renderCode`, `renderCashu`, `renderInvoice`, `renderEmail`, `renderNewline`, `renderEllipsis`, `renderOne`, `renderMany`).
## Common Patterns
### Parse and render a note to HTML
```typescript
import { parse, renderAsHtml } from '@welshman/content'
const event = {
content: "Hello #nostr! Check out nostr:npub1jlrs53pkdfjnts29kveljul2sm0actt6n8dxrrzqcersttvcuv3qdjynqn",
tags: []
}
const parsed = parse({ content: event.content, tags: event.tags })
const html = renderAsHtml(parsed, {
entityBase: 'https://njump.me/',
renderEntity: (entity) => entity.slice(0, 12) + '…',
}).toString()
// → 'Hello nostr! Check out <a href="https://njump.me/nprofile1..." target="_blank">nprofile1qqsj…</a>'
```
### Truncate long notes for a feed preview
```typescript
import { parse, truncate, reduceLinks, renderAsHtml } from '@welshman/content'
const parsed = parse({ content: event.content, tags: event.tags })
const withGrids = reduceLinks(parsed)
const preview = truncate(withGrids, { minLength: 300, maxLength: 500 })
const html = renderAsHtml(preview).toString()
```
### Extract all mentioned pubkeys
```typescript
import { parse, isProfile, isAddress } from '@welshman/content'
const parsed = parse({ content: event.content, tags: event.tags })
const pubkeys = parsed
.filter(isProfile)
.map(p => p.value.pubkey)
const addressPubkeys = parsed
.filter(isAddress)
.map(p => p.value.pubkey)
```
### Extract all links and check for images
```typescript
import { parse, isLink, isImage, isLinkGrid } from '@welshman/content'
const parsed = parse({ content: event.content, tags: event.tags })
const images = parsed.filter(isImage)
// images[0].value.url → URL object
// images[0].value.meta → Record<string, string> from imeta tags
const allLinks = parsed.filter(isLink)
```
### Render with a custom link handler (e.g. Svelte/React)
```typescript
import { parse, renderAsHtml } from '@welshman/content'
const html = renderAsHtml(parse({ content }), {
renderLink: (href, display) =>
`<a href="${href}" class="text-blue-500 underline" rel="noopener">${display}</a>`,
renderEntity: (entity) => entity.slice(0, 16) + '…',
}).toString()
```
### Server-side rendering (no DOM)
The default `createElement` calls `document.createElement`, which fails in Node/SSR environments. Override it:
```typescript
import { parse, renderAsHtml } from '@welshman/content'
import { JSDOM } from 'jsdom'
const dom = new JSDOM('')
const html = renderAsHtml(parse({ content }), {
createElement: (tag: string) => dom.window.document.createElement(tag),
}).toString()
```
### Using custom emoji tags
```typescript
import { parse, isEmoji } from '@welshman/content'
const tags = [
['emoji', 'parrot', 'https://example.com/parrot.gif'],
]
const parsed = parse({ content: 'Hello :parrot:!', tags })
const emojiElements = parsed.filter(isEmoji)
// emojiElements[0].value → { name: 'parrot', url: 'https://example.com/parrot.gif' }
```
## Integration Notes
- `@welshman/content` has no dependencies on other welshman packages. It depends on `@braintree/sanitize-url` as a direct dependency and requires `nostr-tools` ^2.x as a peer dependency (consumers must install it).
- In `@welshman/app`, content parsing is typically done at the component layer. The `parse` function is called with `event.content` and `event.tags` together so that `imeta` and `emoji` tags are resolved.
- `ParsedLinkValue.meta` is populated from `imeta` tags (NIP-92). When an event carries rich media metadata, the parsed link's `meta` object will include fields like `url`, `m` (MIME type), `blurhash`, `dim`, etc.
- `reduceLinks` should be called after `parse` and before `truncate` if you want link grids to count as single media units for truncation purposes.
## Gotchas & Tips
- **`parse` trims content** before processing. Leading/trailing whitespace in the raw content string is dropped.
- **`parse` fallback**: if `content` is empty or whitespace, `parse` will use the first `alt` tag value instead. This is useful for kind-1 reposts and other events with alternative text.
- **`truncate` is non-destructive** when content is short: it returns the original array unchanged if the total estimated size is under `maxLength`.
- **`reduceLinks` requires block-level links**: a link is only pulled into a grid if it appears at the start of a block (i.e. preceded by a newline or at the very beginning). Inline links in the middle of a sentence are left as `ParsedLink`.
- **`isImage` is stricter than `urlIsMedia`**: `isImage` only matches `.jpg/.jpeg/.png/.gif/.webp` — it will not match `.mp4` or `.webm`. Use `urlIsMedia` directly if you need to detect video; note that `urlIsMedia` takes a URL string, not a `Parsed` element — usage would be: `urlIsMedia(parsed.value.url.toString())`.
- **`Renderer.toString()`** is how you get the final string out. `renderAsHtml` and `renderAsText` both return a `Renderer` instance, not a string.
- **`LinkGrid` is not rendered by default renderers**: `renderOne` has no case for `ParsedType.LinkGrid`. You must handle it yourself when building a custom UI (e.g. render each `value.links` entry as an image or card grid).
- **Legacy mentions** (`#[0]`, `#[1]`) are parsed automatically from the `tags` array and emitted as `ParsedProfile` or `ParsedEvent` elements.
- **Numeric hashtags are skipped**: `#42` will not produce a `Topic` element.
- **Email matching** strips a leading `mailto:` — the resulting `ParsedEmail.value` is always the bare address string.
+347
View File
@@ -0,0 +1,347 @@
---
name: welshman-editor
description: "Use this skill when working with @welshman/editor: the batteries-included Tiptap-based rich-text editor for composing nostr notes with mention autocomplete, media upload, and inline nostr objects."
---
# welshman/editor — Nostr Editor Component
`@welshman/editor` provides a batteries-included text editor for composing nostr notes, built on top of [Tiptap](https://tiptap.dev) and [nostr-editor](https://github.com/cesardeazevedo/nostr-editor). It bundles a curated set of extensions that handle nostr-specific concerns (nprofile mentions, nevent/naddr embeds, file upload, Lightning invoices) as well as general composition features (history, placeholder, inline code, word count). It is framework-agnostic — the core is plain TypeScript/DOM, though it powers the Svelte-based editors of Coracle and Flotilla.
## Installation
```bash
npm install @welshman/editor
# or
pnpm add @welshman/editor
# or
yarn add @welshman/editor
```
Peer dependencies that must be installed separately:
```bash
npm install @welshman/lib @welshman/util nostr-tools nostr-editor
```
Import the bundled CSS to get default object/suggestion styles (optional but recommended):
```typescript
import "@welshman/editor/index.css"
```
---
## Key Exports
### Extensions
| Export | Description |
|--------|-------------|
| `WelshmanExtension` | The main all-in-one Tiptap extension. Configure once; it registers every sub-extension below. `submit` is **required**. |
| `BreakOrSubmit` | Keyboard handler: `Mod-Enter` always submits; `Enter` submits only when `aggressive: true` (chat-style); `Shift-Enter` inserts a hard break. |
| `CodeInline` | Inline `code` node with backtick input/paste rules. |
| `WordCount` | Extension that tracks `editor.storage.wordCount.words` and `editor.storage.wordCount.chars` on every document update. |
### Node Views
These are drop-in Tiptap node-view factory functions that render inline pill elements with `.tiptap-object` CSS class. Override them via `WelshmanExtensionOptions` to render richer UI.
| Export | Renders |
|--------|---------|
| `MentionNodeView` | nprofile nodes — shows `@<bech32 prefix>…` |
| `MediaNodeView` | Image and video nodes — shows filename or URL; adds `.tiptap-uploading` animation while uploading |
| `EventNodeView` | nevent and naddr nodes — shows bech32 prefix |
| `Bolt11NodeView` | bolt11 Lightning invoice nodes — shows the first 16 characters of the Lightning invoice string (the `lnbc` attribute) followed by `...` |
### Plugins
| Export | Description |
|--------|-------------|
| `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char`, `name`, `editor`, `search`, and `select`. Optional: `updateSignal`, `createSuggestion`. |
| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`. Optional: `updateSignal`, `createSuggestion`. |
| `DefaultSuggestionsWrapper` | Default dropdown renderer used by `TippySuggestion`. Implements `ISuggestionsWrapper`; replace to use a framework component. |
**`TippySuggestion` options:**
| Option | Required | Description |
|--------|----------|-------------|
| `char` | yes | Trigger character (e.g. `"@"`, `"~"`) |
| `name` | yes | ProseMirror node type name to insert on selection |
| `editor` | yes | The Tiptap `Editor` instance |
| `search` | yes | `(term: string) => string[]` — returns item values matching the query |
| `select` | yes | `(value: string, props) => void` — called when the user picks an item; call `props.command({...attrs})` to insert the node |
| `updateSignal` | no | A Svelte `Readable` store; when it emits, the suggestion list re-renders (use for async/reactive search results) |
| `createSuggestion` | no | `(value: string) => Element` — renders a custom DOM element for each dropdown item |
`MentionSuggestion` is a pre-wired `TippySuggestion` for nprofile nodes. It handles `select` internally (encodes the pubkey as an nprofile with relay hints from `getRelays`) so you only need to supply `editor`, `search`, and `getRelays`.
### Re-exports from upstream
| Export | Source |
|--------|--------|
| `Editor` | `@tiptap/core` — the editor instance class |
| `NodeViewProps` | `@tiptap/core` — prop type for node view factories (Tiptap's type) |
| `NodeViewRendererProps` | `@tiptap/core` — alternate props type used in `Node.create({ addNodeView })` |
| `UploadTask` | `nostr-editor` — shape of an in-progress or completed file upload |
| `FileAttributes` | `nostr-editor``{ file: File, … }` passed to the `upload` callback |
| `editorProps` | `nostr-editor` — base ProseMirror `editorProps` used by nostr-editor; pass directly to `new Editor({ editorProps })` |
---
## WelshmanExtensionOptions Reference
All keys are optional. Pass `false` to disable a built-in extension entirely. Pass `{ extend?, config? }` to override defaults.
```typescript
type WelshmanExtensionOptions = {
bolt11?: false
breakOrSubmit?: false | { extend?, config?: BreakOrSubmitOptions }
codeInline?: false | { extend?, config? }
codeBlock?: false | { extend?, config?: CodeBlockOptions }
document?: false
dropcursor?: false | { extend?, config?: DropcursorOptions }
fileUpload?: { extend?: Partial<any>, config?: Partial<FileUploadOptions> & Pick<FileUploadOptions, "upload"> }
gapcursor?: false
history?: false | { extend?, config?: HistoryOptions }
image?: false | { extend?, config?: ImageOptions }
link?: false | { extend?, config?: LinkOptions }
naddr?: false | { extend?, config? }
nevent?: false | { extend?, config? }
nprofile?: false | { extend?, config? }
nsecReject?: false | { extend?, config?: NSecRejectOptions }
paragraph?: false | { extend?, config?: ParagraphOptions }
placeholder?: false | { extend?, config?: PlaceholderOptions }
tag?: false
text?: false
video?: false
wordCount?: false
}
```
`fileUpload.config` requires at minimum an `upload` function. The `upload` callback must return `Promise<UploadTask>`:
```typescript
interface UploadResult { url: string; sha256: string; tags: string[][] }
interface UploadTask { result?: UploadResult; error?: string }
```
Default allowed MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `video/mpeg`, `video/webm`. `immediateUpload` defaults to `true`.
---
## Example
A full-featured editor factory covering file upload, @-mention and custom-trigger autocomplete, reactive node views, word count, and draft persistence.
```typescript
import {get, writable} from "svelte/store"
import {Node, Extension, mergeAttributes} from "@tiptap/core"
import {Plugin, PluginKey} from "@tiptap/pm/state"
import type {NodeViewRendererProps} from "@tiptap/core"
import {Router} from "@welshman/router"
import {createSearch, profiles, searchProfiles, deriveProfileDisplay} from "@welshman/app"
import {
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
} from "@welshman/editor"
import type {FileAttributes, UploadTask} from "@welshman/editor"
// ── Custom inline node: room reference (~) ───────────────────────────────────
// Defines a new ProseMirror node type for inline room references.
// Register it alongside WelshmanExtension so Tiptap knows about the "roomref" name.
const RoomReferenceExtension = Node.create({
name: "roomref",
atom: true, inline: true, group: "inline", selectable: true, priority: 1000,
addAttributes: () => ({url: {default: undefined}, h: {default: undefined}}),
parseHTML: () => [{tag: 'span[data-type="roomref"]'}],
renderHTML: ({HTMLAttributes}) =>
["span", mergeAttributes(HTMLAttributes, {"data-type": "roomref"}), "~"],
renderText: ({node}) => `~${node.attrs.url ?? ""}:${node.attrs.h ?? ""}`,
addNodeView: () => ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span")
dom.classList.add("room-ref")
const unsub = deriveRoomDisplay(node.attrs.url, node.attrs.h)
.subscribe(d => { dom.textContent = "~" + d })
return {
dom, destroy: unsub,
selectNode: () => dom.classList.add("room-ref--active"),
deselectNode: () => dom.classList.remove("room-ref--active"),
}
},
})
// ── Editor factory ────────────────────────────────────────────────────────────
export const makeEditor = ({
content = "" as string | object,
placeholder = "",
uploading, // optional Writable<boolean>
wordCount, // optional Writable<number>
charCount, // optional Writable<number>
submit,
}: {
content?: string | object
placeholder?: string
uploading?: ReturnType<typeof writable<boolean>>
wordCount?: ReturnType<typeof writable<number>>
charCount?: ReturnType<typeof writable<number>>
submit: () => void
}) => {
const profileSearch = createSearch(get(profiles), {
onSearch: searchProfiles,
getValue: (p: any) => p.event.pubkey,
fuseOptions: {keys: ["nip05", "name", "display_name"], threshold: 0.3},
})
const editor = new Editor({
content,
editorProps,
element: document.createElement("div"),
extensions: [
RoomReferenceExtension,
WelshmanExtension.configure({
submit,
extensions: {
// Chat-style: Enter submits, Shift-Enter inserts line break
breakOrSubmit: {config: {aggressive: true}},
placeholder: {config: {placeholder}},
// File upload — upload() must return Promise<UploadTask>
fileUpload: {
config: {
upload: async (attrs: FileAttributes): Promise<UploadTask> => {
try {
const {url, sha256, tags} = await myUploadServer(attrs.file)
return {result: {url, sha256, tags}}
} catch (e) {
return {error: String(e)}
}
},
onDrop: () => uploading?.set(true),
onComplete: () => uploading?.set(false),
onUploadError: (ed, _task) => {
ed.commands.removeFailedUploads()
uploading?.set(false)
},
},
},
// Custom reactive nprofile node view + "@" and "~" autocomplete
nprofile: {
extend: {
addNodeView: () => ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span")
dom.classList.add("mention")
const unsub = deriveProfileDisplay(node.attrs.pubkey)
.subscribe($d => { dom.textContent = "@" + $d })
return {
dom, destroy: unsub,
selectNode: () => dom.classList.add("mention--active"),
deselectNode: () => dom.classList.remove("mention--active"),
}
},
addProseMirrorPlugins() {
return [
// "@" — nprofile mention; updateSignal re-renders when search index changes
MentionSuggestion({
editor: (this as any).editor,
search: term => profileSearch.searchValues(term),
getRelays: pubkey => Router.get().FromPubkeys([pubkey]).getUrls(),
createSuggestion: pubkey => {
const el = document.createElement("span")
el.textContent = pubkey.slice(0, 12) + "…"
return el
},
}),
// "~" — custom roomref node; select must call props.command({...attrs})
TippySuggestion({
char: "~", name: "roomref",
editor: (this as any).editor,
search: term => roomSearch.searchValues(term),
select: (id, props) => {
const [url, h] = splitRoomId(id)
if (url && h) props.command({url, h})
},
createSuggestion: id => {
const el = document.createElement("span")
el.textContent = id.slice(0, 16) + "…"
return el
},
}),
]
},
},
},
},
}),
],
onUpdate({editor}) {
wordCount?.set(editor.storage.wordCount.words)
charCount?.set(editor.storage.wordCount.chars)
},
})
return editor
}
// ── Reading content on submit ─────────────────────────────────────────────────
const onSubmit = (editor: Editor) => {
const content = editor.getText({blockSeparator: "\n"}).trim()
const tags = editor.storage.nostr.getEditorTags() // NIP-10 / NIP-27 tags
console.log({content, tags})
editor.chain().clearContent().run()
}
// ── Mounting in Svelte ────────────────────────────────────────────────────────
```
```svelte
<script lang="ts">
import type {Editor} from "@welshman/editor"
import {onMount, onDestroy} from "svelte"
const {editor, autofocus = false}: {editor: Editor; autofocus?: boolean} = $props()
let element: HTMLElement
onMount(() => {
element.append(editor.options.element)
if (autofocus) {
const atEnd = editor.getText().trim().length > 0
requestAnimationFrame(() => editor.commands.focus(atEnd ? "end" : "start"))
}
})
onDestroy(() => editor.destroy())
</script>
<div bind:this={element}></div>
```
---
## Integration Notes
- **`@welshman/app`** — `profileSearch` and `deriveProfileDisplay` are the typical sources for mention autocomplete data and display names.
- **`@welshman/router`** — `Router.get().FromPubkeys([pubkey]).getUrls()` provides the relay hints encoded into nprofile bech32 strings.
- **`@welshman/util`** — `fromNostrURI` is used internally by `EventNodeView` to strip the `nostr:` scheme before displaying.
- **`nostr-editor`** — `WelshmanExtension` extends `NostrExtension` from this package. Storage at `editor.storage.nostr` (including `getEditorTags()`) is provided by `nostr-editor`, not welshman itself.
- **`@tiptap/core`** — `Editor`, `NodeViewProps`, and all extension primitives come from Tiptap. Welshman does not re-export every Tiptap helper; import additional ones directly from `@tiptap/core` as needed.
---
## Gotchas & Tips
- **`submit` is required.** `WelshmanExtension.configure({submit})` will throw during editor initialization (when extensions are registered) if `submit` is omitted, not at the `configure()` call site.
- **Extension options are deep-merged, not replaced.** User-supplied `extensions` options are merged with welshman defaults via `deepMergeLeft`, so you only need to specify the keys you want to change. Supplying `false` for a key fully disables that extension.
- **Default node views are plain DOM.** The built-in `MentionNodeView`, `MediaNodeView`, etc. render minimal pill text. Override them via the `extend.addNodeView` pattern (see pattern 4) to render framework components, avatars, or rich previews.
- **`selectFiles()` command.** To open a file picker without a UI button inside the editor, call `editor.chain().selectFiles().run()` from any external button click handler.
- **CSS variables.** The bundled `index.css` exposes `--tiptap-object-bg`, `--tiptap-object-fg`, `--tiptap-active-bg`, `--tiptap-active-fg` for theming pills and the suggestion dropdown without overriding classes.
- **`tiptap-uploading` animation.** While a file is being uploaded, `MediaNodeView` adds the `.tiptap-uploading` class which triggers a pulsing opacity animation defined in `index.css`. No manual wiring is needed.
- **Tippy appends to dialog when open.** `TippySuggestion` checks for an open `<dialog>` element and appends the suggestion popover inside it to avoid z-index stacking issues in modal composers.
- **`getEditorTags()` returns NIP-10/27 tags.** This method on `editor.storage.nostr` collects all inline nostr objects (mentions, links, embeds) and returns the appropriate `p`, `e`, `a`, `t`, `r` tags for the published event. Always call this when building the event to publish.
- **Initial content.** Pass either a plain string or a ProseMirror JSON object to `new Editor({content})`. To restore a draft, save `editor.getJSON()` and pass it back as `content`.
- **`removeFailedUploads()` command.** Call `editor.commands.removeFailedUploads()` in `onUploadError` to clean up any partially-inserted upload nodes so the composer stays in a clean state.
- **`addFile(file, pos)` command.** Programmatically inserts a file upload node at a given ProseMirror position — useful for native clipboard paste (e.g. mobile) where the browser paste event carries no file data.
- **`editor.commands.focus('start' | 'end' | number)`.** Pass `"end"` to place the cursor after existing content (restoring a draft), `"start"` for a fresh empty editor. Call inside `requestAnimationFrame` when the editor element was just mounted.
+301
View File
@@ -0,0 +1,301 @@
---
name: welshman-feeds
description: "Use this skill when working with @welshman/feeds: building nostr feeds, FeedController, FeedCompiler, feed definitions, dynamic filtering, or composing feed sources."
---
# welshman/feeds — Dynamic Feed Construction
`@welshman/feeds` provides a declarative, composable system for defining and executing Nostr event feeds. You describe what you want (authors, kinds, tags, set operations) as a data structure, and the package compiles it into optimized relay requests and handles pagination, deduplication, and exhaustion. It sits on top of `@welshman/net` for relay communication and `@welshman/util` for types.
## Installation
```bash
npm install @welshman/feeds
# pnpm add @welshman/feeds
# yarn add @welshman/feeds
```
## Key Exports
### Feed Types (enum + tuple types)
| Export | Description |
|---|---|
| `FeedType` | Enum of all feed type discriminants (`Author`, `Kind`, `Tag`, `Union`, `Intersection`, `Difference`, `DVM`, `List`, `Label`, `WOT`, `Scope`, `Relay`, `Search`, `ID`, `Address`, `CreatedAt`, `Global`) |
| `Scope` | Enum: `Followers`, `Follows`, `Network`, `Self` |
| `Feed` | Union type of all feed tuple types |
| `RequestItem` | `{ relays?: string[], filters?: Filter[] }` — output of compilation |
### Factory Functions
All feed definitions are typed tuples. Always use factories rather than raw arrays.
```typescript
// Leaf feeds
makeAuthorFeed(...pubkeys: string[]): AuthorFeed
makeKindFeed(...kinds: number[]): KindFeed
makeTagFeed(key: string, ...values: string[]): TagFeed
makeIDFeed(...ids: string[]): IDFeed
makeAddressFeed(...addresses: string[]): AddressFeed
makeRelayFeed(...urls: string[]): RelayFeed
makeSearchFeed(...terms: string[]): SearchFeed
makeGlobalFeed(): GlobalFeed
makeScopeFeed(...scopes: Scope[]): ScopeFeed
makeWOTFeed(...items: WOTItem[]): WOTFeed
makeCreatedAtFeed(...items: CreatedAtItem[]): CreatedAtFeed
// Dynamic / remote feeds
makeDVMFeed(...items: DVMItem[]): DVMFeed
makeListFeed(...items: ListItem[]): ListFeed
makeLabelFeed(...items: LabelItem[]): LabelFeed
// Set operations
makeUnionFeed(...feeds: Feed[]): UnionFeed
makeIntersectionFeed(...feeds: Feed[]): IntersectionFeed
makeDifferenceFeed(...feeds: Feed[]): DifferenceFeed
```
### Type Guards
```typescript
isAuthorFeed(feed) isKindFeed(feed) isTagFeed(feed)
isIDFeed(feed) isAddressFeed(feed) isRelayFeed(feed)
isSearchFeed(feed) isGlobalFeed(feed) isScopeFeed(feed)
isWOTFeed(feed) isCreatedAtFeed(feed)
isDVMFeed(feed) isListFeed(feed) isLabelFeed(feed)
isUnionFeed(feed) isIntersectionFeed(feed) isDifferenceFeed(feed)
hasSubFeeds(feed) // true for Union | Intersection | Difference
```
### Argument Extraction
```typescript
getFeedArgs(feed: AuthorFeed): string[]
getFeedArgs(feed: KindFeed): number[]
getFeedArgs(feed: CreatedAtFeed): CreatedAtItem[]
getFeedArgs(feed: WOTFeed): WOTItem[]
getFeedArgs(feed: UnionFeed): Feed[]
// overloaded for every feed type
```
### Conversion Utilities
```typescript
// Tags → feeds
feedsFromTags(tags: string[][], mappings?: TagFeedMapping[]): Feed[]
feedFromTags(tags: string[][], mappings?: TagFeedMapping[]): IntersectionFeed
// Filter(s) → feeds
feedsFromFilter(filter: Filter): Feed[]
feedFromFilter(filter: Filter): Feed
feedFromFilters(filters: Filter[]): Feed
// Default tag-to-feed mappings (override via DVMItem/ListItem mappings)
defaultTagFeedMappings: TagFeedMapping[]
// [["a", Address], ["e", ID], ["p", Author], ["r", Relay], ["t", Tag "#t"]]
```
### Traversal & Simplification
```typescript
walkFeed(feed: Feed, visit: (feed: Feed) => void): void
findFeed(feed: Feed, match: (feed: Feed) => boolean): Feed | undefined
simplifyFeed(feed: Feed): Feed // flattens nested same-type set ops
```
### FeedCompiler
Transforms a `Feed` into `RequestItem[]` for direct relay querying.
```typescript
class FeedCompiler {
constructor(options: FeedCompilerOptions)
canCompile(feed: Feed): boolean
async compile(feed: Feed): Promise<RequestItem[]>
}
type FeedCompilerOptions = {
getPubkeysForScope: (scope: Scope) => string[]
getPubkeysForWOTRange: (min: number, max: number) => string[]
signer?: ISigner
signal?: AbortSignal
context?: AdapterContext
}
```
### FeedController
Orchestrates loading/listening with pagination, deduplication, and set-operation handling.
```typescript
class FeedController {
compiler: FeedCompiler
constructor(options: FeedControllerOptions)
async load(limit: number): Promise<void>
listen(): () => Promise<void>
async getLoader(): Promise<(limit: number) => Promise<void>>
async getListener(): Promise<() => Promise<void>>
async getRequestItems(): Promise<RequestItem[] | undefined>
}
type FeedControllerOptions = FeedCompilerOptions & {
feed: Feed
tracker?: Tracker
onEvent?: (event: TrustedEvent) => void
onExhausted?: () => void
useWindowing?: boolean
}
```
## Common Patterns
### 1. Simple author + kind feed
```typescript
import { FeedController, makeIntersectionFeed, makeAuthorFeed, makeKindFeed } from '@welshman/feeds'
import { Scope } from '@welshman/feeds'
const controller = new FeedController({
feed: makeIntersectionFeed(
makeAuthorFeed("pubkey1", "pubkey2"),
makeKindFeed(1),
),
getPubkeysForScope: (scope) => [],
getPubkeysForWOTRange: (min, max) => [],
onEvent: (event) => console.log(event.id),
onExhausted: () => console.log('done'),
})
await controller.load(50)
```
### 2. Follows feed with WOT filtering
```typescript
import {
FeedController, makeIntersectionFeed, makeScopeFeed,
makeWOTFeed, makeKindFeed, Scope
} from '@welshman/feeds'
const controller = new FeedController({
feed: makeIntersectionFeed(
makeScopeFeed(Scope.Follows),
makeWOTFeed({ min: 0.1 }),
makeKindFeed(1, 6, 7),
),
getPubkeysForScope: (scope) => {
if (scope === Scope.Follows) return myFollowList
return []
},
getPubkeysForWOTRange: (min, max) => wotIndex.getPubkeys(min, max),
onEvent: handleEvent,
onExhausted: markExhausted,
useWindowing: true,
})
await controller.load(20)
```
### 3. DVM-powered algorithmic feed
```typescript
import {
FeedController, makeIntersectionFeed, makeDVMFeed,
makeWOTFeed, FeedType
} from '@welshman/feeds'
// DVMItem.mappings controls how DVM result tags become sub-feeds
const controller = new FeedController({
feed: makeIntersectionFeed(
makeDVMFeed({
kind: 5300,
tags: [['p', 'dvm-pubkey-hex']],
mappings: [['p', [FeedType.Author]]],
}),
makeWOTFeed({ min: 0.05 }),
),
getPubkeysForScope: () => [],
getPubkeysForWOTRange: (min, max) => wotPubkeys,
onEvent: handleEvent,
})
await controller.load(30)
```
### 4. List-based feed (NIP-51 list)
```typescript
import { FeedController, makeListFeed, makeKindFeed, makeUnionFeed, FeedType } from '@welshman/feeds'
const controller = new FeedController({
feed: makeUnionFeed(
makeListFeed({
addresses: ["10003:pubkey:identifier"],
// default tag mappings applied unless overridden
}),
makeKindFeed(1),
),
getPubkeysForScope: () => [],
getPubkeysForWOTRange: () => [],
onEvent: handleEvent,
})
await controller.load(25)
```
### 5. Converting existing filters to a feed
```typescript
import { ago, HOUR } from '@welshman/lib'
import { feedFromFilters, FeedCompiler } from '@welshman/feeds'
const filters = [
{ kinds: [1], authors: ["pubkey1"], since: ago(HOUR) },
{ kinds: [6], "#e": ["event-id"] },
]
const feed = feedFromFilters(filters)
const compiler = new FeedCompiler({
getPubkeysForScope: () => [],
getPubkeysForWOTRange: () => [],
})
const requestItems = await compiler.compile(feed)
// => [{filters: [{kinds:[1], authors:["pubkey1"], since:...}]}, ...]
```
### 6. Traversing a feed tree to inspect contents
```typescript
import { walkFeed, isKindFeed, isAuthorFeed, getFeedArgs } from '@welshman/feeds'
const kinds = new Set<number>()
const authors = new Set<string>()
walkFeed(myFeed, (node) => {
if (isKindFeed(node)) getFeedArgs(node).forEach(k => kinds.add(k))
if (isAuthorFeed(node)) getFeedArgs(node).forEach(p => authors.add(p))
})
console.log('Kinds in feed:', [...kinds])
console.log('Authors in feed:', [...authors])
```
## Integration Notes
- **`@welshman/util`** — `Filter`, `TrustedEvent`, and nostr primitives used throughout. `getIdFilters()` is used internally by the compiler for address feeds.
- **`@welshman/signer`** — `ISigner` interface, passed optionally through `FeedCompilerOptions` for DVM requests that require signing.
- **`@welshman/net`** — The `FeedController` delegates to `requestPage` for relay communication. The `FeedCompiler` delegates to `requestDVM` for DVM-based feeds. Neither accepts `request` or `requestDVM` as constructor options. `AdapterContext` from net is passed through `FeedCompilerOptions`.
- **`@welshman/app`** — Higher-level app packages typically wire up `getPubkeysForScope` and `getPubkeysForWOTRange` using their own follow/WOT stores, then construct `FeedController` instances from user-facing feed definitions.
- **`Tracker`** — Optional deduplication helper (from `@welshman/net` or app layer). Pass a shared `Tracker` instance to avoid re-emitting events seen in other controllers.
## Gotchas & Tips
- **Always use factory functions** (`makeAuthorFeed`, etc.) rather than constructing raw tuples — the tuple structure is internal and type safety depends on using factories.
- **`useWindowing: true`** is for relays that may return events out of chronological order. Do not use it for DVM/algorithmic feeds where order is part of the result.
- **`FeedController.load()` is stateful** — each call continues from where the last left off (pagination). Create a new controller to reset.
- **`canCompile` returns `false` only for `FeedType.Difference`** (and recursively for `Union`/`Intersection` whose sub-feeds include a `Difference`). DVM and List feeds return `true` from `canCompile` and are compiled asynchronously by `_compileDvms` and `_compileLists` inside the compiler's `compile` method. The feeds handled specially by `FeedController` (outside the compiled request flow) are `Difference`, `Union`, and `Intersection` — but only when `canCompile` returns `false` for them.
- **`simplifyFeed`** flattens nested same-type set operations (e.g. `union(union(a,b), c)``union(a,b,c)`). Run it before storing or serializing feed definitions.
- **`makeDifferenceFeed(included, ...excluded)`** — the first argument is the base feed to include; all subsequent feeds define events to exclude.
- **Tag key convention** — tag feeds use `#t`, `#e`, etc. (hash-prefixed) to match Nostr filter syntax. `makeTagFeed("#t", "bitcoin")` produces filter `{"#t": ["bitcoin"]}`.
- **`CreatedAtItem.relative`** — when set to `["since"]` or `["until"]`, the compiler treats those timestamps as relative offsets from the current time rather than absolute unix timestamps.
- **Intersection of feeds is AND logic across relay results** — an event must appear in responses from ALL sub-feeds to be emitted. This can significantly reduce result counts vs. a union.
+415
View File
@@ -0,0 +1,415 @@
---
name: welshman-lib
description: "Use this skill when working with @welshman/lib: general-purpose utilities including LRU cache, EventEmitter, Deferred promises, TaskQueue, URL normalization, or other standalone helpers."
---
# welshman/lib — General Utilities
`@welshman/lib` is a lightweight TypeScript utility library that forms the foundation of the welshman nostr stack. It provides common helpers used across all sibling packages: array/object manipulation, numeric helpers, async primitives, caching, event emission, and encoding utilities. It depends on `@scure/base` (for bech32/utf8 encoding) and `events` (Node.js EventEmitter polyfill).
## Installation
```bash
npm install @welshman/lib
# or
pnpm add @welshman/lib
```
## Key Exports
### Deferred Promises
| Export | Description |
|--------|-------------|
| `Deferred<T, E>` | Type: a `Promise<T>` with `.resolve(T)` and `.reject(E)` methods attached |
| `defer<T, E>()` | Creates a `Deferred<T, E>` — a promise with exposed `.resolve()` and `.reject()` |
| `makePromise<T, E>(executor)` | Creates a strongly-typed promise with typed error |
`E` defaults to `T` when omitted. `defer<void>()` for a signal-style deferred. `thunk.complete` in `@welshman/app` is a `Deferred<void>`.
```typescript
import { defer } from '@welshman/lib'
const ready = defer<void>()
socket.on('open', () => ready.resolve())
await ready
```
### EventEmitter
| Export | Description |
|--------|-------------|
| `Emitter` | Extends Node.js `EventEmitter`; all events also fire on the `'*'` listener with the event name prepended |
```typescript
import { Emitter } from '@welshman/lib'
const bus = new Emitter()
bus.on('*', (eventType, ...args) => console.log(eventType, args))
bus.emit('login', { pubkey: '...' })
```
### LRU Cache
| Export | Description |
|--------|-------------|
| `LRUCache<K, V>` | LRU cache; evicts least-recently-used entries when full |
| `cached(options)` | Memoizes a function with an LRU backing cache; exposes `.cache` and `.pop()` |
| `simpleCache(getValue)` | Minimal memoization wrapper with default settings |
```typescript
import { LRUCache, cached } from '@welshman/lib'
const cache = new LRUCache<string, number>(100)
cache.set('x', 42)
cache.get('x') // 42
cache.pop('x') // 42 and removes entry
const getProfile = cached({
maxSize: 500,
getKey: ([pubkey]: [string]) => pubkey,
getValue: ([pubkey]: [string]) => fetchProfile(pubkey),
})
getProfile.pop(pubkey) // invalidate one entry
```
### Task Queue
| Export | Description |
|--------|-------------|
| `TaskQueue<Item>` | Processes items asynchronously in configurable batches |
Options: `batchSize`, `batchDelay` (ms), `processItem`. Methods: `.push(item)`, `.remove(item)`, `.start()`, `.stop()`, `.clear()`, `.process()`, `.subscribe(cb)`.
```typescript
import { TaskQueue } from '@welshman/lib'
const queue = new TaskQueue<string>({
batchSize: 10,
batchDelay: 0,
processItem: async (item) => { /* handle */ },
})
queue.start()
queue.push('task-1')
```
### URL Normalization
| Export | Description |
|--------|-------------|
| `normalizeUrl(url, options?)` | Normalizes a URL string (ported from sindresorhus/normalize-url) |
| `stripProtocol(url)` | Removes the protocol prefix (`http://`, `wss://`, etc.) |
| `displayUrl(url)` | Strips protocol, `www.`, and trailing slash for display |
| `displayDomain(url)` | Extracts just the domain from a URL |
```typescript
import { normalizeUrl, displayUrl, displayDomain } from '@welshman/lib'
normalizeUrl('sindresorhus.com/about.html#contact', { stripHash: true })
// => 'http://sindresorhus.com/about.html'
displayUrl('https://www.example.com/path/') // => 'example.com/path'
displayDomain('relay.damus.io/path') // => 'relay.damus.io'
```
> **Note:** `normalizeUrl` defaults to `http://` protocol. Pass `{ defaultProtocol: 'https' }` if needed.
### Async Utilities
| Export | Description |
|--------|-------------|
| `sleep(ms)` | Returns a promise that resolves after `ms` milliseconds |
| `yieldThread()` | Yields to the event loop (microtask break) |
| `poll(options)` | Polls until a condition is met or an AbortSignal fires; options: `{ signal, condition, interval? }` |
| `throttle(ms, fn)` | Returns a throttled version of `fn` |
| `throttleWithValue(ms, fn)` | Throttled function that returns the cached return value between updates |
| `batch(t, fn)` | First call fires `fn([item])` immediately; subsequent calls within `t` ms are collected and `fn` is called with all accumulated items |
| `batcher(t, execute)` | Collects calls for `t` ms, then calls `execute` with all accumulated requests; each individual call returns a `Promise<U>` resolved with its result from the batch. Unlike `batch`, the first call is also deferred — nothing fires immediately. |
| `race(threshold, promises)` | Resolves when `threshold` fraction of promises complete |
### Timestamp / Time Constants
| Export | Description |
|--------|-------------|
| `MINUTE`, `HOUR`, `DAY`, `WEEK`, `MONTH`, `QUARTER`, `YEAR` | Duration constants **in seconds** |
| `LOCALE` | User's default locale string |
| `TIMEZONE` | User's timezone offset string (e.g. `+05:30`) |
| `now()` | Current Unix timestamp in seconds |
| `ago(unit, count?)` | Unix timestamp from `count` units ago — e.g. `ago(DAY, 7)` |
| `int(unit, count?)` | Multiplies a time unit by count — e.g. `int(HOUR, 2)` = 7200 |
| `ms(seconds)` | Converts seconds to milliseconds |
| `secondsToDate(ts)` / `dateToSeconds(date)` | Convert between Unix seconds and `Date` |
| `createLocalDate(dateString, timezone?)` | Parses a date string as a local date in the given timezone |
| `formatTimestamp(ts)` | Formats Unix seconds as a short datetime string |
| `formatTimestampAsDate(ts)` | Formats Unix seconds as a long date string |
| `formatTimestampAsTime(ts)` | Formats Unix seconds as a time string |
| `formatTimestampRelative(ts)` | Formats Unix seconds as "x minutes ago" |
> **Note:** All time constants are in **seconds**, not milliseconds. Use `ms(n)` to convert for `setTimeout`.
### Number Utilities
| Export | Description |
|--------|-------------|
| `ensureNumber(x)` | `parseFloat(x)` — accepts `string \| number` |
| `num(x)` | Returns `x \|\| 0` — converts `undefined` to 0 |
| `add(x, y)` / `sub(x, y)` / `mul(x, y)` / `div(x, y)` | Arithmetic with `undefined`-safe operands |
| `inc(x)` / `dec(x)` | Increment / decrement (undefined-safe) |
| `lt(x, y)` / `lte(x, y)` / `gt(x, y)` / `gte(x, y)` | Comparisons (undefined-safe) |
| `max(xs)` / `min(xs)` / `sum(xs)` / `avg(xs)` | Aggregates over `(number \| undefined)[]` |
| `between([low, high], n)` | `n > low && n < high` (exclusive) |
| `within([low, high], n)` | `n >= low && n <= high` (inclusive) |
| `clamp([min, max], n)` | Constrains `n` to the range |
| `round(precision, x)` | Rounds to `precision` decimal places |
### Array / Sequence Utilities
All return new arrays — no mutation.
| Export | Description |
|--------|-------------|
| `first(xs)` / `last(xs)` | First/last element (`undefined` if empty) |
| `ffirst(xs)` | First element of the first iterable in a nested iterable |
| `take(n, xs)` / `drop(n, xs)` | Slice from start / drop from start |
| `concat(...xs)` | Flattens vararg arrays into one, skipping any argument that is `undefined` |
| `append(x, xs)` / `prepend(x, xs)` | Add element to end / start |
| `remove(x, xs)` | Remove all occurrences of `x` |
| `removeAt(i, xs)` | Remove element at index `i` |
| `splitAt(n, xs)` | Split into `[xs.slice(0, n), xs.slice(n)]` |
| `insertAt(n, x, xs)` | Insert `x` at index `n` |
| `replaceAt(n, x, xs)` | Replace element at index `n` with `x` |
| `uniq(xs)` / `uniqBy(f, xs)` | Deduplicate |
| `sort(xs)` | Sorted copy (natural order) |
| `sortBy(f, xs)` | Sort by key function |
| `groupBy(f, xs)` | Returns `Map<K, T[]>` |
| `indexBy(f, xs)` | Returns `Map<K, T>` (last item wins per key) |
| `countBy(f, xs)` | Returns `Map<K, number>` |
| `partition(f, xs)` | Split into `[passing, failing]` |
| `chunk(n, xs)` | Split into fixed-size chunks of length `n` |
| `chunks(n, xs)` | Split into exactly `n` chunks |
| `toggle(x, xs)` | Add if absent, remove if present (pure) |
| `union(a, b)` / `intersection(a, b)` / `difference(a, b)` / `without(a, b)` | Set operations |
| `sample(n, xs)` / `shuffle(xs)` / `choice(xs)` | Random selection / shuffle / single random pick |
| `flatten(xs)` | Flatten one level |
| `ensurePlural(x)` | Wraps a value in `[x]` if it isn't already an array |
| `removeUndefined(xs)` | Filters out `undefined` values |
| `overlappingPairs(xs)` | Returns `[[xs[0],xs[1]], [xs[1],xs[2]], ...]` |
| `range(a, b, step?)` | Generator yielding numbers from `a` to `b` (exclusive) |
| `enumerate(xs)` | Generator yielding `[index, item]` tuples |
| `pluck<T>(k, xs)` | Maps `xs` to `xs[k]` |
| `fromPairs(pairs)` | Creates an object from `[key, value]` tuples |
| `initArray(n, f)` | Creates an array of length `n` using generator `f` |
| `isIterable(x)` / `toIterable(x)` | Check / wrap as iterable |
| `map(f, xs)` / `filter(f, xs)` / `reject(f, xs)` | Iterable-safe versions (accept any `Iterable<T>`) |
| `find(f, xs)` / `some(f, xs)` | Iterable-safe find / any-match |
### Object Utilities
| Export | Description |
|--------|-------------|
| `isPojo(obj)` | Returns `true` if value is a plain object (not class instance, null, or array) |
| `pick(keys, obj)` / `omit(keys, obj)` | Include / exclude keys |
| `omitVals(vals, obj)` | Remove entries whose value is in `vals` |
| `filterVals(f, obj)` | Keep entries where `f(value)` is truthy |
| `mapKeys(f, obj)` / `mapVals(f, obj)` | Transform keys or values |
| `mergeLeft(a, b)` / `mergeRight(a, b)` | Shallow merge — left/right wins on conflicts |
| `deepMergeLeft(a, b)` / `deepMergeRight(a, b)` | Deep merge — left/right wins on conflicts |
| `switcher(key, map)` | Lookup with implicit `map.default` fallback |
| `mapPop(k, m)` | Gets and deletes key from a `Map<K, T>` — returns `T \| undefined` |
> **Note:** `mergeLeft(a, b)` means `a` wins — it spreads `b` first, then `a` on top.
### TypeScript Utility Types
```typescript
import type { Override, MakeOptional, MakeNonOptional, Obj, Maybe, MaybeAsync } from '@welshman/lib'
type UserWithRole = Override<User, { role: 'admin' | 'user' }>
type DraftUser = MakeOptional<User, 'id' | 'createdAt'>
type FullUser = MakeNonOptional<User>
type AnyRecord = Obj // Record<string, any>
type MaybeStr = Maybe<string> // string | undefined
```
### Functional / Combinator Helpers
| Export | Description |
|--------|-------------|
| `noop` | No-op function |
| `identity(x)` | Returns `x` unchanged |
| `always(x)` | Returns a function that always returns `x` |
| `not(x)` | Logical NOT |
| `complement(f)` | Returns `(...args) => !f(...args)` |
| `tap(f)` | Returns `(x) => { f(x); return x }` — runs a side effect and passes the value through |
| `bind(f, ...args)` | Partially applies `f` with leading `args` |
| `equals(a, b)` | Deep equality (handles arrays, Sets, plain objects) |
| `tryCatch(f, onError?)` | Calls `f`, swallows errors, returns `undefined` on failure |
| `thrower(message)` | Returns a function that throws `new Error(message)` when called |
| `once(f)` | Wraps `f` so it only executes once |
| `memoize(f)` | Single-slot memoization: caches last call; re-runs when args change |
| `call(f)` | Calls `f()` immediately — IIFE alternative; useful with async |
| `ifLet(x, f)` | Calls `f(x)` only if `x` is defined |
| `doLet(x, f)` | Calls `f(x)` and returns the result — scoped binding without a variable |
| `isDefined(x)` / `isUndefined(x)` / `assertDefined(x)` | `undefined` checks (not null) |
### Curried Collection Helpers
Useful as `.filter()` / `.map()` callbacks:
| Export | Description |
|--------|-------------|
| `eq(v)` / `ne(v)` | `x => x === v` / `x => x !== v` |
| `prop(k)` | `x => x[k]` — pluck a property |
| `propIn(k, xs)` | `x => xs.includes(x[k])` — property is in list |
| `nth(i)` | `xs => xs[i]` — element at index |
| `nthEq(i, v)` | `xs => xs[i] === v` |
| `nthNe(i, v)` | `xs => xs[i] !== v` |
| `nthIn(i, vs)` | `xs => vs.includes(xs[i])` |
| `nthNotIn(i, vs)` | `xs => !vs.includes(xs[i])` |
| `spec(values)` | `x => all key-value pairs in values match x` |
| `member(xs)` | `x => xs.includes(x)` |
| `assoc(k, v)` | `obj => ({ ...obj, [k]: v })` — add/update property |
| `dissoc(k)` | `obj => omit([k], obj)` — remove property |
```typescript
import { eq, prop, nth, nthEq, nthIn, nthNotIn, spec, member, assoc, dissoc } from '@welshman/lib'
events.filter(spec({ kind: 1 })) // kind === 1
events.map(prop('id')) // pluck id
tags.filter(nthEq(0, 'p')) // tags where tag[0] === 'p'
tags.filter(nthIn(0, ['p', 'e'])) // tags where tag[0] is 'p' or 'e'
tags.filter(nthNotIn(0, ['p', 'e'])) // tags where tag[0] is neither
items.filter(member(['a', 'b'])) // items in the set
items.map(assoc('seen', true)) // add property
items.map(dissoc('secret')) // remove property
```
### Bech32 / Hex / Binary Encoding
| Export | Description |
|--------|-------------|
| `hexToBech32(prefix, hex)` | Encodes hex string to bech32 (e.g. `npub`, `note`) |
| `bech32ToHex(b32)` | Decodes bech32 to hex |
| `bytesToHex(buffer)` | `ArrayBuffer \| Uint8Array` to hex string |
| `hexToBytes(hex)` | Hex string to `Uint8Array` |
| `sha256(data)` | SHA-256 hash of binary data — async, returns hex string |
| `textEncoder` | Shared `TextEncoder` instance |
| `textDecoder` | Shared `TextDecoder` instance |
### JSON / Storage / Network
| Export | Description |
|--------|-------------|
| `parseJson(str)` | Safe `JSON.parse` — returns `undefined` on error or empty input |
| `getJson(key)` / `setJson(key, val)` | `localStorage` get/set with JSON serialization |
| `fetchJson(url, opts?)` | Fetch JSON with optional method/headers/body |
| `postJson(url, data, opts?)` | POST JSON to a URL |
| `uploadFile(url, file)` | Upload a `File` object via `multipart/form-data` POST |
| `on(target, event, cb)` | Type-safe `.on()` wrapper — returns an unsubscribe `() => void` |
### Randomness / IDs
| Export | Description |
|--------|-------------|
| `randomId()` | Generates a random string ID |
| `randomInt(min?, max?)` | Random integer in range (inclusive; default 09) |
### String Utilities
| Export | Description |
|--------|-------------|
| `ellipsize(s, len, suffix?)` | Truncates at word boundary with an ellipsis suffix (default `"..."`) |
| `displayList(xs, conj?, n?)` | Oxford-comma list — e.g. `"a, b, and c"` |
| `hash(s)` | Numeric hash from a string |
### Map / Set Helpers
```typescript
import { addToKey, pushToKey, addToMapKey, pushToMapKey } from '@welshman/lib'
// Object-keyed (Record<string, Set<T>> / Record<string, T[]>)
addToKey(byTag, 'p', pubkey) // adds to Set at key 'p'
pushToKey(byKind, '1', eventId) // appends to array at key '1'
// Map-keyed (Map<K, Set<T>> / Map<K, T[]>)
addToMapKey(m, relay, eventId)
pushToMapKey(m, relay, eventId)
```
## Common Patterns
### Pattern 1 — Batching writes (IndexedDB / relay)
`batch(t, f)` — first call fires immediately; subsequent calls within `t` ms are accumulated and flushed together.
```typescript
import { batch, on } from '@welshman/lib'
import type { RepositoryUpdate } from '@welshman/net'
on(
repository,
'update',
batch(3000, async (updates: RepositoryUpdate[]) => {
const toAdd = updates.flatMap(u => u.added)
const toRemove = new Set(updates.flatMap(u => u.removed))
const tx = db.transaction('events', 'readwrite')
await Promise.all([
...toAdd.map(e => tx.store.put(e)),
...Array.from(toRemove).map(id => tx.store.delete(id)),
tx.done,
])
}),
)
```
### Pattern 2 — Grouping and indexing nostr events
```typescript
import { groupBy, indexBy, sortBy } from '@welshman/lib'
const byKind = groupBy((e) => e.kind, events) // Map<number, Event[]>
const byId = indexBy((e) => e.id, events) // Map<string, Event>
const sorted = sortBy((e) => -e.created_at, events) // newest first
```
### Pattern 3 — Relative timestamp display
```typescript
import { now, ago, DAY, formatTimestampRelative } from '@welshman/lib'
const recentEvents = events.filter(e => e.created_at > ago(DAY, 7))
const label = formatTimestampRelative(event.created_at) // "3 hours ago"
```
### Pattern 4 — Subscribing to EventEmitter-based objects with `on`
`on(target, event, handler)` returns an unsubscribe function:
```typescript
import { on } from '@welshman/lib'
import { repository } from '@welshman/app'
const unsub = on(repository, 'update', updates => {
console.log('added', updates.flatMap(u => u.added).length, 'events')
})
// Later:
unsub()
```
### Pattern 5 — IIFE alternative with `call`
```typescript
import { call } from '@welshman/lib'
call(async () => {
const data = await fetchJson('/something')
})
```
## Integration Notes
- **All welshman packages depend on `@welshman/lib`** — `Deferred`, `Emitter`, time constants, and collection utilities are shared across `@welshman/net`, `@welshman/store`, `@welshman/util`, etc.
- **`@welshman/net`** uses `Emitter` (via `Tracker`, `Repository`, `WrapManager`), `batch`, and `LRUCache` internally. `Socket` extends Node's built-in `EventEmitter` directly.
- **`@welshman/app`** thunks use `Deferred<void>``thunk.complete` resolves when all relays have responded or timed out.
- **`batcher`** is used in `@welshman/net` for deduplicating concurrent fetch requests — pass it an `execute` function that returns results in the same order as its inputs.
+442
View File
@@ -0,0 +1,442 @@
---
name: welshman-net
description: "Use this skill when working with @welshman/net: relay connections, request/publish flows, auth, relay pool management, adapters, policies, or low-level nostr network I/O."
---
# welshman/net — Relay Network Layer
`@welshman/net` is the core networking layer for welshman-based nostr apps. It manages WebSocket relay connections, subscriptions, event publishing, NIP-42 auth, and NIP-77 negentropy sync. It sits below `@welshman/app` (which provides higher-level reactive stores and routing) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities.
## Installation
```bash
npm install @welshman/net
# or
pnpm add @welshman/net
yarn add @welshman/net
```
## Key Exports
### Pool & Sockets
| Export | Description |
|--------|-------------|
| `Pool` | Singleton connection pool; creates and manages `Socket` instances per relay URL |
| `Pool.get()` | Returns the singleton `Pool` instance |
| `pool.get(url)` | Gets or lazily creates a `Socket` for the given relay URL |
| `pool.remove(url)` | Removes and cleans up a socket |
| `pool.subscribe(cb)` | Fires `cb(socket)` each time a new socket is created; returns unsubscriber |
| `Socket` | WebSocket wrapper with status tracking, send queue, and auth state |
| `SocketStatus` | Enum: `Open`, `Opening`, `Closing`, `Closed`, `Error` |
| `SocketEvent` | Enum: `Status`, `Send`, `Sending`, `Receive`, `Receiving`, `Error` |
| `socket.auth` | `AuthState` instance for NIP-42 on this connection |
### Request
| Export | Description |
|--------|-------------|
| `requestOne(options)` | Subscribe to a single relay; returns `Promise<TrustedEvent[]>` |
| `request(options)` | Subscribe to multiple relays in parallel; returns `Promise<TrustedEvent[]>` |
| `makeLoader(options)` | Creates a batching `load` function with configurable delay/timeout/threshold |
| `load(options)` | Pre-built loader: 200 ms batch delay, 3 s timeout, 0.5 threshold. Simpler than `request()` when you just want events — auto-closes after EOSE, timeout, or disconnect; resolves when half the relays' subscriptions have closed; returns a `Promise<TrustedEvent[]>`. When used with `@welshman/app`, received events auto-flow into the repository and tracker. |
`request` / `requestOne` options (key fields):
- `relay` / `relays` — relay URL(s)
- `filters` — array of nostr `Filter` objects
- `autoClose?: boolean` — close subscription after EOSE or on socket disconnect
- `signal?: AbortSignal` — cancellation
- `tracker?: Tracker` — cross-relay deduplication (shared automatically by `request`)
- Callbacks: `onEvent(event, url)`, `onEose(url)`, `onClose()`, `onDisconnect(url)`, `onFiltered`, `onDuplicate`, `onDeleted`, `onInvalid`, `onClosed(reason, url)`
`request`-only options:
- `threshold?: number` — fraction of relays that must close before the promise resolves (default `1`)
Without `autoClose` or a `signal`, `requestOne` streams indefinitely — the returned promise only resolves if the relay sends CLOSED for all active subscription IDs. Default policies also re-send the REQ when sockets reconnect.
### Publish
| Export | Description |
|--------|-------------|
| `publish(options)` | Publishes to multiple relays; resolves to `PublishResultsByRelay` |
| `publishOne(options)` | Publishes to a single relay; resolves to `PublishResult` |
| `PublishStatus` | Enum: `Sending`, `Pending`, `Success`, `Failure`, `Timeout`, `Aborted` |
| `PublishResult` | `{ relay: string, status: PublishStatus, detail: string }` |
| `PublishResultsByRelay` | `Record<string, PublishResult>` |
`publish` options: `event`, `relays`, `timeout?` (default 10 s), `signal?`, `context?`, plus callbacks `onSuccess`, `onFailure`, `onPending`, `onTimeout`, `onAborted`, `onComplete`.
### Auth (NIP-42)
| Export | Description |
|--------|-------------|
| `AuthState` | Manages auth state for one socket; available as `socket.auth` |
| `AuthStatus` | Enum: `None`, `Requested`, `PendingSignature`, `DeniedSignature`, `PendingResponse`, `Forbidden`, `Ok` |
| `AuthStateEvent.Status` | Emitted when auth status changes |
| `makeSocketPolicyAuth(options)` | Creates a socket policy that auto-handles auth challenges |
| `defaultSocketPolicies` | Mutable array of policies applied to every new socket |
### Policies
| Export | Description |
|--------|-------------|
| `socketPolicyPing` | Sends a PING frame every 30 s when the socket is open and idle, to keep the connection alive |
| `socketPolicyAuthBuffer` | Buffers outgoing messages during auth and replays after success |
| `socketPolicyConnectOnSend` | Auto-opens closed sockets when a message is queued |
| `socketPolicyCloseInactive` | Closes idle sockets after 30 s (when no pending work remains); if the socket closes with pending work it delays and reopens, replaying queued messages |
| `defaultSocketPolicies` | Array of the four above; passed to every socket created by `Pool` |
A `SocketPolicy` is `(socket: Socket) => Unsubscriber`.
### Repository
| Export | Description |
|--------|-------------|
| `Repository` | In-memory indexed event store with delete/expiry support |
| `Repository.get()` | Returns the singleton instance |
| `repository.publish(event)` | Stores an event; returns `false` if duplicate/stale |
| `repository.query(filters, opts?)` | Returns matching `TrustedEvent[]` sorted by `created_at` desc |
| `repository.getEvent(idOrAddress)` | Look up by id or NIP-01 address (`kind:pubkey:d`) |
| `repository.isDeleted(event)` | `true` if a kind-5 delete covers this event |
| `repository.dump()` | Returns all stored events as `TrustedEvent[]` |
| `repository.load(events)` | Bulk-replaces all stored events; emits a single `"update"` diff. Events with `event[verifiedSymbol] = true` skip signature re-verification. |
| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — conventional URL for the local repository |
| `RepositoryUpdate` | `{ added: TrustedEvent[], removed: Set<string> }` — payload of `"update"` events |
| `mergeRepositoryUpdates(updates)` | Merges an array of `RepositoryUpdate` objects into one |
Emits `"update"` with `RepositoryUpdate` (`{ added: TrustedEvent[], removed: Set<string> }`) on every change.
> **Prefer `LOCAL_RELAY_URL` over direct repository access.** Rather than calling `repository.query()` or `repository.publish()` directly, pass `LOCAL_RELAY_URL` as a relay URL to the standard `load()`, `request()`, and `publish()` functions. This keeps local reads/writes going through the same policy, deduplication, and tracking pipeline as remote relay operations. Direct repository access is appropriate only for bulk startup (`repository.load()`) and low-level introspection (`repository.getEvent()`, `repository.isDeleted()`).
### Tracker
| Export | Description |
|--------|-------------|
| `Tracker` | Bidirectional map of `eventId ↔ Set<relayUrl>` |
| `tracker.track(eventId, relay)` | Records relay; returns `true` if the event was already seen |
| `tracker.getRelays(eventId)` | Set of relay URLs that have sent this event |
| `tracker.getIds(relay)` | Set of event ids seen from a relay |
| `tracker.copy(id1, id2)` | Copies relay associations from one id to another (used for gift wraps) |
| `tracker.load(relaysById)` | Bulk-replaces all relay mappings from a `Map<string, Set<string>>`; emits `"load"` |
| `tracker.clear()` | Removes all relay mappings; emits `"clear"` |
### Adapters
| Export | Description |
|--------|-------------|
| `getAdapter(url, context?)` | Factory: returns `SocketAdapter`, `LocalAdapter`, or custom adapter |
| `SocketAdapter` | WebSocket relay adapter |
| `LocalAdapter` | In-memory relay adapter |
| `MockAdapter` | Test adapter with manual send control |
| `AbstractAdapter` | Base class for custom adapters |
| `AdapterEvent.Receive` | Emitted when a relay message arrives |
### Context
| Export | Description |
|--------|-------------|
| `netContext` | Global `NetContext` config object |
| `NetContext` | `{ pool, repository, isEventValid, isEventDeleted, getAdapter? }` |
Mutate `netContext` fields directly to change global defaults; pass `context` to individual calls to override per-request.
### Negentropy / Diff (NIP-77)
| Export | Description |
|--------|-------------|
| `diff(options)` | Compares local events against relays; returns `{ relay, have, need }[]` |
| `pull(options)` | Fetches events relays have that you don't |
| `push(options)` | Publishes events you have that relays don't |
| `Difference` | Low-level per-relay negentropy session |
### Messages
| Export | Description |
|--------|-------------|
| `RelayMessageType` | Enum of relay→client message types |
| `ClientMessageType` | Enum of client→relay message types |
| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, etc. | Type guards for relay messages |
| `isClientReq()`, `isClientEvent()`, etc. | Type guards for client messages |
### WrapManager
| Export | Description |
|--------|-------------|
| `WrapManager` | Tracks NIP-59 gift wrap → rumor relationships; stores decrypted rumors in the repository and copies relay tracking from the wrap to the rumor |
---
## Common Patterns
### Connect to a relay and stream events
```typescript
import {Pool, SocketEvent, SocketStatus} from '@welshman/net'
const pool = Pool.get()
const socket = pool.get('wss://relay.example.com')
socket.on(SocketEvent.Status, (status: SocketStatus) => {
console.log('status:', status)
})
// Send REQ directly (prefer request() for higher-level use)
socket.send(['REQ', 'my-sub', {kinds: [1], limit: 10}])
```
### Load events (one-shot, batched)
```typescript
import {load} from '@welshman/net'
// load() batches multiple concurrent calls within 200 ms into a single REQ per relay.
// It auto-closes after EOSE, timeout, or disconnect, and resolves at 50 % relay threshold.
const events = await load({
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
filters: [{kinds: [0], authors: ['<pubkey>']}],
})
```
### Stream events indefinitely
```typescript
import {request} from '@welshman/net'
import {now} from '@welshman/lib'
// Without autoClose this will stream forever.
// The returned promise never settles unless all relays close the subscription.
const ctrl = new AbortController()
request({
relays: ['wss://relay.example.com'],
filters: [{kinds: [1], since: now()}],
signal: ctrl.signal,
onEvent: (event, url) => console.log(event.id, 'from', url),
})
// Later:
ctrl.abort()
```
### Publish an event
```typescript
import {publish, PublishStatus} from '@welshman/net'
const results = await publish({
event: signedEvent,
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
timeout: 5000,
onSuccess: r => console.log('accepted by', r.relay),
onFailure: r => console.warn('rejected by', r.relay, r.detail),
})
for (const [relay, result] of Object.entries(results)) {
if (result.status === PublishStatus.Success) {
console.log(relay, 'ok')
}
}
```
### Enable NIP-42 auth globally
```typescript
import {defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net'
import type {StampedEvent} from '@welshman/util'
// Call once at app startup, before any sockets are opened.
defaultSocketPolicies.push(
makeSocketPolicyAuth({
sign: (event: StampedEvent) => mySigner.sign(event),
shouldAuth: (socket) => true, // auth on every relay
}),
)
```
### Custom socket policies
A `SocketPolicy` is `(socket: Socket) => Unsubscriber`. It receives the socket when it is created, attaches listeners or patches socket methods, and returns a cleanup function. Push custom policies onto `defaultSocketPolicies` before any sockets are opened.
```typescript
import {writable} from 'svelte/store'
import {on} from '@welshman/lib'
import {defaultSocketPolicies, SocketEvent, isRelayEvent} from '@welshman/net'
import type {Socket, RelayMessage} from '@welshman/net'
// Track how many events each relay has delivered this session
export const eventCountByRelay = writable<Record<string, number>>({})
const eventCountPolicy = (socket: Socket) => {
const unsub = on(socket, SocketEvent.Receive, (message: RelayMessage) => {
if (isRelayEvent(message)) {
eventCountByRelay.update(counts => ({
...counts,
[socket.url]: (counts[socket.url] ?? 0) + 1,
}))
}
})
return unsub // called when the socket is destroyed
}
defaultSocketPolicies.push(eventCountPolicy)
```
The same structure applies to more advanced patterns — patch `socket.open` to block connections, listen to `SocketEvent.Sending`/`SocketEvent.Receiving` to intercept messages before they are processed, or manipulate `socket._recvQueue` directly to suppress or replay messages.
### Custom adapter (e.g. non-WebSocket backend)
```typescript
import {AbstractAdapter, AdapterEvent, request} from '@welshman/net'
import type {ClientMessage} from '@welshman/net'
class MyAdapter extends AbstractAdapter {
constructor(private url: string) {
super()
// set up your transport here
}
get urls() { return [this.url] }
get sockets() { return [] }
send(message: ClientMessage) {
// forward message to your backend; call this.emit(AdapterEvent.Receive, replyMsg, this.url) when data arrives
}
}
request({
relays: ['myscheme://some-id'],
filters: [{kinds: [1]}],
autoClose: true,
context: {
getAdapter: (url) => url.startsWith('myscheme://') ? new MyAdapter(url) : undefined,
},
})
```
### Use LOCAL_RELAY_URL to read/write the local repository
Pass `LOCAL_RELAY_URL` as a relay to the standard net functions so local operations go through the same pipeline as remote ones (policies, deduplication, tracker):
```typescript
import {load, publish, request, LOCAL_RELAY_URL} from '@welshman/net'
import {now} from '@welshman/lib'
// Read from the local repository the same way you'd read from a remote relay
const events = await load({
relays: [LOCAL_RELAY_URL],
filters: [{kinds: [1], authors: ['<pubkey>'], limit: 20}],
})
// Write to the local repository (and any remote relays) in one call
await publish({
event: signedEvent,
relays: [LOCAL_RELAY_URL, 'wss://relay.example.com'],
})
// Subscribe to new local events in real time
request({
relays: [LOCAL_RELAY_URL],
filters: [{kinds: [1], since: now()}],
onEvent: (event) => console.log('new local event', event.id),
})
```
Direct `repository` API calls (`repository.load()`, `repository.getEvent()`, `repository.isDeleted()`, `repository.dump()`) are still appropriate for bulk startup and low-level introspection — but for routine reads and writes prefer `LOCAL_RELAY_URL`.
### Startup: bulk-load persisted events (skip re-verification)
```typescript
import {Repository} from '@welshman/net'
import {verifiedSymbol} from '@welshman/util'
import type {TrustedEvent} from '@welshman/util'
const repo = Repository.get()
// Mark events as already-verified so welshman skips signature checks
const storedEvents: TrustedEvent[] = await loadFromStorage()
for (const event of storedEvents) {
event[verifiedSymbol] = true
}
// Replaces all in-memory events in one pass; emits a single "update"
repo.load(storedEvents)
```
### Startup: bulk-load Tracker state
```typescript
import {tracker} from '@welshman/app' // singleton wired to the pool and repository
// Build the map from your stored relay<->event mappings
const relaysById = new Map<string, Set<string>>()
for (const {id, relays} of storedTrackerItems) {
if (repo.getEvent(id)) { // skip orphaned entries
relaysById.set(id, new Set(relays))
}
}
// Takes Map<string, Set<string>> — same shape as tracker.relaysById
tracker.load(relaysById)
```
### Persist repository changes to IndexedDB (canonical pattern)
```typescript
import {on, batch} from '@welshman/lib'
import {repository} from '@welshman/app' // singleton; or Repository.get() standalone
import type {RepositoryUpdate} from '@welshman/net'
import type {TrustedEvent} from '@welshman/util'
// batch(ms, fn) collects all "update" events fired within `ms` and calls fn once
on(
repository,
'update',
batch(3000, async (updates: RepositoryUpdate[]) => {
const toAdd: TrustedEvent[] = []
const toRemove = new Set<string>()
for (const {added, removed} of updates) {
for (const event of added) toAdd.push(event)
for (const id of removed) toRemove.add(id)
}
const tx = db.transaction('events', 'readwrite')
await Promise.all([
...toAdd.map(e => tx.store.put(e)),
...Array.from(toRemove).map(id => tx.store.delete(id)),
tx.done,
])
}),
)
```
---
## Integration Notes
- **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent` (already verified).
- **`@welshman/lib`** — utility helpers (`Emitter`, `batcher`, `defer`, `on`, etc.) used internally; `Emitter` (from `@welshman/lib`) is the base class for `Tracker`, `Repository`, and `WrapManager`. `Socket`, `AuthState`, `AbstractAdapter`, and `Difference` extend node's built-in `EventEmitter` directly.
- **`@welshman/app`** — wraps `@welshman/net` with reactive Svelte stores, a router, and higher-level helpers. Most app-level code should use `@welshman/app`; drop down to `@welshman/net` only for raw relay I/O or when building non-Svelte clients.
- **`netContext`** — shared singleton used as the default by `request`, `requestOne`, and the repository. Override fields on `netContext` at startup, or pass a `context` object per-call to isolate behavior.
---
## Gotchas & Tips
- **Use `LOCAL_RELAY_URL`, not direct repository calls, for routine reads/writes.** Passing `LOCAL_RELAY_URL` to `load()`, `publish()`, or `request()` routes through the normal net pipeline (policies, deduplication, tracker). Calling `repository.query()` / `repository.publish()` directly bypasses all of that. Reserve the direct API for bulk startup (`repository.load()`), introspection (`getEvent`, `isDeleted`, `dump`), and listening to `"update"` events.
- **`request()` without `autoClose` or `signal` never resolves.** Always pass `autoClose: true` or an `AbortSignal` when you just want a one-shot fetch. Use `load()` for the common case.
- **`load()` sets `autoClose: true` internally** and uses a 0.5 relay threshold; it resolves when half the relays' subscriptions have closed (typically after EOSE, timeout, or disconnect) — useful when some relays are slow or offline.
- **Relay URL normalization** happens inside `Pool.get(url)` via `normalizeRelayUrl`. Pass raw URLs everywhere; the pool handles canonicalization.
- **`defaultSocketPolicies` is mutable.** Push policies before any sockets are created. Sockets created before a policy is pushed will not have it applied.
- **`socketPolicyCloseInactive` only replays pending work on unexpected close.** It reopens and replays queued messages when a socket closes while work is pending — it does not proactively open sockets when new work is queued (that is `socketPolicyConnectOnSend`'s job). After `pool.remove(url)` the socket is cleaned up including its policy listeners, so `socketPolicyCloseInactive` can no longer reopen it.
- **`Pool.get(url)` lazily creates a new socket on every call after `pool.remove(url)`.** Calling `pool.remove(url)` forgets the URL and cleans up the socket — any subsequent `pool.get(url)` will construct a fresh socket. Call `pool.remove()` only when you want the pool to forget the URL entirely, not merely to disconnect temporarily.
- **`Tracker` is shared across relays in `request()`.** This means `onDuplicate` fires for events received from more than one relay — expected behavior for cross-relay deduplication.
- **`Repository.publish()` returns `false` for stale replaceable events.** If a newer version of a replaceable event is already stored, the older one is silently dropped.
- **`WrapManager` stores the decrypted rumor in the `Repository`** and copies relay tracking from the gift-wrap event id to the rumor id. Keep a reference to the `WrapManager` instance alongside your `Repository` and `Tracker` singletons.
- **`makeSocketPolicyAuth` requires a `sign` function** that returns a `Promise<SignedEvent>`. If the user cancels signing, have the `sign` function throw or reject; `doAuth` will catch the failure via `tryCatch` and automatically transition to `AuthStatus.DeniedSignature`, preventing infinite retry loops.
- **Each filter in `filters` array generates a separate REQ** inside `requestOne`. For large filter arrays consider merging them with `unionFilters` from `@welshman/util` before calling `request`.
- **`repository.load()` replaces all events, not appends.** It clears internal indexes first, then re-inserts every event. Emit a single batched `"update"` diff — do not call it repeatedly for incremental updates; use `repository.publish(event)` for that.
- **`RepositoryUpdate.removed` is `Set<string>`, not an array.** Iterate with `for...of` or `Array.from(removed)`. The `batch()` helper from `@welshman/lib` delivers updates as `RepositoryUpdate[]` to your flush callback — merge them yourself or use `mergeRepositoryUpdates`.
- **`tracker.load()` takes `Map<string, Set<string>>`** (the same type as `tracker.relaysById`). Load it after `repository.load()` so you can filter out orphaned event ids.
+291
View File
@@ -0,0 +1,291 @@
---
name: welshman-router
description: "Use this skill when working with @welshman/router: relay selection, routing strategies, scenario-based relay routing, or choosing which relays to use for reads/writes."
---
# welshman/router — Relay Selection
`@welshman/router` provides scenario-based relay selection for nostr clients. It answers the question "which relays should I use for this operation?" by scoring candidate relays based on pubkey relay lists, relay quality, and configurable fallback policies. It sits between `@welshman/util` (types/helpers) and `@welshman/net` (actual relay connections), and is wrapped by `@welshman/app` for full-stack usage.
## Installation
```bash
npm install @welshman/router
# or
pnpm add @welshman/router
yarn add @welshman/router
```
Peer dependencies: `@welshman/lib`, `@welshman/net`, `@welshman/util`.
## Key Exports
### Router (class)
The main entry point. Use as a singleton via `Router.configure()` + `Router.get()`, or instantiate directly with options.
| Method | Description |
|--------|-------------|
| `Router.configure(options)` | Merge options into the global `routerContext` |
| `Router.get()` | Return a `Router` instance using the global context |
| `new Router(options)` | Create a router that overrides specific options from the global context |
**RouterOptions** (all optional):
| Option | Signature | Description |
|--------|-----------|-------------|
| `getUserPubkey` | `() => string \| undefined` | Current user's pubkey |
| `getPubkeyRelays` | `(pubkey, mode?) => string[]` | Relays for a pubkey; `mode` is `"read"`, `"write"`, or `"messaging"` |
| `getDefaultRelays` | `() => string[]` | Fallback relays of last resort |
| `getIndexerRelays` | `() => string[]` | Relays that index profiles and relay lists (NIP-65) |
| `getSearchRelays` | `() => string[]` | Relays supporting NIP-50 search |
| `getRelayQuality` | `(url) => number` | Quality score 01 for a relay (affects selection ranking) |
| `getLimit` | `() => number` | Max relays returned by `getUrls()` (default: 3) |
**Default behavior:** if `getPubkeyRelays` is not configured, the router falls back to querying the local `Repository` (from `@welshman/net`) for kind-10002 events.
### Router Scenario Methods
All return a `RouterScenario`. Naming convention: `For*` = relays to write to (so others can read), `From*` = relays to read from (author's outbox).
| Method | Description |
|--------|-------------|
| `FromRelays(relays)` | Use an explicit list of relay URLs |
| `ForUser()` | User's read relays (where others can send things to the user) |
| `FromUser()` | User's write relays (user's outbox) |
| `MessagesForUser()` | User's messaging relays (NIP-17 DMs) |
| `ForPubkey(pubkey)` | A pubkey's read relays |
| `FromPubkey(pubkey)` | A pubkey's write relays (outbox) |
| `MessagesForPubkey(pubkey)` | A pubkey's messaging relays |
| `ForPubkeys(pubkeys)` | Merged read relays for multiple pubkeys |
| `FromPubkeys(pubkeys)` | Merged write relays for multiple pubkeys |
| `MessagesForPubkeys(pubkeys)` | Merged messaging relays for multiple pubkeys |
| `Event(event)` | Event author's write relays (where the event lives) |
| `Replies(event)` | Event author's read relays (where replies should be sent) |
| `PublishEvent(event)` | Author's outbox + mentioned pubkeys' read relays; hard-limits to 30 |
| `Quote(event, id, hints)` | Best relays to find a quoted event; checks tag relay hints and author pubkey from tag |
| `EventParents(event)` | Relays for fetching parent events (from ancestor tags + mentioned pubkeys) |
| `EventRoots(event)` | Relays for fetching root events |
| `Search()` | Search relays |
| `Index()` | Indexer relays |
| `Default()` | Default/fallback relays |
| `merge(scenarios)` | Combine multiple scenarios into one |
### RouterScenario (class)
Immutable builder — every builder method returns a new instance. Terminal methods (`getUrls()`, `getUrl()`) return relay URLs, not instances.
| Method | Description |
|--------|-------------|
| `getUrls()` | Execute selection; returns `string[]` |
| `getUrl()` | Returns the first selected URL or `undefined` |
| `limit(n)` | Override max relay count for this scenario |
| `weight(scale)` | Multiply all selection weights by `scale` |
| `policy(fn)` | Set fallback policy |
| `allowLocal(bool)` | Allow `ws://localhost` / `ws://127.*` URLs (default: false) |
| `allowOnion(bool)` | Allow `.onion` URLs (default: false) |
| `allowInsecure(bool)` | Allow plain `ws://` non-onion URLs (default: false) |
| `filter(fn)` | Filter the internal `Selection[]` |
| `update(fn)` | Map over the internal `Selection[]` |
### Fallback Policies
Applied after relay scoring when not enough relays are found. Draw from `getDefaultRelays`.
| Export | Behavior |
|--------|----------|
| `addNoFallbacks` | Never add fallbacks (default) |
| `addMinimalFallbacks` | Add 1 fallback only if zero relays were selected |
| `addMaximalFallbacks` | Fill remaining slots up to the limit |
### Filter Selection
| Export | Description |
|--------|-------------|
| `getFilterSelections(filters, rules?)` | Returns `RelaysAndFilters[]` — optimized relay+filter combos for a subscription |
| `RelaysAndFilters` | `{ relays: string[], filters: Filter[] }` |
| `defaultFilterSelectionRules` | The default ordered rule array |
| `getFilterSelectionsForSearch` | Rule: search filters → search relays (weight 10) |
| `getFilterSelectionsForWraps` | Rule: kind-1059 wraps without authors → user messaging relays |
| `getFilterSelectionsForIndexedKinds` | Rule: kinds 0/3/10002/10050 → indexer relays |
| `getFilterSelectionsForAuthors` | Rule: author filters → each author's outbox (split into up to 30 chunks) |
| `getFilterSelectionsForUser` | Rule: low-weight (0.2) baseline that always fires for every filter → user's read relays. It is not conditional on other rules failing. |
### Other Exports
| Export | Description |
|--------|-------------|
| `INDEXED_KINDS` | `[PROFILE, RELAYS, MESSAGING_RELAYS, FOLLOWS]` — kinds routed to indexers |
| `makeSelection(relays, weight?)` | Create a `Selection` object; validates and normalizes URLs |
| `Selection` | `{ weight: number, relays: string[] }` |
| `FallbackPolicy` | `(count: number, limit: number) => number` |
| `routerContext` | The global mutable options object updated by `Router.configure()` |
## Common Patterns
### 1. Configure once at app startup
`Router.get()` is the primary entry point — it returns a `Router` instance using the global `routerContext`. Call `Router.configure()` once at startup to set options, or assign directly to `routerContext` for individual overrides.
```typescript
import {Router} from '@welshman/router'
Router.configure({
getUserPubkey: () => myStore.userPubkey,
getPubkeyRelays: (pubkey, mode) => myStore.getRelaysForPubkey(pubkey, mode),
getDefaultRelays: () => ['wss://relay.example.com/', 'wss://relay2.example.com/'],
getIndexerRelays: () => ['wss://indexer.example.com/', 'wss://indexer2.example.com/'],
getSearchRelays: () => ['wss://search.example.com/', 'wss://search2.example.com/'],
getRelayQuality: (url) => myStore.getRelayQuality(url),
getLimit: () => 5,
})
```
When using `@welshman/app`, it pre-configures `Router` automatically. The two most common customization points when using `@welshman/app` are `getDefaultRelays` and `getIndexerRelays`, which you can assign directly on `routerContext`:
```typescript
import {routerContext} from '@welshman/router'
routerContext.getDefaultRelays = () => [
'wss://relay.example.com/',
'wss://relay2.example.com/',
]
routerContext.getIndexerRelays = () => [
'wss://indexer.example.com/',
'wss://indexer2.example.com/',
]
```
### 2. Fetch events from specific pubkeys
```typescript
import {Router} from '@welshman/router'
const relays = Router.get().FromPubkeys(['pubkey1', 'pubkey2']).getUrls()
// relays is string[] — pass to your subscription
```
### 3. Publish an event
```typescript
import {Router} from '@welshman/router'
import type {TrustedEvent} from '@welshman/util'
function getPublishRelays(event: TrustedEvent): string[] {
return Router.get().PublishEvent(event).getUrls()
// Automatically includes author's outbox + mentioned pubkeys' read relays
// Hard-limited to 30 relays for deliverability
}
```
### 4. Find a quoted/referenced event with fallbacks
```typescript
import {Router, addMaximalFallbacks} from '@welshman/router'
import type {TrustedEvent} from '@welshman/util'
function getQuoteRelays(event: TrustedEvent, quotedId: string, hints: string[]) {
return Router.get()
.Quote(event, quotedId, hints)
.policy(addMaximalFallbacks)
.limit(8)
.getUrls()
}
```
### 5. Common scenario cheat-sheet
```typescript
import {Router} from '@welshman/router'
const router = Router.get()
// Read relays for the current user (where others deliver events to you)
router.ForUser().getUrls()
// Write relays for the current user (your outbox)
router.FromUser().getUrls()
// Best relays to deliver an event to a pubkey (their inbox)
router.ForPubkey('pubkey').getUrls()
// Best relays to fetch events authored by a pubkey (their outbox)
router.FromPubkey('pubkey').getUrls()
// Indexer relays (profiles, relay lists)
router.Index().getUrls()
// Cap relay count for this scenario only
router.ForPubkey('pubkey').limit(3).getUrls()
// Merge multiple scenarios; relay URLs are deduplicated when getUrls() is called
router.merge([
router.FromUser(),
router.Index(),
]).getUrls()
```
### 6. Build subscriptions with getFilterSelections
```typescript
import {getFilterSelections} from '@welshman/router'
import type {Filter} from '@welshman/util'
const filters: Filter[] = [
{kinds: [1], authors: ['pubkey1', 'pubkey2']},
{kinds: [0], search: 'bitcoin'},
]
for (const {relays, filters} of getFilterSelections(filters)) {
// Open one subscription per relay group
myPool.subscribe(relays, filters)
}
```
### 7. Use a custom filter routing rule
```typescript
import {
Router,
getFilterSelections,
defaultFilterSelectionRules,
type RelaysAndFilters,
} from '@welshman/router'
import type {Filter} from '@welshman/util'
// Add a rule that sends kind-1 to a dedicated relay
const myRule = (filter: Filter) => {
if (!filter.kinds?.includes(1)) return []
return [{filter, scenario: Router.get().FromRelays(['wss://notes.example.com/'])}]
}
const selections = getFilterSelections(filters, [myRule, ...defaultFilterSelectionRules])
```
## Integration Notes
- **`@welshman/util`** — Router imports `TrustedEvent`, `Filter`, `RelayMode`, `PROFILE`, `RELAYS`, `MESSAGING_RELAYS`, `FOLLOWS`, `WRAP`, `normalizeRelayUrl`, and tag-parsing helpers. All relay URLs are normalized with `normalizeRelayUrl` and validated with `isRelayUrl` before use.
- **`@welshman/net`** — The default `getPubkeyRelays` implementation queries `Repository.get()` (the in-memory event store from `@welshman/net`) for kind-10002 events. Override it if you maintain relay lists elsewhere.
- **`@welshman/app`** — The app layer pre-configures `Router` using its own stores (relay lists, connection quality). If you use `@welshman/app`, call `Router.configure` only to override specific options; the app layer handles the rest.
- **`@welshman/lib`** — Used internally for utilities (`sortBy`, `shuffle`, `uniq`, etc.); no direct integration needed.
## Gotchas & Tips
- **Relay list events must be in the Repository for pubkey routing to work.** The default `getPubkeyRelays` implementation reads kind-10002 (NIP-65) relay list events from the global in-memory `Repository`. If those events haven't been loaded — either from a local cache at startup or fetched from the network — the Router has no relay list data for that pubkey and silently falls back to default/indexer relays. When using `@welshman/app`, relay lists are fetched automatically as part of profile loading (`loadRelayList`, `makeOutboxLoader`). Without `@welshman/app`, you must fetch and load them yourself before calling pubkey-based scenarios.
- **`For*` vs `From*`**: `ForPubkey` returns a pubkey's **read** relays (where you send things for that pubkey to receive); `FromPubkey` returns their **write** relays (their outbox, where their events live). Use `From*` to fetch events, `For*` to deliver events.
- **Default limit is 3.** Set `getLimit` in `Router.configure` or call `.limit(n)` on a scenario if you need more. `PublishEvent` unconditionally overrides to 30.
- **Scoring includes randomness.** `getUrls()` introduces `Math.random()` in the scoring formula so that lower-quality or less-popular relays get occasional selection. Results are not deterministic across calls.
- **`addNoFallbacks` is the default policy.** If no relays are found for a scenario (e.g. no relay list for a pubkey) and you haven't set a policy, `getUrls()` returns `[]`. Use `addMinimalFallbacks` or `addMaximalFallbacks` when you need a result even for unknown pubkeys.
- **Insecure `ws://` URLs are filtered by default.** Only onion addresses (`*.onion`) are exempt from the TLS requirement. Pass `.allowInsecure(true)` to a scenario if you need to support plain websocket relays (e.g. local dev).
- **`getFilterSelections` uses `addMinimalFallbacks`.** Each resulting relay group will have at least one relay *if* `getDefaultRelays` is configured and returns relays. If `getDefaultRelays` is not configured or returns an empty array, the group may still be empty.
- **`routerContext` is a shared mutable object.** `Router.configure()` mutates it in place with `Object.assign`. `new Router(options)` merges the supplied options *over* the global `routerContext` (via `mergeLeft`), so any options not provided in `options` still fall back to whatever is in `routerContext`. For true isolation (e.g. in tests), pass a complete options object or reset `routerContext` first.
- **`Quote` reads relay hints from event tags.** It looks for a tag whose second element (`t[1]`) matches the quoted event ID, then extracts a relay hint from `t[2]` and an author pubkey from `t[3]`. Standard NIP-21/NIP-10 tag format.
+225
View File
@@ -0,0 +1,225 @@
---
name: welshman-signer
description: "Use this skill when working with @welshman/signer: nostr signing, login methods (NIP-07, NIP-46, NIP-55, NIP-59, NIP-01), ISigner interface, or encrypted events."
---
# welshman/signer — Signing & Login
## Overview
`@welshman/signer` provides a unified `ISigner` interface and concrete implementations for every major nostr signing method: local keypair (NIP-01), browser extension (NIP-07), remote bunker/Nostr Connect (NIP-46), native mobile app via Capacitor (NIP-55), and Gift Wrap encryption (NIP-59). All signers share the same API surface, so callers can swap signing methods without changing application logic. This package depends on `@welshman/util`, `@welshman/lib`, and (for NIP-46) `@welshman/net`. It has no dependency on `@welshman/app`.
## Installation
```bash
npm install @welshman/signer
# or
pnpm add @welshman/signer
yarn add @welshman/signer
```
## Key Exports
### ISigner Interface
The common contract all signers implement.
```typescript
import type { ISigner, SignOptions, SignWithOptions } from '@welshman/signer'
interface ISigner {
sign: (event: StampedEvent, options?: SignOptions) => Promise<SignedEvent>
getPubkey: () => Promise<string>
nip04: {
encrypt: (pubkey: string, message: string) => Promise<string>
decrypt: (pubkey: string, message: string) => Promise<string>
}
nip44: {
encrypt: (pubkey: string, message: string) => Promise<string>
decrypt: (pubkey: string, message: string) => Promise<string>
}
cleanup?: () => Promise<void>
}
type SignOptions = { signal?: AbortSignal }
```
### Nip01Signer (local keypair)
| Export | Description |
|---|---|
| `new Nip01Signer(secret)` | Create from an existing hex private key |
| `Nip01Signer.fromSecret(secret)` | Alias constructor (returns `Nip01Signer`) |
| `Nip01Signer.ephemeral()` | Create with a randomly-generated private key |
### Nip07Signer (browser extension)
| Export | Description |
|---|---|
| `new Nip07Signer()` | Delegates all operations to the browser extension (nos2x, Alby, etc.) |
| `getNip07()` | Returns the `window.nostr` object if present, otherwise `undefined` |
### Nip46Signer (remote / bunker)
| Export | Description |
|---|---|
| `new Nip46Signer(broker)` | ISigner that routes operations through a `Nip46Broker` |
| `new Nip46Broker(params)` | Create a broker directly from `Nip46BrokerParams` |
| `Nip46Broker.parseBunkerUrl(url)` | Parses a `bunker://` URL into `{ signerPubkey, connectSecret, relays }` |
| `Nip46Broker.fromBunkerUrl(url)` | Create a broker directly from a `bunker://` URL |
| `broker.makeNostrconnectUrl(metadata)` | Generates a `nostrconnect://` URL for QR display |
| `broker.waitForNostrconnect(url, signal)` | Resolves when the remote signer approves the connection; `signal` is a required `AbortSignal` |
| `broker.getBunkerUrl()` | Returns a `bunker://` URL for persisting the session |
### Nip55Signer (native mobile)
| Export | Description |
|---|---|
| `getNip55()` | Returns `Promise<AppInfo[]>` — installed signing apps via Capacitor |
| `new Nip55Signer(packageName, pubkey?)` | Communicates with the specified native app; pass saved pubkey to resume a session |
Requires the peer dependency: `npm install nostr-signer-capacitor-plugin`
### Nip59 (Gift Wrap)
| Export | Description |
|---|---|
| `Nip59.fromSigner(signer)` | Create a Gift Wrap helper from any ISigner |
| `Nip59.fromSecret(secret)` | Create directly from a hex private key |
| `new Nip59(signer, wrapper?)` | Explicit constructor; `wrapper` defaults to an ephemeral signer |
| `nip59.wrap(pubkey, template, tags?)` | Encrypt an event for a recipient; returns `Promise<SignedEvent>` — the kind-1059 gift wrap event |
| `nip59.unwrap(event)` | Decrypt a received wrapped event |
| `nip59.withWrapper(wrapper)` | Return a new `Nip59` instance with a different wrapper signer |
## Common Patterns
### Local keypair login
```typescript
import { makeSecret } from '@welshman/util'
import { Nip01Signer } from '@welshman/signer'
import type { ISigner } from '@welshman/signer'
// New random key
const signer: ISigner = Nip01Signer.ephemeral()
// From a stored key
const signer: ISigner = new Nip01Signer(localStorage.getItem('nsec')!)
// With a timeout
const event = makeEvent(1, { content: 'hello' })
const signed = await signer.sign(event, { signal: AbortSignal.timeout(5_000) })
```
### Browser extension login
```typescript
import { getNip07, Nip07Signer } from '@welshman/signer'
function loginWithExtension(): ISigner {
if (!getNip07()) {
throw new Error('No NIP-07 extension found. Install nos2x or Alby.')
}
return new Nip07Signer()
}
const signer = loginWithExtension()
const pubkey = await signer.getPubkey()
```
### Remote signer (bunker) — first connect
```typescript
import { makeSecret } from '@welshman/util'
import { Nip46Broker, Nip46Signer } from '@welshman/signer'
const broker = new Nip46Broker({
relays: ['wss://relay.nsec.app'],
clientSecret: makeSecret(),
})
const signer = new Nip46Signer(broker)
// Show this URL as a QR code or link
const ncUrl = await broker.makeNostrconnectUrl({
name: 'My App',
description: 'Connect your nostr key',
})
// Block until the user approves in their bunker app
const abortController = new AbortController()
await broker.waitForNostrconnect(ncUrl, abortController.signal)
// Persist for future sessions
localStorage.setItem('bunkerUrl', broker.getBunkerUrl())
const pubkey = await signer.getPubkey()
```
### Remote signer — reconnect from saved session
```typescript
import { makeSecret } from '@welshman/util'
import { Nip46Broker, Nip46Signer } from '@welshman/signer'
const raw = localStorage.getItem('bunkerUrl')
if (raw) {
const { signerPubkey, connectSecret, relays } = Nip46Broker.parseBunkerUrl(raw)
const broker = new Nip46Broker({
relays,
clientSecret: makeSecret(),
signerPubkey,
connectSecret,
})
const signer = new Nip46Signer(broker)
// Ready to use immediately — no user approval needed
}
```
### Gift Wrap (NIP-59) — send and receive
```typescript
import { Nip01Signer, Nip59 } from '@welshman/signer'
import { makeEvent } from '@welshman/util'
const signer = new Nip01Signer(mySecret)
const nip59 = Nip59.fromSigner(signer)
// Wrap a DM for a recipient
const wrappedEvent = await nip59.wrap(
recipientPubkey,
makeEvent(14, { content: 'Secret message', tags: [['p', recipientPubkey]] }),
)
// Publish the kind-1059 gift wrap event to relays
await publishToRelays(wrappedEvent)
// Receive and unwrap
const unwrapped = await nip59.unwrap(receivedKind1059Event)
console.log(unwrapped.content) // 'Secret message'
```
### NIP-44 encryption between two parties
```typescript
import { Nip01Signer } from '@welshman/signer'
const signer = new Nip01Signer(mySecret)
const ciphertext = await signer.nip44.encrypt(theirPubkey, 'hello')
const plaintext = await signer.nip44.decrypt(theirPubkey, ciphertext)
```
## Integration Notes
- **`@welshman/util`** supplies `makeEvent`, `makeSecret`, `StampedEvent`, `SignedEvent`, and nostr kind constants (`NOTE`, `DIRECT_MESSAGE`, etc.) used in all examples above.
- **`@welshman/net`** and **`@welshman/app`** accept an `ISigner` wherever signing is needed (e.g. publishing events). Pass any concrete signer — they are interchangeable.
- **`@welshman/app`** exposes a `signer` writable store (`import { signer } from '@welshman/app'`) that the rest of the app stack reads. Set it to your chosen `ISigner` after login.
- `Nip59` wraps events with an ephemeral `Nip01Signer` by default (per the NIP-59 spec), so callers do not need to supply a wrapper unless they want a custom one.
## Gotchas & Tips
- **`Nip07Signer` is browser-only.** Do not instantiate it in SSR or Node environments; always guard with `getNip07()` first.
- **`Nip55Signer` requires Capacitor.** It will not work in a plain browser build. Only use it in a Capacitor-wrapped mobile app after confirming `getNip55()` returns apps.
- **`waitForNostrconnect` holds an open subscription.** Always pass an `AbortSignal` (e.g., from `new AbortController().signal`) so you can cancel if the user navigates away.
- **`makeSecret()`** (from `@welshman/util`) generates a cryptographically secure random hex private key. Use it for the `clientSecret` in NIP-46 — never reuse the user's actual private key as the client secret.
- **`nip59.wrap()` returns the gift-wrap `SignedEvent` directly** — the return value itself is the kind-1059 event to publish. There is no `.wrap` sub-property on the return value.
- **Both `nip04` and `nip44` are supported** on all signers. Prefer `nip44` for new code; `nip04` is provided for backwards compatibility with older clients.
- **`sign()` options accept `signal: AbortSignal`** — always set a timeout when signing in a UI flow to avoid hanging indefinitely if the user ignores the extension prompt.
+314
View File
@@ -0,0 +1,314 @@
---
name: welshman-store
description: "Use this skill when working with @welshman/store: Repository pattern for nostr events, synced Svelte stores, throttled stores, or getter/derived store utilities."
---
# welshman/store — Svelte Store Utilities
## Overview
`@welshman/store` provides reactive Svelte store primitives tailored for nostr development. It bridges the `Repository` (event cache) from `@welshman/net` with Svelte's reactive system, letting you derive live-updating collections of events or domain objects (profiles, lists, etc.) with minimal boilerplate. It also ships general-purpose utilities: persistence via `synced`, throttling via `throttled`, and optimized access via `withGetter`/`getter`.
## Installation
```bash
npm install @welshman/store
# or
pnpm add @welshman/store
yarn add @welshman/store
```
## Key Exports
### Event stores (from Repository)
| Export | Description |
|---|---|
| `deriveEventsById(options)` | Returns `Readable<Map<string, TrustedEvent>>` — live map of events matching `filters` |
| `deriveEvents(options)` | Returns `Readable<TrustedEvent[]>` — calls `deriveEventsById` internally and converts to array |
| `deriveEventsAsc(eventsByIdStore)` | Takes a `Readable<Map<string, TrustedEvent>>` and returns events sorted ascending by `created_at` |
| `deriveEventsDesc(eventsByIdStore)` | Takes a `Readable<Map<string, TrustedEvent>>` and returns events sorted descending by `created_at` |
| `makeDeriveEvent(options)` | Factory returning `(idOrAddress: string) => Readable<TrustedEvent \| undefined>` for single-event lookups |
| `deriveIsDeleted(repository, event)` | `Readable<boolean>` — tracks deletion status of an event |
`deriveEventsById` / `deriveEvents` options (`EventsByIdOptions`):
```typescript
{
repository: Repository
filters: Filter[]
includeDeleted?: boolean // default: false
}
```
`makeDeriveEvent` options (`EventOptions`):
```typescript
{
repository: Repository
includeDeleted?: boolean // default: false
onDerive?: (filters: Filter[], ...args: any[]) => void
}
```
Usage of `makeDeriveEvent`:
```typescript
const deriveEvent = makeDeriveEvent({ repository })
const eventStore = deriveEvent(someIdOrAddress) // Readable<TrustedEvent | undefined>
```
`deriveEventsAsc` / `deriveEventsDesc` take a map store, not an array store:
```typescript
// correct: pass the Readable<Map<string, TrustedEvent>> directly
const notesAsc = deriveEventsAsc(noteEventsById)
const notesDesc = deriveEventsDesc(noteEventsById)
```
### Indexed collections
| Export | Description |
|---|---|
| `deriveItemsByKey<T>(options)` | Maps events to domain objects, indexed by a string key; `Readable<Map<string, T>>` |
| `deriveItems<T>(itemsByKey)` | Converts the map to `Readable<T[]>` |
| `deriveItemsSorted<T>(sortFn, itemsStore)` | Sorts a `Readable<T[]>` by a numeric sort-value function `(item: T) => number`; returns `Readable<T[]>` |
| `makeDeriveItem<T>(itemsByKey, onDerive?)` | Returns a factory `(key) => Readable<T \| undefined>` for per-key reactive lookups |
| `makeLoadItem<T>(loadItem, getItem, options?)` | Cached async loader with staleness checks and exponential backoff |
| `makeForceLoadItem<T>(loadItem, getItem)` | Async loader that always fetches fresh data |
`deriveItemsByKey` options:
```typescript
{
repository: Repository
filters: Filter[]
eventToItem: (event: TrustedEvent) => MaybeAsync<Maybe<T>>
getKey: (item: T) => string
includeDeleted?: boolean
}
```
### Persistence
| Export | Description |
|---|---|
| `synced(config)` | Writable store that auto-persists to a `StorageProvider`; exposes a `.ready` promise |
| `localStorageProvider` | Built-in `StorageProvider` backed by `localStorage` |
`StorageProvider` interface:
```typescript
interface StorageProvider {
get: (key: string) => Promise<any>
set: (key: string, value: any) => Promise<void>
}
```
### Throttling
| Export | Description |
|---|---|
| `throttled(delay, store)` | Wraps any readable store; subscribers notified at most once per `delay` ms. Pass `0` to skip wrapping. |
### Getter utilities
| Export | Description |
|---|---|
| `getter<T>(store, options?)` | Returns `() => T`; auto-switches from `get()` to a subscription when call frequency exceeds `threshold` (default 10/s) |
| `withGetter<T>(store)` | Adds a `.get()` method to a `Readable` or `Writable` store |
## Common Patterns
### 1. Reactive list of text notes
```typescript
import { Repository } from "@welshman/net"
import { deriveEventsById, deriveEventsDesc } from "@welshman/store"
const repository = new Repository()
const noteEventsById = deriveEventsById({
repository,
filters: [{ kinds: [1], limit: 100 }],
})
// deriveEventsDesc takes the map store directly
const notes = deriveEventsDesc(noteEventsById)
notes.subscribe($notes => {
console.log(`${$notes.length} notes, newest first`)
})
```
### 2. Profiles indexed by pubkey
```typescript
import { Repository } from "@welshman/net"
import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store"
import { readProfile, PROFILE, type PublishedProfile } from "@welshman/util"
const repository = new Repository()
const profilesByPubkey = deriveItemsByKey<PublishedProfile>({
repository,
filters: [{ kinds: [PROFILE] }],
eventToItem: event => readProfile(event),
getKey: profile => profile.event.pubkey,
})
// All profiles as array
const profiles = deriveItems(profilesByPubkey)
// Per-pubkey reactive lookup
const deriveProfile = makeDeriveItem(profilesByPubkey)
const aliceProfile = deriveProfile("alice-pubkey-hex")
aliceProfile.subscribe($profile => {
console.log($profile?.name)
})
```
### 3. Persisted user preferences
```typescript
import { synced, localStorageProvider } from "@welshman/store"
const prefs = synced({
key: "app-prefs",
storage: localStorageProvider,
defaultValue: { theme: "dark", notifs: true },
})
// Wait until storage has been read before rendering
await prefs.ready
prefs.update(p => ({ ...p, theme: "light" }))
```
### 4. Throttled store for high-frequency updates
```typescript
import { writable } from "svelte/store"
import { throttled } from "@welshman/store"
const rawCursor = writable({ x: 0, y: 0 })
const cursor = throttled(50, rawCursor) // UI updates at most every 50 ms
window.addEventListener("mousemove", e => {
rawCursor.set({ x: e.clientX, y: e.clientY })
})
```
### 5. Optimized getter for hot code paths
```typescript
import { getter, withGetter } from "@welshman/store"
import { writable } from "svelte/store"
const counter = withGetter(writable(0))
// Safe to call in tight loops — switches internally to subscription when hot
function getCount() {
return counter.get()
}
```
`getter(store)` is useful when you only need the accessor function (not the full store
API). A common pattern is using it to look up a single item from a map store:
```typescript
import { getter } from "@welshman/store"
// bookmarksByPubkey is Readable<Map<string, Bookmark>>
const getBookmarksByPubkey = getter(bookmarksByPubkey)
// Synchronous, dedup-aware lookup — safe in event handlers and callbacks
const getBookmark = (pubkey: string) => getBookmarksByPubkey().get(pubkey)
```
This `getBookmark` function is the right shape to pass as `getItem` to `makeLoadItem`
(see Pattern 6).
### 6. Full reactive item chain: deriveItemsByKey → deriveItems → getter → makeLoadItem → makeDeriveItem
This is the canonical pattern for domain objects derived from repository events with
on-demand network loading.
```typescript
import {
deriveItemsByKey,
deriveItems,
getter,
makeLoadItem,
makeDeriveItem,
} from "@welshman/store"
import { load } from "@welshman/net"
import { repository } from "@welshman/app"
import { Router } from "@welshman/router"
import { getTagValue, getTagValues } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util"
const BOOKMARK_KIND = 30003
type Bookmark = {
pubkey: string
title: string
urls: string[]
event: TrustedEvent
}
const parseBookmark = (event: TrustedEvent): Bookmark => ({
pubkey: event.pubkey,
title: getTagValue("title", event.tags) ?? "Untitled",
urls: getTagValues("r", event.tags),
event,
})
// Step 1: Reactive Map<pubkey, Bookmark> — live-updates from repository
const bookmarksByPubkey = deriveItemsByKey<Bookmark>({
repository,
filters: [{ kinds: [BOOKMARK_KIND] }],
getKey: b => b.pubkey,
eventToItem: parseBookmark,
})
// Step 2: Reactive array of all bookmarks
const bookmarks = deriveItems(bookmarksByPubkey)
// Step 3: Synchronous getter for use in callbacks and as getItem for makeLoadItem
const getBookmarksByPubkey = getter(bookmarksByPubkey)
const getBookmark = (pubkey: string) => getBookmarksByPubkey().get(pubkey)
// Step 4: Cached async loader — concurrent calls for the same key collapse;
// re-fetches only after the timeout window (default: 3600 s)
const loadBookmark = makeLoadItem<Bookmark>(
async (pubkey: string) => {
await load({
relays: Router.get().ForPubkey(pubkey).getUrls(),
filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }],
})
},
getBookmark,
)
// Step 5: Per-key reactive store factory — loadBookmark is called on each unique
// key access (makeDeriveItem passes it as onDerive; makeLoadItem handles dedup)
const deriveBookmark = makeDeriveItem(bookmarksByPubkey, loadBookmark)
// Usage: each call returns Readable<Bookmark | undefined>
const aliceBookmark = deriveBookmark("alice-pubkey-hex")
aliceBookmark.subscribe($b => console.log($b?.title))
```
## Integration Notes
- **`@welshman/net`** — provides `Repository` and `Tracker`. `Repository` is the event cache that feeds all store primitives in this package. Events flow from the network into the repository, which triggers store updates automatically.
- **`@welshman/util`** — provides `TrustedEvent`, `Filter`, `readProfile`, `readList`, and other event-parsing helpers that feed into `deriveItemsByKey` / `deriveEventsById`.
- **`@welshman/app`** — the high-level app layer re-exports and composes store utilities with pre-configured repositories, loaders, and context. If you are using `@welshman/app`, many of these stores are already wired up for you.
- Stores in this package are **framework-agnostic** at runtime (plain Svelte stores), so they work in SvelteKit SSR as well as browser-only Svelte apps. The `synced` store's `localStorageProvider` is browser-only — guard it with `if (browser)` in SvelteKit.
## Gotchas & Tips
- **`eventToItem` can return `null`/`undefined`** — returning a falsy value from `eventToItem` in `deriveItemsByKey` causes that event to be skipped. Use this to filter out malformed events (e.g. `event.tags.length > 1 ? readList(event) : null`).
- **`synced` is async on first read** — the store emits `defaultValue` synchronously, then overwrites it once storage resolves. Always `await store.ready` before reading in server-side or initialization code where you need the persisted value.
- **`throttled(0, store)` is a no-op** — it returns the original store unchanged, so it is safe to call with a user-configurable delay that may be zero.
- **`makeDeriveItem` is a factory** — call it once to create the lookup function, then call the returned function with a key to get a per-key `Readable`. Do not call `deriveItemsByKey` inside a Svelte `$:` block repeatedly; derive once at module level and pass the store down.
- **`makeLoadItem` timeout is in seconds** — the `timeout` option is compared against `now()` from `@welshman/lib`, which returns Unix time in seconds. The default is `3600` (one hour). Use `{ timeout: 30 }` for a 30-second staleness window, not `30_000`.
- **`makeLoadItem` uses exponential backoff** — repeated calls for the same key that already has a fresh result (item exists AND was fetched within the timeout window) are returned from cache without re-fetching. If the timeout has elapsed, it will re-fetch even if a previous value exists. Use `makeForceLoadItem` when you explicitly need fresh data.
- **`deriveEventsAsc`/`deriveEventsDesc` take a map store** — both functions accept a `Readable<Map<string, TrustedEvent>>` (the output of `deriveEventsById`), not an array store. To sort an array store use `deriveItemsSorted`.
- **`getter` vs `withGetter`** — use `getter(store)` when you only need the accessor function; use `withGetter(store)` when you want to keep the full store API (`.subscribe`, `.set`, `.update`) plus `.get()` on the same object.
+675
View File
@@ -0,0 +1,675 @@
---
name: welshman-util
description: "Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, NIPs (42/86/98), profiles, relays, zaps, wallets, or any core nostr data structures."
---
# welshman/util — Core Nostr Utilities
`@welshman/util` is the foundational layer of the welshman nostr stack, providing types, constants, and helpers for every nostr primitive: events, kinds, tags, filters, addresses, profiles, lists, zaps, relays, and Lightning wallet integration. Higher level welshman packages (`@welshman/net`, `@welshman/app`, `@welshman/store`, etc.) depend on the types and utilities defined here.
## Installation
```bash
npm install @welshman/util
# or
pnpm add @welshman/util
# or
yarn add @welshman/util
```
---
## Key Exports
### Event Types
| Type | Description |
|------|-------------|
| `EventContent` | `{ tags, content }` — base content structure |
| `EventTemplate` | `EventContent + kind` |
| `StampedEvent` | `EventTemplate + created_at` |
| `OwnedEvent` | `StampedEvent + pubkey` |
| `HashedEvent` | `OwnedEvent + id` |
| `SignedEvent` | `HashedEvent + sig` |
| `TrustedEvent` | `HashedEvent + optional sig` — most common in-app type |
| `DecryptedEvent` | `TrustedEvent + plaintext` (for encrypted lists/events) |
### Event Utilities
| Export | Description |
|--------|-------------|
| `verifiedSymbol` | Symbol (re-exported from `nostr-tools`) used as a key on events; set `event[verifiedSymbol] = true` to skip signature re-validation |
| `makeEvent(kind, opts?)` | Create a `StampedEvent` with optional content, tags, created_at |
| `verifyEvent(event)` | Verify event signature; returns `false` for unsigned events (no `sig` field) even if `verifiedSymbol` is set, because `isSignedEvent` is checked first; returns `true` immediately for signed events where `event[verifiedSymbol]` is already set |
| `getIdentifier(event)` | Get `d` tag value |
| `getIdOrAddress(event)` | Returns address string for replaceable events, id otherwise |
| `getIdAndAddress(event)` | Returns array with both id and address (if applicable) |
| `deduplicateEvents(events)` | Deduplicate by id or address |
| `isEphemeral(event)` | True for ephemeral kinds (2000029999) |
| `isReplaceable(event)` | True for plain or parameterized replaceable |
| `isPlainReplaceable(event)` | True for kinds 1000019999 and metadata/contacts |
| `isParameterizedReplaceable(event)` | True for kinds 3000039999 |
| `getAncestors(event)` | Returns `{ roots, replies, mentions }` for NIP-10 events (mentions may be empty `[]` but is always present); NIP-22/COMMENT path returns `{ roots, replies }` without mentions |
| `getParentIdOrAddr(event)` | Immediate parent id or address |
| `isChildOf(child, parent)` | Check if child replies to parent |
### Type Guards
`isEventTemplate`, `isStampedEvent`, `isOwnedEvent`, `isHashedEvent`, `isSignedEvent`
### Event Kinds (constants)
All constants are exported by name from `@welshman/util`.
**Core / NIP-01**
```
PROFILE = 0 NOTE = 1 FOLLOWS = 3
DELETE = 5 REPOST = 6 REACTION = 7
BADGE_AWARD = 8 MESSAGE = 9 THREAD = 11
SEAL = 13 DIRECT_MESSAGE = 14 DIRECT_MESSAGE_FILE = 15
GENERIC_REPOST = 16 PICTURE_NOTE = 20 VANISH = 62
COMMENT = 1111 GENERIC_REPOST = 16
```
**Channels (NIP-28)**
```
CHANNEL_CREATE = 40 CHANNEL_UPDATE = 41 CHANNEL_MESSAGE = 42
CHANNEL_HIDE_MESSAGE = 43 CHANNEL_MUTE_USER = 44
```
**Wrapped / encrypted (NIP-59)**
```
WRAP = 1059 WRAP_NIP04 = 1060
WRAPPED_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE] // convenience array
```
**Media / files**
```
FILE_METADATA = 1063 PICTURE_NOTE = 20 AUDIO = 31337
```
**Polls**
```
POLL = 1068 POLL_RESPONSE = 1018
```
**Marketplace / auction**
```
BID = 1021 BID_CONFIRMATION = 1022
STALL = 30017 PRODUCT = 30018 MARKET_UI = 30019
PRODUCT_SOLD_AS_AUCTION = 30020
CLASSIFIED = 30402 DRAFT_CLASSIFIED = 30403
```
**Git (NIP-34)**
```
GIT_PATCH = 1617 GIT_ISSUE = 1621 GIT_REPLY = 1622
GIT_STATUS_OPEN = 1630 GIT_STATUS_COMPLETE = 1631
GIT_STATUS_CLOSED = 1632 GIT_STATUS_DRAFT = 1633
GIT_REPOSITORY = 30403
```
**Social / community**
```
REMIX = 1808 REPORT = 1984 LABEL = 1985
REVIEW = 1986 HIGHLIGHT = 9802 APPROVAL = 4550
NOSTROCKET_PROBLEM = 1971
COMMUNITY = 34550
BADGE_DEFINITION = 30009 BADGES = 30008
LIVE_EVENT = 30311 LIVE_CHAT_MESSAGE = 1311
```
**Rooms (NIP-29)**
```
ROOM_CREATE = 9007 ROOM_DELETE = 9008 ROOM = 35834
ROOM_JOIN = 9021 ROOM_LEAVE = 9022 ROOM_META = 39000
ROOM_ADMINS = 39001 ROOM_MEMBERS = 39002 ROOM_EDIT_META = 9002
ROOM_ADD_MEMBER = 9000 ROOM_REMOVE_MEMBER = 9001
ROOM_ADD_PERM = 9003 ROOM_REMOVE_PERM = 9004
ROOM_DELETE_EVENT = 9005 ROOM_EDIT_STATUS = 9006
ROOM_CREATE_PERMISSION = 19004
RELAY_MEMBERS = 13534 RELAY_ADD_MEMBER = 8000 RELAY_REMOVE_MEMBER = 8001
RELAY_JOIN = 28934 RELAY_INVITE = 28935 RELAY_LEAVE = 28936
```
**Replaceable lists (kinds 1000010099)**
```
MUTES = 10000 PINS = 10001 RELAYS = 10002
BOOKMARKS = 10003 COMMUNITIES = 10004 CHANNELS = 10005
BLOCKED_RELAYS = 10006 SEARCH_RELAYS = 10007 ROOMS = 10009
FEEDS = 10014 TOPICS = 10015 EMOJIS = 10030
MESSAGING_RELAYS = 10050 BLOSSOM_SERVERS = 10063
FILE_SERVERS = 10096
```
**Parameterized replaceable lists (kinds 3000030102)**
```
NAMED_PEOPLE = 30000 NAMED_RELAYS = 30002 NAMED_BOOKMARKS = 30003
NAMED_CURATIONS = 30004 NAMED_TOPICS = 30015
NAMED_WIKI_AUTHORS = 30101 NAMED_WIKI_RELAYS = 30102
NAMED_EMOJIS = 30030 NAMED_ARTIFACTS = 30063
NAMED_COMMUNITIES = 30064
```
**Long-form / wiki / publishing (NIP-23)**
```
LONG_FORM = 30023 LONG_FORM_DRAFT = 30024
WIKI = 30818 APP_DATA = 30078
FEED = 31890
```
**Calendar (NIP-52)**
```
CALENDAR = 31924 EVENT_DATE = 31922 EVENT_TIME = 31923
EVENT_RSVP = 31925
```
**Handlers (NIP-89)**
```
HANDLER_INFORMATION = 31990 HANDLER_RECOMMENDATION = 31989
```
**Status / alerts**
```
STATUS = 30315
ALERT_EMAIL = 32830 ALERT_STATUS = 32831 ALERT_WEB = 32832
ALERT_ANDROID = 32833 ALERT_IOS = 32834
```
**Zaps / wallet / Lightning**
```
ZAP_GOAL = 9041 ZAP_REQUEST = 9734 ZAP_RESPONSE = 9735
WALLET_INFO = 13194 WALLET_REQUEST = 23194 WALLET_RESPONSE = 23195
LIGHTNING_PUB_RPC = 21000
OTS = 1040
```
**Auth**
```
CLIENT_AUTH = 22242 BLOSSOM_AUTH = 24242 HTTP_AUTH = 27235
NOSTR_CONNECT = 24133
```
**Follow packs**
```
FOLLOW_PACK = 39089
```
**Promenade protocol**
```
PROMENADE_REGISTER_ACCOUNT = 16430 PROMENADE_SHARD_SHARE = 26428
PROMENADE_SHARD_ACK = 26429 PROMENADE_CONFIG = 26430
PROMENADE_COMMIT = 26431 PROMENADE_REQUEST = 26432
PROMENADE_RESULT = 26433
```
**Deprecated**
```
DEPRECATED_RELAY_RECOMMENDATION = 2
DEPRECATED_DIRECT_MESSAGE = 4
DEPRECATED_NAMED_GENERIC = 30001
```
**DVM — Data Vending Machines (NIP-90, kinds 50007000)**
Requests (`5xxx`) and their paired responses (`6xxx`):
```
DVM_REQUEST_TEXT_EXTRACTION = 5000 DVM_RESPONSE_TEXT_EXTRACTION = 6000
DVM_REQUEST_TEXT_SUMMARY = 5001 DVM_RESPONSE_TEXT_SUMMARY = 6001
DVM_REQUEST_TEXT_TRANSLATION = 5002 DVM_RESPONSE_TEXT_TRANSLATION = 6002
DVM_REQUEST_TEXT_GENERATION = 5050 DVM_RESPONSE_TEXT_GENERATION = 6050
DVM_REQUEST_IMAGE_GENERATION = 5100 DVM_RESPONSE_IMAGE_GENERATION = 6100
DVM_REQUEST_VIDEO_CONVERSION = 5200 DVM_RESPONSE_VIDEO_CONVERSION = 6200
DVM_REQUEST_VIDEO_TRANSLATION = 5201 DVM_RESPONSE_VIDEO_TRANSLATION = 6201
DVM_REQUEST_IMAGE_TO_VIDEO_CONVERSION = 5202
DVM_RESPONSE_IMAGE_TO_VIDEO_CONVERSION = 6202
DVM_REQUEST_TEXT_TO_SPEECH = 5250 DVM_RESPONSE_TEXT_TO_SPEECH = 6250
DVM_REQUEST_DISCOVER_CONTENT = 5300 DVM_RESPONSE_DISCOVER_CONTENT = 6300
DVM_REQUEST_DISCOVER_PEOPLE = 5301 DVM_RESPONSE_DISCOVER_PEOPLE = 6301
DVM_REQUEST_SEARCH_CONTENT = 5302 DVM_RESPONSE_SEARCH_CONTENT = 6302
DVM_REQUEST_SEARCH_PEOPLE = 5303 DVM_RESPONSE_SEARCH_PEOPLE = 6303
DVM_REQUEST_COUNT = 5400 DVM_RESPONSE_COUNT = 6400
DVM_REQUEST_MALWARE_SCAN = 5500 DVM_RESPONSE_MALWARE_SCAN = 6500
DVM_REQUEST_OTS = 5900 DVM_RESPONSE_OTS = 6900
DVM_REQUEST_OP_RETURN = 5901 DVM_RESPONSE_OP_RETURN = 6901
DVM_REQUEST_PUBLISH_SCHEDULE = 5905 DVM_RESPONSE_PUBLISH_SCHEDULE = 6905
DVM_FEEDBACK = 7000
```
Use `isDVMKind(kind)` to test if a kind falls in the DVM range (50007000).
**Kind classifiers**
```typescript
isRegularKind(kind) // 10009999 and select low kinds
isPlainReplaceableKind(kind) // 0, 3, and 1000019999
isEphemeralKind(kind) // 2000029999
isParameterizedReplaceableKind(kind) // 3000039999
isReplaceableKind(kind) // plain OR parameterized replaceable
isDVMKind(kind) // 50007000
```
### Tags
| Export | Description |
|--------|-------------|
| `getTags(types, tags)` | Get all tags matching one or more type strings |
| `getTag(types, tags)` | Get first matching tag |
| `getTagValues(types, tags)` | Get value (index 1) of all matching tags — types first, then the tags array |
| `getTagValue(types, tags)` | Get value of first matching tag — types first, then the tags array |
| `getEventTags(tags)` | `e` tags |
| `getEventTagValues(tags)` | Values of `e` tags |
| `getAddressTags(tags)` | `a` tags |
| `getAddressTagValues(tags)` | Values of `a` tags |
| `getPubkeyTags(tags)` | `p` tags |
| `getPubkeyTagValues(tags)` | Values of `p` tags |
| `getTopicTags(tags)` / `getTopicTagValues(tags)` | `t` (hashtag) tags |
| `getRelayTags(tags)` / `getRelayTagValues(tags)` | `r` and `relay` tags |
| `getKindTags(tags)` / `getKindTagValues(tags)` | `k` tags (returns `number[]`) |
| `getGroupTags(tags)` / `getGroupTagValues(tags)` | group tags |
| `getReplyTags(tags)` | `{ roots, replies, mentions }` — NIP-10 threading |
| `getCommentTags(tags)` | `{ roots, replies }` — NIP-22 uppercase/lowercase tags |
| `uniqTags(tags)` | Remove duplicate tags |
| `tagsFromIMeta(imeta)` | Parse `imeta` tag into array of tag arrays |
### Filters
| Export | Description |
|--------|-------------|
| `matchFilter(filter, event)` | Test if event matches a single filter |
| `matchFilters(filters, event)` | Test if event matches any filter |
| `getIdFilters(idsOrAddresses)` | Build filters from mixed ids and addresses |
| `getReplyFilters(events, filter?)` | Build filters to find replies |
| `addRepostFilters(filters)` | Add kind 6/16 repost filters |
| `unionFilters(filters)` | Merge overlapping filters |
| `intersectFilters(groups)` | Intersect arrays of filter groups |
| `trimFilter(filter)` / `trimFilters(filters)` | Limit array fields to 1000 items |
| `getFilterId(filter)` | Compact string key for a filter |
| `getFilterGenerality(filter)` | 0 = specific, 1 = general |
| `guessFilterDelta(filters, max?)` | Estimate appropriate time window in seconds |
| `getFilterResultCardinality(filter)` | Expected result count for id-based filters |
### Address
| Export | Description |
|--------|-------------|
| `Address` class | Handles `kind:pubkey:identifier` and NIP-19 naddr format |
| `Address.isAddress(s)` | Validate address string format |
| `Address.from(s, relays?)` | Parse from `kind:pubkey:identifier` string |
| `Address.fromNaddr(naddr)` | Parse from NIP-19 naddr |
| `Address.fromEvent(event, relays?)` | Create from addressable event |
| `address.toString()` | Serialize to `kind:pubkey:identifier` |
| `address.toNaddr()` | Serialize to NIP-19 naddr |
| `getAddress(event)` | Convenience: get address string from event |
### Profile
| Export | Description |
|--------|-------------|
| `makeProfile(partial)` | Create a profile object |
| `readProfile(event)` | Parse `PublishedProfile` from kind 0 event |
| `createProfile(profile)` | Create kind 0 `EventTemplate` |
| `editProfile(published)` | Update existing profile event |
| `displayProfile(profile?, fallback?)` | Get best display name string |
| `displayPubkey(pubkey)` | Shorten pubkey to `npub1abc...xyz` |
| `profileHasName(profile?)` | Check if profile has a name field |
Profile fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`, `nip05`, `lud06`, `lud16`, `lnurl`
### Lists (kind 10000+)
| Export | Description |
|--------|-------------|
| `makeList(params)` | Create a new list |
| `readList(event)` | Parse `PublishedList` from `DecryptedEvent` |
| `getListTags(list)` | Combined public + private tags |
| `addToListPublicly(list, ...tags)` | Returns `Encryptable` with tag added publicly |
| `addToListPrivately(list, ...tags)` | Returns `Encryptable` with tag added privately |
| `removeFromList(list, value)` | Returns `Encryptable` with tag removed |
| `removeFromListByPredicate(list, pred)` | Returns `Encryptable` with matching tags removed |
| `updateList(list, { publicTags?, privateTags? })` | Bulk update tags |
### Encryptable
| Export | Description |
|--------|-------------|
| `Encryptable<T>` | Wraps a partial event with plaintext updates; call `.reconcile(encrypt)` to produce encrypted event |
| `asDecryptedEvent(event, plaintext?)` | Attach plaintext data to a `TrustedEvent` |
### Relay
| Export | Description |
|--------|-------------|
| `RelayMode` | Enum: `Read`, `Write`, `Search`, `Blocked`, `Messaging` |
| `RelayProfile` | NIP-11 relay info type |
| `isRelayUrl(url)` | Validate relay URL |
| `isShareableRelayUrl(url)` | True if valid relay URL and not a local address |
| `isOnionUrl(url)` | Tor address check |
| `isLocalUrl(url)` | Local address check |
| `isIPAddress(url)` | IP address check |
| `normalizeRelayUrl(url)` | Normalize to standard wss:// format |
| `displayRelayUrl(url)` | Strip protocol and trailing slash |
| `displayRelayProfile(profile?, fallback?)` | Get display name for relay |
### Zaps (NIP-57)
| Export | Description |
|--------|-------------|
| `getLnUrl(address)` | Convert lightning address or URL to LNURL; returns `undefined` if invalid |
| `getInvoiceAmount(bolt11)` | Extract millisatoshi amount from BOLT11 invoice |
| `hrpToMillisat(hrpString)` | Convert human-readable BTC amount to millisats (`bigint`) |
| `zapFromEvent(response, zapper)` | Validate zap receipt and return `Zap` or `undefined` |
| `Zapper` type | `{ lnurl, pubkey?, callback?, minSendable?, maxSendable?, nostrPubkey?, allowsNostr? }` |
| `Zap` type | `{ request: TrustedEvent, response: TrustedEvent, invoiceAmount: number }` |
### Wallet
| Export | Description |
|--------|-------------|
| `WalletType` | Enum: `WebLN`, `NWC` |
| `Wallet` | Union: `WebLNWallet | NWCWallet` |
| `isWebLNWallet(wallet)` | Type guard |
| `isNWCWallet(wallet)` | Type guard |
### NIP-42 (Relay Auth)
```typescript
makeRelayAuth(url: string, challenge: string): StampedEvent
// Creates kind 22242 auth event; sign before sending
```
### NIP-98 (HTTP Auth)
```typescript
makeHttpAuth(url: string, method?: string, body?: string): Promise<StampedEvent>
makeHttpAuthHeader(event: SignedEvent): string // Returns "Nostr <base64>"
```
### NIP-86 (Relay Management)
```typescript
sendManagementRequest(url: string, request: ManagementRequest, authEvent: SignedEvent): Promise<ManagementResponse>
// ManagementResponse = { result?: any; error?: string }
// ManagementMethod enum covers: BanPubkey, AllowPubkey, BanEvent, AllowEvent, etc.
```
### Handlers (NIP-89)
```typescript
readHandlers(event: TrustedEvent): Handler[]
getHandlerKey(handler: Handler): string // "kind:address" format
getHandlerAddress(event: TrustedEvent): string | undefined
displayHandler(handler?: Handler, fallback?: string): string
```
### Links
```typescript
fromNostrURI(s: string): string // strips "nostr:" or "nostr://" prefix
toNostrURI(s: string): string // ensures "nostr:" prefix
```
### Blossom (Media Servers)
```typescript
makeBlossomAuthEvent(opts: BlossomAuthEventOpts): StampedEvent
uploadBlob(server, blob, opts?): Promise<Response>
getBlob(server, sha256, opts?): Promise<Response>
deleteBlob(server, sha256, opts?): Promise<Response>
listBlobs(server, pubkey, opts?): Promise<Response>
checkBlobExists(server, sha256, opts?): Promise<{exists, size?}>
buildBlobUrl(server, sha256, extension?): string
encryptFile(file: Blob): Promise<EncryptedFile>
decryptFile(encryptedFile: EncryptedFile): Promise<Uint8Array>
```
---
## Common Patterns
### Creating and inspecting events
```typescript
import { makeEvent, NOTE, PROFILE, RELAYS, LONG_FORM, getIdentifier, getIdOrAddress } from '@welshman/util'
// Text note (kind 1)
const note = makeEvent(NOTE, {
content: 'Hello Nostr!',
tags: [['t', 'nostr']],
})
// Profile update (kind 0)
const profile = makeEvent(PROFILE, {
content: JSON.stringify({ name: 'Alice', about: 'Nostr dev' }),
tags: [],
})
// Relay list (kind 10002)
const relayList = makeEvent(RELAYS, {
content: '',
tags: [
['r', 'wss://relay.example.com', 'read'],
['r', 'wss://relay2.example.com', 'write'],
],
})
```
### Pre-verifying persisted events with verifiedSymbol
When loading events from a local store (IndexedDB, localStorage, etc.) at startup, you
can skip expensive signature re-validation by marking them as already verified:
```typescript
import { verifiedSymbol } from '@welshman/util'
import type { TrustedEvent } from '@welshman/util'
// Load from storage
const storedEvents: TrustedEvent[] = await db.getAll('events')
// Mark as pre-verified — verifyEvent() will return true immediately (without
// re-running the cryptographic check) for events that have a sig field
for (const event of storedEvents) {
event[verifiedSymbol] = true
}
repository.load(storedEvents)
```
Only do this for events you persisted yourself after they were validated. Never set
`verifiedSymbol` on events received directly from untrusted external sources.
### Working with tags
```typescript
import {
getTagValue,
getTagValues,
getPubkeyTagValues,
getTopicTagValues,
getRelayTagValues,
getReplyTags,
uniqTags,
} from '@welshman/util'
// getTagValue and getTagValues: types argument FIRST, then the tags array
const title = getTagValue('title', event.tags) // string | undefined
const urls = getTagValues('r', event.tags) // string[]
// Multiple types at once
const ids = getTagValues(['e', 'a'], event.tags) // string[]
const mentions = getPubkeyTagValues(event.tags) // string[]
const topics = getTopicTagValues(event.tags) // string[]
const relays = getRelayTagValues(event.tags) // string[]
// NIP-10 thread context
const { roots, replies, mentions: threadMentions } = getReplyTags(event.tags)
```
### Matching and building filters
```typescript
import { matchFilters, getIdFilters, getReplyFilters, addRepostFilters, NOTE } from '@welshman/util'
import { ago, HOUR } from '@welshman/lib'
// Does this event match our subscription?
const active = matchFilters([{ kinds: [NOTE], authors: [myPubkey] }], event)
// Fetch a set of events by id or address
const filters = getIdFilters([
'abc123', // event id
'30023:deadbeef:my-slug', // address
])
// Find all replies to a set of events
const replyFilters = getReplyFilters(events, { since: ago(HOUR) })
// Automatically include repost kinds
const withReposts = addRepostFilters([{ kinds: [NOTE] }])
```
### Addresses for replaceable events
```typescript
import { Address, getAddress } from '@welshman/util'
// From an addressable event
const addr = Address.fromEvent(event, ['wss://relay.example.com'])
console.log(addr.toString()) // '30023:deadbeef:my-slug'
console.log(addr.toNaddr()) // 'naddr1...'
// Round-trip from naddr
const parsed = Address.fromNaddr('naddr1...')
// Quick string form
const addressStr = getAddress(event) // '30023:deadbeef:my-slug'
```
### Profiles
```typescript
import { readProfile, displayProfile, displayPubkey, editProfile } from '@welshman/util'
const profile = readProfile(kind0Event)
console.log(displayProfile(profile, 'Anonymous')) // name or fallback
console.log(displayPubkey(pubkey)) // 'npub1abc...xyz'
// Update profile
const updatedEvent = editProfile({ ...profile, name: 'New Name', about: 'Updated bio' })
// sign and publish updatedEvent
```
### Zap flow
```typescript
import { getLnUrl, makeEvent, ZAP_REQUEST, zapFromEvent } from '@welshman/util'
// Step 1: resolve LNURL
const lnurl = getLnUrl('satoshi@getalby.com')
if (!lnurl) throw new Error('Invalid lightning address')
// Step 2: build zap request (kind 9734)
const zapRequest = makeEvent(ZAP_REQUEST, {
content: 'Great post!',
tags: [
['p', recipientPubkey],
['e', targetEventId],
['amount', '5000'], // millisats
['lnurl', lnurl],
['relays', 'wss://relay.damus.io'],
],
})
// Step 3: sign, send to LNURL callback, pay invoice...
// Step 4: validate receipt (kind 9735)
const zap = zapFromEvent(zapReceipt, { nostrPubkey: zapperPubkey, allowsNostr: true, lnurl })
if (zap) {
console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content)
}
```
### NIP-42 relay authentication
```typescript
import { makeRelayAuth } from '@welshman/util'
// Inside relay AUTH handler
const authEvent = makeRelayAuth('wss://relay.example.com', challengeFromRelay)
const signed = await signer.sign(authEvent)
// send signed AUTH message to relay
```
### NIP-98 HTTP authentication
```typescript
import { makeHttpAuth, makeHttpAuthHeader } from '@welshman/util'
const body = JSON.stringify({ data: 'example' })
const authEvent = await makeHttpAuth('https://api.example.com/upload', 'POST', body)
const signed = await signer.signEvent(authEvent)
await fetch('https://api.example.com/upload', {
method: 'POST',
body,
headers: {
Authorization: makeHttpAuthHeader(signed),
'Content-Type': 'application/json',
},
})
```
---
## Integration Notes
- **`@welshman/net`** — uses `TrustedEvent`, `Filter`, `SignedEvent` from this package as the wire types for relay connections and subscriptions.
- **`@welshman/store`** — provides Svelte stores over repositories built on `TrustedEvent`; relies on `isReplaceable`, `getAddress`, etc. for deduplication.
- **`@welshman/app`** — high-level application layer; wraps net/store/router and uses profile, list, zap, and handler helpers from this package.
- **`@welshman/router`** — uses `RelayMode` and relay URL helpers when computing relay selections.
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here; the `Encrypt` function type used by `Encryptable` is typically provided by a signer.
---
## Gotchas & Tips
- **`TrustedEvent` vs `SignedEvent`**: Most in-app code should accept `TrustedEvent` (has id, may have sig). Only require `SignedEvent` when you need to ensure the event has a signature.
- **Replaceable event identity**: Use `getIdOrAddress` rather than `event.id` when referencing events that may be addressable — the address string is stable across updates, the id is not.
- **`getAncestors` handles two protocols**: Kind 1111 (comment/NIP-22) uses uppercase `E`/`A` for roots and lowercase for replies, returning `{ roots, replies }`. All other kinds use NIP-10 positional rules, returning `{ roots, replies, mentions }` where `mentions` is always present but may be an empty array. You do not need to branch on this; `getAncestors`, `getParentIdOrAddr`, and `isChildOf` handle it automatically.
- **List mutations return `Encryptable`**: `addToListPrivately`, `removeFromList`, etc. do not return an event directly. Call `.reconcile(encryptFn)` on the result to get the final `EventTemplate` ready to sign.
- **`zapFromEvent` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result.
- **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else.
- **`normalizeRelayUrl` vs `displayRelayUrl`**: Use `normalizeRelayUrl` before storing or comparing relay URLs. Use `displayRelayUrl` only for human-readable display (strips protocol/trailing slash).
- **`Address.isAddress`** checks the `kind:pubkey:identifier` format only, not naddr. To validate an naddr string, use `Address.fromNaddr` inside a try/catch.
- **`getTagValue` / `getTagValues` argument order**: the type(s) come **first**, the tags array comes **second**`getTagValue('title', event.tags)`. This is the opposite of the specialized helpers like `getEventTags(tags)` which take only the tags array. Mixing up the order produces no TypeScript error but silently returns `undefined` or `[]`.
- **`verifiedSymbol` is a Symbol key**: you must import `verifiedSymbol` from `@welshman/util` and use it as a computed property key — `event[verifiedSymbol] = true`. You cannot use a string key. The symbol is re-exported from `nostr-tools/pure`, so it is the same identity as the one used internally by `verifyEvent`.
+194
View File
@@ -0,0 +1,194 @@
---
name: welshman
description: "Use this skill for general welshman questions: architecture overview, which package to use, getting started, nostr concepts, or when you're unsure which sub-skill applies. Welshman is a modular TypeScript nostr toolkit for building client applications."
---
## What is welshman
Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](https://coracle.social) nostr client, designed for building highly configurable nostr client applications. It is production-tested, powering both Coracle and [Flotilla](https://flotilla.social). Packages are independent and opt-in — you can grab a single utility or use the full batteries-included framework.
## Package map
| Package | Description |
|---|---|
| `@welshman/util` | Core nostr types, event helpers, filters, and NIP implementations |
| `@welshman/lib` | General-purpose utilities: LRU cache, event emitter, deferred promises, task queue |
| `@welshman/net` | Relay connections, request/publish lifecycle, and auth handling |
| `@welshman/router` | Relay selection strategies for reads and writes |
| `@welshman/store` | Svelte stores and a Repository for indexing/querying nostr events client-side |
| `@welshman/signer` | Signing and login methods: NIP-01 (privkey), NIP-07 (extension), NIP-46 (bunker), NIP-55 (app), NIP-59 (gift wrap) |
| `@welshman/feeds` | Dynamic feed construction, filtering, and composition |
| `@welshman/app` | High-level Svelte stores that compose net, router, store, signer, and feeds into a full application framework |
| `@welshman/content` | Parser and renderer for nostr note content (links, mentions, media, custom formatting) |
| `@welshman/editor` | Batteries-included Svelte rich-text editor component with mention and embed support |
## Dependency layering
Packages are layered so lower-level ones have no welshman dependencies:
- **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util`
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/router`, `@welshman/store`, `@welshman/signer`
- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/app`
- **UI-focused** (largely independent, UI rendering concerns): `@welshman/content`, `@welshman/editor`
For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-signer`).
## Getting started
Install only what you need:
```bash
# Full application framework (includes app, net, router, store, signer, feeds)
npm i @welshman/app
# Or assemble manually for more control
npm i @welshman/util @welshman/net @welshman/signer
```
If you're building a conventional nostr web client, use `@welshman/app` for batteries-included functionality. For more advanced usage, use the lower-level modules without `app` for more control.
## Key nostr concepts
- **event** — the fundamental data unit in nostr; a JSON object signed by a keypair
- **kind** — integer field on an event that determines its type (e.g. kind 1 = short text note, kind 0 = profile metadata)
- **filter** — a query object (`{kinds, authors, since, until, limit, ...}`) sent to relays to request matching events
- **relay** — a WebSocket server that stores and forwards nostr events; clients connect to multiple relays
- **NIP** — "Nostr Implementation Possibility"; numbered specifications defining protocol behavior and event kinds
- **pubkey** — 32-byte hex public key that identifies a nostr user
- **signer** — abstraction over key management; handles signing events and optionally encryption, regardless of where the private key lives (in-memory, browser extension, remote bunker, mobile app)
## Common use-case routing
| Goal | Package(s) to use |
|---|---|
| Fetch notes from relays | `@welshman/net` (low-level) or `@welshman/app` (high-level) |
| Select which relays to use | `@welshman/router` |
| Sign and publish events | `@welshman/signer` + `@welshman/net` |
| Build a feed UI | `@welshman/feeds` + `@welshman/app` |
| Parse note text and media | `@welshman/content` |
| Embed a composer / editor | `@welshman/editor` |
| Cache nostr events client-side | `@welshman/store` |
| Core event/filter utilities | `@welshman/util` |
| Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` |
### App Example
```typescript
import "@welshman/app" // side effects: wires pool → repository + tracker + router
import { openDB } from "idb"
import { batch, on } from "@welshman/lib"
import { verifiedSymbol } from "@welshman/util"
import { repository, tracker, loginWithNip07, publishThunk, userProfile, loadUserProfile } from "@welshman/app"
import { routerContext } from "@welshman/router"
import { load } from "@welshman/net"
import type { TrustedEvent } from "@welshman/util"
import type { RepositoryUpdate } from "@welshman/net"
// 1. Configure fallback relays
routerContext.getDefaultRelays = () => ["wss://relay.example.com", "wss://relay2.example.com"]
routerContext.getIndexerRelays = () => ["wss://indexer.example.com"]
// 2. Open IndexedDB and hydrate the repository
const db = await openDB("my-app", 1, {
upgrade(db) {
db.createObjectStore("events", { keyPath: "id" })
},
})
const stored: TrustedEvent[] = await db.getAll("events")
for (const e of stored) e[verifiedSymbol] = true
repository.load(stored)
// Flush new events to IndexedDB
on(repository, "update", batch(3000, async (updates: RepositoryUpdate[]) => {
const tx = db.transaction("events", "readwrite")
for (const { added, removed } of updates) {
for (const e of added) tx.store.put(e)
for (const id of removed) tx.store.delete(id)
}
await tx.done
}))
// 3. Log in
const pk = await window.nostr.getPublicKey()
loginWithNip07(pk)
// 4. Load user's profile reactively (triggers network fetch if not cached)
await loadUserProfile()
userProfile.subscribe($profile => {
if ($profile) console.log("Hello,", $profile.name)
})
// 5. Publish a note
import { makeEvent } from "@welshman/util"
import { Router } from "@welshman/router"
const thunk = publishThunk({
event: makeEvent(1, { content: "Hello, Nostr!", tags: [] }),
relays: Router.get().FromUser().getUrls(),
})
await thunk.complete
```
### Lower-level Example
```typescript
import { AbstractAdapter, ClientMessage, NetContext, isClientEvent, netContext, publish, request } from '@welshman/net'
import { call, sleep } from '@welshman/lib'
import { Nip01Signer } from '@welshman/signer'
import { makeEvent, NOTE } from '@welshman/util'
const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
const RELAY_URL = "bogus.relay"
// Create an adapter for our relay url which just prints the content
export class PrintAdapter extends AbstractAdapter {
get sockets() { return [] }
get urls() { return [] }
send = (message: ClientMessage) => {
if (isClientEvent(message)) {
const [_, event] = message
console.log(event.content)
}
}
}
// Configure net context to use our custom adapter
netContext.getAdapter = (url: string, context: NetContext) => {
if (url === RELAY_URL) {
return new PrintAdapter()
}
}
// Loop, sending off pings every so often
call(async () => {
while (true) {
await sleep(1000)
const ping = await pingSigner.sign(
makeEvent(NOTE, {content: 'ping'})
)
await publish({event: ping, relays: [RELAY_URL]})
}
})
// Meanwhile, listen for pings and quote-note with a pong
call(async () => {
request({
relays: [RELAY_URL],
filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}],
onEvent: async (ping, url) => {
const pong = await pongSigner.sign(
makeEvent(NOTE, {content: 'pong', tags: [["q", ping.id, RELAY_URL, ping.pubkey]]})
)
await publish({event: pong, relays: [RELAY_URL]})
},
})
})
```
-1
View File
@@ -4,7 +4,6 @@ ios
build build
# Git # Git
.git
.gitignore .gitignore
# Env files (keep .env for build; exclude local overrides) # Env files (keep .env for build; exclude local overrides)
+2
View File
@@ -1,5 +1,6 @@
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3 VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/ VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
VITE_DEFAULT_SPACES=https://chat.flotilla.social/
VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
VITE_PLATFORM_URL=https://app.flotilla.social VITE_PLATFORM_URL=https://app.flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms VITE_PLATFORM_TERMS=https://flotilla.social/terms
@@ -19,5 +20,6 @@ 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_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
VITE_GLITCHTIP_API_KEY= VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN= GLITCHTIP_AUTH_TOKEN=
+3 -1
View File
@@ -1,4 +1,5 @@
src/assets src/assets
.claude
target target
build build
.idea .idea
@@ -13,4 +14,5 @@ ios/App/Pods/
android/capacitor-cordova-android-plugins android/capacitor-cordova-android-plugins
android/app/src/androidTest android/app/src/androidTest
android/app/src/test android/app/src/test
node_modules
.svelte-kit
@@ -1,12 +1,17 @@
name: Docker name: Container Image Build and Publish
on: on:
push: push:
branches: [master] branches: [master]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env: env:
REGISTRY: ghcr.io REGISTRY: gitea.coracle.social
IMAGE_NAME: coracle-social/flotilla IMAGE_NAME: coracle/flotilla
jobs: jobs:
build-and-push-image: build-and-push-image:
@@ -23,8 +28,8 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }} username: hodlbod
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.PACKAGE_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
@@ -32,6 +37,7 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=sha
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -45,6 +51,7 @@ jobs:
with: with:
context: . context: .
push: true push: true
target: production
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
+12 -6
View File
@@ -6,6 +6,11 @@
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Playwright
/test-results/
/playwright-report/
/playwright/.cache/
# Generated assets # Generated assets
static/favicon.ico static/favicon.ico
static/pwa-64x64.png static/pwa-64x64.png
@@ -27,11 +32,9 @@ android/app/src/main/assets/public/
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
build/ build/
build-server/
.svelte-kit/ .svelte-kit/
.next/
# Rust/Tauri
*target/
src-tauri/binaries/
# iOS # iOS
ios/App/App/public ios/App/App/public
@@ -67,9 +70,12 @@ GoogleService-Info.plist
# IDEs and editors # IDEs and editors
.roo .roo
.idea/ .idea
.vscode/ .vscode
.claude
.local
# OS generated # OS generated
.DS_Store .DS_Store
Thumbs.db Thumbs.db
package-lock.json
+57
View File
@@ -1,5 +1,62 @@
# Changelog # Changelog
# 1.8.0
* Fix relay badge overflow
* Suppress programmatic scroll when user is scrolling
* Fix vertical alignment of emoji and overflow buttons in shared event action row
* Use type=email for signup/login email inputs, validate password
* Improve toggle switch placement on settings screens
* Fix relay auth privacy toggle
* Improve field layout
* Add progress bar to signup flow
* Bundle emojis properly
* Rework hosting page
* Fix padding on pages on small screens
* Add richer link preview support
* Fix pasting into event summary
* Publish fewer join/claim requests
* Fix new messages not rendering in safari
* Avoid capturing stale cleanup function in chat
* Hide keyboard on app resume
* Add email rendering support
* Fix bunker login
* Fix undefined chat draft key
* Allow sharing to chat without a message
* Make sure to show date on calendar events when embedded
* Improve space search
# 1.7.4
* Fix safe area inset for FAB
# 1.7.3
* Add native share support for space invites
* Stop sending duplicate requests per room
* Add more robust thumbnail url generation
* Make space reordering discoverable with smoother drag animation
* Improve relay member list
* Add room mentions and clickable room/relay refs
* Support native clipboard image paste on mobile
* publish kind 9 quote after room content creation for cross-client interoperability
* Improve feed pagination logic and performance
* Support Aegis URL scheme for NIP-46 login
* Various UI and bug fixes
* Raise message size limit in chat
* Fix realtime updates for room members and admins
* Add video to calls
* Remove follow graph building
* Add start chat FAB
* Add drafts
* Redesign toast notifications
* Remove room/space leave indications
* Hide report badge for non-admin users
* Add polls
* Add search to recent activity page
* Fix notification badge on mobile nav
* Change audio devices in call
# 1.7.2 # 1.7.2
* Fix race condition in nip 46 * Fix race condition in nip 46
+24 -25
View File
@@ -1,32 +1,31 @@
# Stage 1: Build # Build and run the Flotilla web server.
# Uses .env from build context for config (logo, branding, etc.) #
# Optional: docker build --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) -t flotilla . # docker build -t flotilla .
# docker run -p 3000:3000 flotilla
#
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
# A .env in the build context is picked up by build.sh for branding config.
FROM node:20-bookworm AS builder # https://pnpm.io/docker#example-3-build-on-cicd
FROM node:24-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends curl ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@latest RUN corepack enable
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm i
# Copy everything (including .env when present) - build.sh will source it
COPY . .
ARG VITE_BUILD_HASH
ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
ENV NODE_OPTIONS=--max_old_space_size=16384 ENV NODE_OPTIONS=--max_old_space_size=16384
COPY package.json pnpm-lock.yaml ./
RUN pnpm i --frozen-lockfile
COPY . .
ARG VITE_BUILD_HASH
RUN pnpm run build RUN pnpm run build
RUN pnpm run build:server
FROM node:20-alpine FROM node:24-slim AS production
ENV NODE_ENV=production
WORKDIR /app WORKDIR /app
COPY --from=builder /app/build /app/build
# Copy only the built output - no source, no .env, no dev deps COPY --from=builder /app/build-server/server.js /app/server.js
COPY --from=builder /app/build ./build EXPOSE 3000
USER node
CMD ["npx", "serve", "-s", "build"] CMD ["node", "server.js"]
+26 -5
View File
@@ -8,13 +8,34 @@ If you would like to be interoperable with Flotilla, please check out this guide
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` 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 **Platform branding**
- `VITE_PLATFORM_URL` - The url where the app will be hosted - `VITE_PLATFORM_URL` - The url where the app will be hosted
- `VITE_PLATFORM_NAME` - The name of the app - `VITE_PLATFORM_NAME` - The name of the app
- `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file. - `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file.
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color - `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
- `VITE_PLATFORM_DESCRIPTION` - A description of the app - `VITE_PLATFORM_DESCRIPTION` - A description of the app
- `VITE_PLATFORM_TERMS` - URL to your terms of service page
- `VITE_PLATFORM_PRIVACY` - URL to your privacy policy page
**Platform mode**
- `VITE_PLATFORM_RELAYS` - A comma-separated list of relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
**Defaults**
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
- `VITE_DEFAULT_SPACES` - A comma-separated list of relay urls that new users will be automatically joined to on signup
- `VITE_DEFAULT_RELAYS` - A comma-separated list of relay urls used as default outbox/inbox relays
- `VITE_DEFAULT_MESSAGING_RELAYS` - A comma-separated list of relay urls used for encrypted direct messages
- `VITE_DEFAULT_BLOSSOM_SERVERS` - A comma-separated list of blossom server urls used for file uploads
**Infrastructure**
- `VITE_INDEXER_RELAYS` - A comma-separated list of relay urls used for user profile/key lookup
- `VITE_SIGNER_RELAYS` - A comma-separated list of relay urls used for NIP-55 remote signers
- `VITE_BLOCKED_RELAYS` - A comma-separated list of relay urls that will be blocked
- `VITE_PUSH_SERVER` - URL of the push notification server
- `VITE_PUSH_BRIDGE` - WebSocket URL of the push notification relay bridge
- `VITE_VAPID_PUBLIC_KEY` - VAPID public key for web push notifications
- `VITE_POMADE_SIGNERS` - A comma-separated list of Pomade signer server URLs (3+ required to enable email signup)
- `VITE_THUMBNAIL_URL` - URL of the image thumbnail service
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. 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.
@@ -31,18 +52,18 @@ To run your own Flotilla, it's as simple as:
```sh ```sh
pnpm install pnpm install
pnpm run build pnpm run build
npx serve -s build pnpm run start
``` ```
Or, if you prefer to use a container: Or, if you prefer to use a container:
```sh ```sh
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
``` ```
Alternatively, you can copy the build files into a directory of your choice and serve it yourself: Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
```sh ```sh
mkdir ./mount mkdir ./mount
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount' docker run -v ./mount:/app/mount gitea.coracle.social/coracle/flotilla:latest bash -c 'cp -r build/* mount'
``` ```
+2 -2
View File
@@ -8,8 +8,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 44 versionCode 47
versionName "1.7.2" versionName "1.8.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2
View File
@@ -12,10 +12,12 @@ dependencies {
implementation project(':aparajita-capacitor-secure-storage') implementation project(':aparajita-capacitor-secure-storage')
implementation project(':capacitor-community-safe-area') implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-filesystem') implementation project(':capacitor-filesystem')
implementation project(':capacitor-keyboard') implementation project(':capacitor-keyboard')
implementation project(':capacitor-preferences') implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications') implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-android-dark-mode-support') implementation project(':capawesome-capacitor-android-dark-mode-support')
implementation project(':capawesome-capacitor-badge') implementation project(':capawesome-capacitor-badge')
implementation project(':nostr-signer-capacitor-plugin') implementation project(':nostr-signer-capacitor-plugin')
+3
View File
@@ -44,4 +44,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
</manifest> </manifest>
@@ -7,6 +7,7 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
@@ -76,6 +77,7 @@ class AndroidPushFallbackPlugin : Plugin() {
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java) val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
.setConstraints(constraints) .setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build() .build()
workManager.enqueueUniquePeriodicWork( workManager.enqueueUniquePeriodicWork(
@@ -43,7 +43,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
private const val TAG = "PushFallback" private const val TAG = "PushFallback"
private const val CHANNEL_ID = "flotilla_fallback" private const val CHANNEL_ID = "flotilla_fallback"
private const val CURSOR_PREFIX = "androidPushFallback.cursor." private const val CURSOR_PREFIX = "androidPushFallback.cursor."
private const val SOCKET_TIMEOUT_SECONDS = 20L private const val SOCKET_TIMEOUT_SECONDS = 30L
private const val REJECTED = "__REJECTED__" private const val REJECTED = "__REJECTED__"
private const val KIND_RELAY_AUTH = 22242 private const val KIND_RELAY_AUTH = 22242
private const val KIND_NIP46_RPC = 24133 private const val KIND_NIP46_RPC = 24133
@@ -72,6 +72,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
} }
override fun doWork(): Result { override fun doWork(): Result {
Log.i(TAG, "doWork() started")
if (isAppInForeground()) { if (isAppInForeground()) {
return Result.success() return Result.success()
} }
@@ -88,7 +90,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
val activeSince = state.optLong("activeSince", 0L) val activeSince = state.optLong("activeSince", 0L)
val seen = mutableSetOf<String>() val seen = mutableSetOf<String>()
var latestPair: Pair<String, JSONObject>? = null val newEvents = mutableListOf<Pair<String, JSONObject>>()
for (sub in subscriptions) { for (sub in subscriptions) {
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
@@ -102,23 +104,19 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
for (event in result.events) { for (event in result.events) {
val id = event.optString("id", "") val id = event.optString("id", "")
if (id.isNotEmpty() && seen.add(id)) { if (id.isNotEmpty() && seen.add(id)) {
val createdAt = event.optLong("created_at", 0L) newEvents.add(Pair(sub.relay, event))
if (latestPair == null || createdAt > latestPair!!.second.optLong("created_at", 0L)) {
latestPair = Pair(sub.relay, event)
}
} }
} }
} }
if (latestPair != null) { for ((relay, event) in newEvents) {
val (relay, event) = latestPair!!
postNotification(relay, event) postNotification(relay, event)
} }
return Result.success() return Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Worker failed", e) Log.e(TAG, "Worker failed", e)
return Result.success() return Result.retry()
} finally { } finally {
pool.closeAll() pool.closeAll()
client.dispatcher.executorService.shutdown() client.dispatcher.executorService.shutdown()
@@ -214,7 +212,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.build() .build()
NotificationManagerCompat.from(context).notify(1, notification) val notificationId = id.hashCode().let { if (it == 0) 1 else it }
NotificationManagerCompat.from(context).notify(notificationId, notification)
} }
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean { private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
+7 -1
View File
@@ -11,6 +11,9 @@ project(':capacitor-community-safe-area').projectDir = new File('../node_modules
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android')
include ':capacitor-clipboard'
project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard/android')
include ':capacitor-filesystem' include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android') project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android')
@@ -23,6 +26,9 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@
include ':capacitor-push-notifications' include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android') project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share/android')
include ':capawesome-capacitor-android-dark-mode-support' include ':capawesome-capacitor-android-dark-mode-support'
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android') project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
@@ -30,4 +36,4 @@ include ':capawesome-capacitor-badge'
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android') project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android')
include ':nostr-signer-capacitor-plugin' include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin/android') project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin/android')
+12
View File
@@ -0,0 +1,12 @@
import {expect, test} from "@playwright/test"
test("boots the SPA on the home page", async ({page}) => {
const response = await page.goto("/")
expect(response?.ok()).toBeTruthy()
// adapter-static serves an empty shell that hydrates client-side, so the presence of
// rendered text proves the Svelte app actually mounted (not just that a file was served).
// TODO: tighten this to assert concrete onboarding UI once the markup is settled.
await expect(page.locator("body")).toContainText(/\S/, {timeout: 15_000})
})
+29
View File
@@ -0,0 +1,29 @@
import type {SignedEvent} from "@welshman/util"
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
import relay1Events from "./fixtures/relay1.json"
// Fake relay urls used by tests. Each maps to a json fixture under ./fixtures/ and an entry in
// EVENTS_BY_RELAY below. To add a relay: drop a `<name>.json` file in ./fixtures/, import it, add a
// url here, and wire it into EVENTS_BY_RELAY.
export const FIXTURE_RELAYS = {
relay1: "wss://relay1.test/",
} as const
// The events each fake relay serves. The json files hold static, pre-signed events: schnorr
// signatures are non-deterministic, so events are signed once and committed verbatim (they pass
// verifyEvent, which netContext.isEventValid enforces). Regenerate with @welshman/signer:
// await Nip01Signer.fromSecret(secret).sign(makeEvent(kind, {content, created_at}))
const EVENTS_BY_RELAY: Record<string, SignedEvent[]> = {
[FIXTURE_RELAYS.relay1]: relay1Events as SignedEvent[],
}
// Build a RelayMockConfig populating the given fixture relays (all of them when none are passed).
// Any relay not included returns nothing, keeping tests offline.
export const relayFixtures = (...urls: string[]): RelayMockConfig => {
const selected = urls.length > 0 ? urls : Object.keys(EVENTS_BY_RELAY)
return {
relays: Object.fromEntries(selected.map(url => [url, EVENTS_BY_RELAY[url] ?? []])),
}
}
+29
View File
@@ -0,0 +1,29 @@
[
{
"kind": 0,
"content": "{\"name\":\"Alice\"}",
"tags": [],
"created_at": 1700000000,
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"id": "9b3d138641b38364945b20d800268006c2cb7d974bb4b1d63a9f90f5ab974b90",
"sig": "de6b86274e7bcf6c02aa881ada1feee9e01ba320691711d4975916b3cd231ab43cf469a47c5db99503ed72707d5db85fede1ad3763c4fbd7c998d04f00eda6bc"
},
{
"kind": 1,
"content": "hello from the fixture relay",
"tags": [],
"created_at": 1700000000,
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"id": "b9874875bfa8d830c5c9ef3673104360cf21b94848a311febfaf52f0a652b1a9",
"sig": "85df94a2e9884ac3d280145492d5191cde2948d49a824c443a1f5d2143633eff1e1789fa7e8843b6efc3dd2dc0d7e33322edb628125d8e35de8ddca1d06ca970"
},
{
"kind": 1,
"content": "reply from bob",
"tags": [],
"created_at": 1700000001,
"pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
"id": "171dcbdd63d474ba46da609e8b0104cbcf4801fbb581b6c343d9426280f9e1be",
"sig": "eecf26e616a6b70dc67c7eae16fc4fe159314647ba1d4581332257de7f070410aa9a9e41f571b9403ff145d16c9b32766846fce08516201263e25cf08c1ed8f1"
}
]
+30
View File
@@ -0,0 +1,30 @@
import type {Page} from "@playwright/test"
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
// Must match RELAY_MOCKS_KEY in src/lib/test/relayMocks.ts.
const RELAY_MOCKS_KEY = "__RELAY_MOCKS__"
// Hard safety net: intercept every real websocket so a test can never reach the network, even if
// some code path opens a socket directly (e.g. relay AUTH) rather than going through the adapter
// layer. We never call route.connectToServer(), so the socket connects to Playwright's in-process
// mock and simply receives nothing.
export const blockWebsockets = (page: Page) => page.routeWebSocket(/^wss?:\/\//, () => {})
// Inject the relay-mock config the app reads on startup. addInitScript runs before any page script
// on every navigation, so this must be called before page.goto().
export const injectRelayConfig = (page: Page, config: RelayMockConfig) =>
page.addInitScript(
([key, value]) => {
Object.assign(window, {[key]: value})
},
[RELAY_MOCKS_KEY, config] as const,
)
// Full network isolation plus optional fixtures, in one call. With no config, every relay returns
// nothing (requirement 1). Pass {relays: {url: events}} to populate specific relays (requirement 2).
export const setupRelayMocks = async (page: Page, config: RelayMockConfig = {}) => {
await blockWebsockets(page)
await injectRelayConfig(page, config)
}
export type {RelayMockConfig}
+29 -11
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 48; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -131,8 +131,9 @@
504EC2FC1FED79650016851F /* Project object */ = { 504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920; LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 920; LastUpgradeCheck = 2630;
TargetAttributes = { TargetAttributes = {
504EC3031FED79650016851F = { 504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2; CreatedOnToolsVersion = 9.2;
@@ -257,6 +258,7 @@
CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES; CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_EMPTY_BODY = YES;
@@ -264,8 +266,10 @@
CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -275,8 +279,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = S26U9DYW3A;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@@ -295,6 +301,7 @@
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@@ -314,6 +321,7 @@
CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES; CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_EMPTY_BODY = YES;
@@ -321,8 +329,10 @@
CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -332,8 +342,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = S26U9DYW3A;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -345,7 +357,9 @@
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
@@ -358,14 +372,16 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
MARKETING_VERSION = 1.7.2; "$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.8.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -385,14 +401,16 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
MARKETING_VERSION = 1.7.2; "$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
+3 -1
View File
@@ -24,8 +24,10 @@
<false/> <false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key>
<string>Flotilla uses the camera when you enable it in a voice room.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Flotilla uses the microphone for voice chat in rooms.</string> <string>Flotilla uses the microphone when you enable it in a voice room.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>remote-notification</string> <string>remote-notification</string>
+3 -1
View File
@@ -14,12 +14,14 @@ def capacitor_pods
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage' pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area' pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard'
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem' pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard' pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard'
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences' pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications' pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications'
pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share'
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge' pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_2704ecccfd05fcfb1ad8852744422b7c/node_modules/nostr-signer-capacitor-plugin' pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin'
end end
target 'Flotilla Chat' do target 'Flotilla Chat' do
+1
View File
@@ -12,6 +12,7 @@ if (execSync('git status --porcelain', { encoding: 'utf8' }).trim() && !force) {
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')) const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
pkg.pnpm = pkg.pnpm || {}
pkg.pnpm.overrides = pkg.pnpm.overrides || {} pkg.pnpm.overrides = pkg.pnpm.overrides || {}
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app" pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content" pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
+38 -40
View File
@@ -1,18 +1,18 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.7.2", "version": "1.8.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "./build.sh", "build": "./build.sh",
"build:server": "vite build --config vite.config.server.ts",
"start": "node server.js",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner", "release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:info": "tauri info",
"tauri:icons": "tauri icon assets/logo.png --output src-tauri/icons",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src", "lint": "prettier --check src && eslint src",
"test": "playwright test",
"test:ui": "playwright test --ui",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write", "format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format:all": "prettier --write src", "format:all": "prettier --write src",
"prepare": "husky" "prepare": "husky"
@@ -20,25 +20,27 @@
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@sveltejs/kit": "^2.50.1", "@playwright/test": "^1.49.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/kit": "^2.61.1",
"@tauri-apps/cli": "^2.9.6", "@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/postcss": "^4.2.2",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/node": "^25.9.1",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^9.1.2", "eslint-config-prettier": "^9.1.2",
"eslint-plugin-svelte": "^2.46.1", "eslint-plugin-svelte": "^2.46.1",
"globals": "^15.15.0", "globals": "^15.15.0",
"postcss": "^8.5.6", "postcss": "^8.5.15",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1", "prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.48.0", "svelte": "^5.55.9",
"svelte-check": "^4.3.5", "svelte-check": "^4.3.5",
"tailwindcss": "^3.4.19", "tailwindcss": "^4.2.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.53.1", "typescript-eslint": "^8.53.1",
"vite": "^5.4.21" "vite": "^6.4.2"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -47,62 +49,58 @@
"@capacitor/android": "^8.0.1", "@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
"@capacitor/cli": "^8.0.1", "@capacitor/cli": "^8.0.1",
"@capacitor/clipboard": "^8.0.1",
"@capacitor/core": "^8.0.1", "@capacitor/core": "^8.0.1",
"@capacitor/filesystem": "^8.1.0", "@capacitor/filesystem": "^8.1.0",
"@capacitor/ios": "^8.0.1", "@capacitor/ios": "^8.0.1",
"@capacitor/keyboard": "^8.0.0", "@capacitor/keyboard": "^8.0.0",
"@capacitor/preferences": "^8.0.0", "@capacitor/preferences": "^8.0.0",
"@capacitor/push-notifications": "^8.0.0", "@capacitor/push-notifications": "^8.0.0",
"@capacitor/share": "^8.0.1",
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0", "@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
"@capawesome/capacitor-badge": "^8.0.0", "@capawesome/capacitor-badge": "^8.0.0",
"@getalby/lightning-tools": "^6.1.0", "@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2", "@getalby/sdk": "^5.1.2",
"@hono/node-server": "^2.0.0",
"@noble/curves": "^1.9.7", "@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.2", "@pomade/core": "^0.3.0",
"@poppanator/sveltekit-svg": "^4.2.1", "@poppanator/sveltekit-svg": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2", "@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^0.6.8", "@vite-pwa/sveltekit": "^1.1.0",
"@welshman/app": "^0.8.12", "@welshman/app": "^0.8.16",
"@welshman/content": "^0.8.12", "@welshman/content": "^0.8.16",
"@welshman/editor": "^0.8.12", "@welshman/editor": "^0.8.16",
"@welshman/feeds": "^0.8.12", "@welshman/feeds": "^0.8.16",
"@welshman/lib": "^0.8.12", "@welshman/lib": "^0.8.16",
"@welshman/net": "^0.8.12", "@welshman/net": "^0.8.16",
"@welshman/router": "^0.8.12", "@welshman/router": "^0.8.16",
"@welshman/signer": "^0.8.12", "@welshman/signer": "^0.8.16",
"@welshman/store": "^0.8.12", "@welshman/store": "^0.8.16",
"@welshman/util": "^0.8.12", "@welshman/util": "^0.8.16",
"cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2", "compressorjs-next": "^1.1.2",
"daisyui": "^4.12.24", "daisyui": "^5.5.19",
"date-picker-svelte": "^2.17.0", "date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1", "emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"hono": "^4.12.23",
"husky": "^9.1.7", "husky": "^9.1.7",
"idb": "^8.0.3", "idb": "^8.0.3",
"livekit-client": "^2.17.2", "livekit-client": "^2.17.2",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main", "nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4", "nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.7.2",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2", "throttle-debounce": "^5.0.2",
"tippy.js": "^6.3.7" "tippy.js": "^6.3.7"
}, },
"pnpm": { "packageManager": "pnpm@11.5.1"
"ignoredBuiltDependencies": [
"esbuild"
],
"onlyBuiltDependencies": [
"sharp",
"nostr-signer-capacitor-plugin"
],
"overrides": {
"sharp": "0.35.0-rc.0"
}
}
} }
+27
View File
@@ -0,0 +1,27 @@
import {defineConfig, devices} from "@playwright/test"
// E2E tests live in ./e2e and run against the dev server (port 1847 from vite.config.ts).
// Run with `pnpm test:e2e` (after `pnpm exec playwright install` to fetch browsers).
export default defineConfig({
testDir: "e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "html",
use: {
baseURL: "http://localhost:1847",
trace: "on-first-retry",
},
// Boots the SvelteKit dev server before the suite and reuses one if already running locally.
webServer: {
command: "pnpm dev",
url: "http://localhost:1847",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{name: "chromium", use: {...devices["Desktop Chrome"]}},
{name: "firefox", use: {...devices["Desktop Firefox"]}},
{name: "webkit", use: {...devices["Desktop Safari"]}},
],
})
+2850 -2927
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
allowBuilds:
nostr-signer-capacitor-plugin: true
cbor-extract: false
esbuild: false
sharp: true
minimumReleaseAgeExclude:
- '@pomade/core'
- '@welshman/app'
- '@welshman/content'
- '@welshman/editor'
- '@welshman/feeds'
- '@welshman/lib'
- '@welshman/net'
- '@welshman/router'
- '@welshman/signer'
- '@welshman/store'
- '@welshman/util'
overrides:
sharp: 0.35.0-rc.0
+1 -2
View File
@@ -1,6 +1,5 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, "@tailwindcss/postcss": {},
autoprefixer: {},
}, },
} }
-2
View File
@@ -1,2 +0,0 @@
[toolchain]
channel = "1.92.0"
+282
View File
@@ -0,0 +1,282 @@
import path from "node:path"
import {promises as fs} from "node:fs"
import {fileURLToPath} from "node:url"
import "dotenv/config"
import {serve} from "@hono/node-server"
import {serveStatic} from "@hono/node-server/serve-static"
import {loadRelay} from "@welshman/app"
import {displayRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {load} from "cheerio"
import {Hono} from "hono"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const BUILD_DIR = path.join(__dirname, "build")
const INDEX_PATH = path.join(BUILD_DIR, "index.html")
const PORT = parseInt(process.env.PORT || "", 10) || 3000
const HOST = process.env.HOST || "0.0.0.0"
let TEMPLATE_HTML = ""
try {
TEMPLATE_HTML = await fs.readFile(INDEX_PATH, "utf8")
} catch (error) {
console.error(`Unable to read ${INDEX_PATH}. Run "pnpm run build" first.`)
process.exit(1)
}
const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME
const PLATFORM_DESCRIPTION = process.env.VITE_PLATFORM_DESCRIPTION
// Match client-side decode logic
const decodeRelay = url => {
try {
return normalizeRelayUrl(decodeURIComponent(url))
} catch {
return undefined
}
}
const requestUrlFromContext = context => {
const requestUrl = new URL(context.req.url)
const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim()
const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim()
if (forwardedProto === "http" || forwardedProto === "https") {
requestUrl.protocol = `${forwardedProto}:`
}
if (forwardedHost) {
requestUrl.host = forwardedHost
}
return requestUrl
}
const fetchRelayMeta = async relayUrl => {
if (!relayUrl) return undefined
try {
return await loadRelay(normalizeRelayUrl(relayUrl))
} catch (err) {
console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err)
return undefined
}
}
const buildDefaultImage = requestUrl => {
return new URL("/maskable-icon-512x512.png", requestUrl.origin).toString()
}
const getMetadataForInvite = async (url, match) => {
const relayParam = url.searchParams.get("r")
if (!relayParam) return undefined
const relayMetadata = await fetchRelayMeta(relayParam)
if (!relayMetadata) return undefined
const relayDisplay = displayRelayUrl(relayParam)
const spaceName = relayMetadata.name
const relayDescription = relayMetadata.description
const title = spaceName
? `Invite to ${spaceName} on ${PLATFORM_NAME}`
: `Invite to a Space on ${PLATFORM_NAME}`
const parts = []
if (spaceName) {
parts.push(`You are invited to join ${spaceName} on ${PLATFORM_NAME}.`)
} else {
parts.push(`You are invited to join a space on ${PLATFORM_NAME}.`)
}
if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`)
if (relayDescription) parts.push(relayDescription)
else parts.push(PLATFORM_DESCRIPTION)
const description = parts.join(" ")
const image =
relayMetadata.icon ||
relayMetadata.picture ||
relayMetadata.image ||
buildDefaultImage(url)
return {
title,
description,
image,
url: url.toString(),
site: url.origin,
}
}
const getMetadataForSpace = async (url, match) => {
const relayParam = decodeRelay(match[1])
if (!relayParam) return undefined
const relayMetadata = await fetchRelayMeta(relayParam)
if (!relayMetadata) return undefined
const spaceName = relayMetadata.name || displayRelayUrl(relayParam)
return {
title: `${spaceName} on ${PLATFORM_NAME}`,
description: relayMetadata.description || PLATFORM_DESCRIPTION,
image:
relayMetadata.icon ||
relayMetadata.picture ||
relayMetadata.image ||
buildDefaultImage(url),
url: url.toString(),
site: url.origin,
}
}
const getMetadataForSpaceSection = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
const section = match[2]
const sectionName = section.charAt(0).toUpperCase() + section.slice(1)
spaceMeta.title = `${sectionName} on ${spaceMeta.title}`
return spaceMeta
}
const getMetadataForSpaceItem = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
const section = match[2]
let itemType = "Item"
if (section === "calendar") itemType = "Event"
if (section === "threads") itemType = "Thread"
if (section === "polls") itemType = "Poll"
if (section === "goals") itemType = "Goal"
if (section === "classifieds") itemType = "Listing"
spaceMeta.title = `${itemType} on ${spaceMeta.title}`
return spaceMeta
}
const getMetadataForRoom = async (url, match) => {
const spaceMeta = await getMetadataForSpace(url, match)
if (!spaceMeta) return undefined
// Room metadata requires fetching from Nostr, which can be added later.
spaceMeta.title = `Room on ${spaceMeta.title}`
return spaceMeta
}
const routes = [
[/^\/join\/?$/, getMetadataForInvite],
[/^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/, getMetadataForSpaceSection],
[/^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/, getMetadataForSpaceItem],
[/^\/spaces\/([^/]+)\/([^/]+)\/?$/, getMetadataForRoom],
[/^\/spaces\/([^/]+)\/?$/, getMetadataForSpace],
]
const getMetadataForRoute = async url => {
for (const [regex, getMetadata] of routes) {
const match = url.pathname.match(regex)
if (match) {
try {
return await getMetadata(url, match)
} catch (err) {
console.error(`Error generating metadata for route ${url.pathname}:`, err)
return undefined
}
}
}
return undefined
}
const injectMeta = metadata => {
const $ = load(TEMPLATE_HTML)
if (metadata.title) {
$("title").text(metadata.title)
$('meta[property="og:title"]').attr("content", metadata.title)
$('meta[name="twitter:title"]').attr("content", metadata.title)
}
if (metadata.description) {
$('meta[name="description"]').attr("content", metadata.description)
$('meta[property="og:description"]').attr("content", metadata.description)
$('meta[name="twitter:description"]').attr("content", metadata.description)
}
if (metadata.image) {
$('meta[property="og:image"]').attr("content", metadata.image)
$('meta[name="twitter:image"]').attr("content", metadata.image)
}
if (metadata.url) {
$('meta[property="og:url"]').attr("content", metadata.url)
$('meta[name="twitter:site"]').attr("content", metadata.site)
$('meta[name="twitter:url"]').attr("content", metadata.url)
$('link[rel="canonical"]').attr("href", metadata.url)
}
return $.html()
}
const app = new Hono()
// Only allow GET and HEAD requests
app.use("*", async (context, next) => {
const method = context.req.method
if (method !== "GET" && method !== "HEAD") {
return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"})
}
await next()
})
// Serve static assets with appropriate caching
app.use(
"*",
serveStatic({
root: BUILD_DIR,
onFound: (filePath, context) => {
const isImmutable = filePath.split(path.sep).join("/").includes("/_app/immutable/")
const cacheControl =
path.basename(filePath) === "index.html"
? "no-cache"
: isImmutable
? "public, max-age=31536000, immutable"
: "public, max-age=3600"
context.header("Cache-Control", cacheControl)
// Immutable assets are content-hashed by Vite, so the filename is itself a
// stable content identifier. Exposing it as an ETag lets clients that
// revalidate explicitly (e.g. emoji-picker-element checks its data source
// on every load) skip re-downloading large files when nothing changed.
if (isImmutable) {
context.header("ETag", `"${path.basename(filePath)}"`)
}
},
}),
)
// SPA fallback for routes that don't match static files
app.get("*", async context => {
const requestUrl = requestUrlFromContext(context)
const metadata = await getMetadataForRoute(requestUrl)
const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML
return context.html(html, 200, {
"Cache-Control": metadata ? "no-store" : "no-cache",
})
})
serve(
{
fetch: app.fetch,
hostname: HOST,
port: PORT,
},
() => {
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
},
)
+71
View File
@@ -0,0 +1,71 @@
{
"version": 1,
"skills": {
"welshman": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman/SKILL.md",
"computedHash": "586c6b142324e0a9043e7af16c662cee5f114d649367baba088282cc0de12734"
},
"welshman-app": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-app/SKILL.md",
"computedHash": "764a3bed16678b18e3935fee6069ceed965004ce4e624ae1a7edadfca6708ca1"
},
"welshman-content": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-content/SKILL.md",
"computedHash": "8ad4b3646e781d124c5332565cc4e0333664fcbcb5dccafc70b1d21266bcafd8"
},
"welshman-editor": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-editor/SKILL.md",
"computedHash": "dbc39e6506231d1071b75453a78d99bb90017c17a57fd087c659a1daae335536"
},
"welshman-feeds": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-feeds/SKILL.md",
"computedHash": "148bd556a0a08b9dc07b959c7ecd8b3259058dd2b3a3b2a2f2171c1cc669b25c"
},
"welshman-lib": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-lib/SKILL.md",
"computedHash": "16cf693a002d2e781c085a38c3b43f93c25ab5a0f43f9404c3fa2ead39139b50"
},
"welshman-net": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-net/SKILL.md",
"computedHash": "234d48ff9ebea01919011db7f471bf997574424c313090bcfaf440762f6e2284"
},
"welshman-router": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-router/SKILL.md",
"computedHash": "f37e0c08fa32b577786f33fa21462fabf325eee27d32bad87269466e94d7dd72"
},
"welshman-signer": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-signer/SKILL.md",
"computedHash": "a62c6d5211b904e4edb992ad2003765ab9956694bf1c30f3b4179c3d1d159a40"
},
"welshman-store": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-store/SKILL.md",
"computedHash": "2079746f8b5ac0b8ef3ba49afc31a120f9a3a17671b6e2512e8481c5d9979a5c"
},
"welshman-util": {
"source": "coracle-social/welshman",
"sourceType": "github",
"skillPath": "skills/welshman-util/SKILL.md",
"computedHash": "2d4c06937e9417c72347f1a54ae5c3688fbaa001a4f1c2a094f79f6637a99f33"
}
}
}
-4784
View File
File diff suppressed because it is too large Load Diff
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "flotilla"
version = "0.1.0"
edition = "2021"
[lib]
name = "flotilla_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
tauri = { version = "2.9.5", features = [] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default desktop capability for the main window",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

-2
View File
@@ -1,2 +0,0 @@
[toolchain]
channel = "1.92.0"
-6
View File
@@ -1,6 +0,0 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
-6
View File
@@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
flotilla_lib::run();
}

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