Compare commits

...

373 commits

Author SHA1 Message Date
mplorentz
bcfae8dea9 Merge branch 'dev' of ssh://forgejo.lorentz.is:4201/matt/flotilla into dev
All checks were successful
CI / lint-check-build (push) Successful in 3m58s
2026-09-23 17:07:34 -04:00
Coracle-Bot
bc7bc61f1f Keep a deleted room out of the space history when leaving its page 2026-09-23 07:05:01 +00:00
Coracle-Bot
b49fd0b6fd Give a modal history entry back when navigating out of it instead of replacing it (#631) 2026-09-23 04:03:15 +00:00
Coracle-Bot
180ab5cb24 Open a note in a modal instead of on a page of its own (#628) 2026-09-23 03:48:52 +00:00
Coracle-Bot
cfc30803b4 Open a note from anywhere on its card (#630) 2026-09-23 03:48:41 +00:00
Coracle-Bot
a677fa5911 Give own chat messages a primary border instead of a primary fill 2026-09-23 03:39:18 +00:00
Jon Staab
6a4ffbb9c4 Swap out icon 2026-09-22 20:14:24 -07:00
Jon Staab
84957e7fb8 Start out new users with some dashboard activity 2026-09-22 20:14:24 -07:00
Jon Staab
7688e7fd10 Publish desktop update manifests from pnpm release, keeping each Gitea release a draft until every platform is attached and signing and notarizing the macOS build. 2026-09-22 20:14:24 -07:00
Coracle-Bot
3a680033c5 Add a note detail page so a kind 1 note and its thread open in the app (#625) 2026-09-23 02:35:06 +00:00
Coracle-Bot
36cb34613f Put component classes in a cascade layer so utilities can override them 2026-09-23 00:03:45 +00:00
Coracle-Bot
298cab2e54 Review what a health check will do before applying it (#620) 2026-09-22 23:11:32 +00:00
Gaurav Chaudhary
fcba6b197c Add desktop auto-updates and Linux icon integration (#621) 2026-09-22 12:27:10 -07:00
Coracle-Bot
05c7d963c2 Wrap long words and links so a chat bubble cannot overflow the screen (#622) 2026-09-22 18:51:59 +00:00
Coracle-Bot
bcc36cbce4 Leave the space owner out of the assignee list 2026-09-22 18:40:41 +00:00
Coracle-Bot
0bbe0c3ae4 Run the e2e suite against a zooid that has listmethodassignees
The pinned 0.2.2 predates the method, so US-130 has no relay to answer it. This
digest is master, one commit past 0.2.2, and that commit also drops NIP-86
signevent — nothing in flotilla or welshman calls it.
2026-09-22 18:40:41 +00:00
Coracle-Bot
90133dd465 List a space's admins and edit a member's permissions 2026-09-22 18:40:41 +00:00
Coracle-Bot
10004658ee Lay the home page out like the profile page (#617) 2026-09-22 18:22:45 +00:00
Coracle-Bot
bed1740689 Open a hosted relay admin panel by clicking its home page row 2026-09-22 18:17:32 +00:00
Coracle-Bot
47c390944b Show only name, host and status in the home hosting rows (#618) 2026-09-22 18:16:19 +00:00
Gaurav Chaudhary
929b6e4e41 fix: move generated key backup to home health checks (#587) 2026-09-22 17:57:47 +00:00
Coracle-Bot
a50b0160bd Authenticate a relay as soon as the user's lists name it 2026-09-22 16:49:13 +00:00
Gaurav Chaudhary
f67ea6fb36 Protect desktop secrets with OS-backed storage (#561) 2026-09-22 16:32:08 +00:00
Gaurav Chaudhary
d67309650c Fix encrypted audio attachment playback (#610) 2026-09-22 15:48:04 +00:00
Coracle-Bot
8cc970906d Read a message aloud from audio the browser decoded rather than bytes the app guessed at (#611) 2026-09-22 15:40:08 +00:00
Coracle-Bot
f2473d67b4 Read US-044 permalink off the query parameter it moved to 2026-09-22 13:59:14 +00:00
Coracle-Bot
4e73de7592 Show a thread reply count on its opening post 2026-09-22 13:59:14 +00:00
Coracle-Bot
3e85df5d02 Match US-129 thresholds exactly so the help text does not also match 2026-09-22 07:12:45 +00:00
Coracle-Bot
f44d99be8c Name the signup modal by its new title in US-001 2026-09-22 07:12:45 +00:00
Coracle-Bot
5c38fdbb44 Tighten the prose around chat requests 2026-09-21 20:57:07 +00:00
Coracle-Bot
939caadc32 Split the chat list into conversations and requests (#585) 2026-09-21 20:51:39 +00:00
Jon Staab
a734147811 linting 2026-09-21 13:39:35 -07:00
Coracle Bot
fa489d4b72 Redesign the goal list and detail pages (#582) 2026-09-21 20:25:01 +00:00
Jon Staab
590803f0a4 linting 2026-09-21 13:02:53 -07:00
Gaurav Chaudhary
b43309f0c3 Add native app shortcuts for common features (#604) 2026-09-21 19:51:15 +00:00
Coracle-Bot
31d9c6bd9c Wait for the page to settle before prompting to join a space (#607) 2026-09-21 19:48:16 +00:00
Jon Staab
f36b748a70 Scroll to unread/bottom of thread detail 2026-09-21 11:59:06 -07:00
Coracle-Bot
e42d6fa707 Read the permalink pointer out of the query in US-028 2026-09-21 18:21:47 +00:00
Coracle-Bot
14a3985442 Keep the admin delete toast wording 2026-09-21 18:20:42 +00:00
Coracle-Bot
94bb7adb33 Check the room admin list for the delete kind before sending a NIP-29 delete 2026-09-21 17:59:13 +00:00
Coracle-Bot
6b5947ae0e Upgrade to welshman 0.11.0 2026-09-21 17:59:11 +00:00
Coracle-Bot
7cb3feeeb3 Let a room admin delete a message with a NIP-29 delete-event op 2026-09-21 17:58:22 +00:00
Jon Staab
3f57607780 Only set programmatic scroll if room scroll will move 2026-09-21 10:57:47 -07:00
Jon Staab
2b26c033f6 Improve permalinks 2026-09-21 10:53:27 -07:00
Jon Staab
4b8fcdf196 Change permalink event style 2026-09-21 10:53:27 -07:00
Jon Staab
da6a63870a Require braces on conditionals 2026-09-21 10:53:27 -07:00
Jon Staab
ef6069e7a4 Tweak landing page when there's an invite code 2026-09-21 10:53:27 -07:00
Jon Staab
5bc499cf3a Add f-droid to the release pipeline 2026-09-21 10:53:27 -07:00
Jon Staab
1cbb862b33 Clean up useFallback for the push adapter 2026-09-21 10:53:27 -07:00
Jon Staab
cf6c025bf2 Consolidate release scripts 2026-09-21 10:53:27 -07:00
Coracle-Bot
e46f2d2054 Settle a permalink near the live end at the live end (#599) 2026-09-21 17:53:18 +00:00
Coracle-Bot
fd8123d428 Wait out the app second before Bob replies in US-024 2026-09-20 06:38:12 +00:00
Coracle-Bot
444d36ac35 Keep the unread dot on a content item you have not opened (#592) 2026-09-19 18:27:38 +00:00
Coracle-Bot
c4a67a4716 Stop webkit save-image callout swallowing a tap on a space icon (#589) 2026-09-19 18:26:52 +00:00
Coracle-Bot
a6b47bdb8b Close the space menu when the space you pick has a page behind it 2026-09-18 19:38:22 +00:00
Coracle-Bot
6e5b0dfd01 Center the share dialog link heading in a divider (#586) 2026-09-18 18:55:04 +00:00
Coracle-Bot
882b263701 Stop a closing dialog taking focus back from whatever moved on (#584) 2026-09-18 18:44:59 +00:00
Coracle-Bot
f2c9a02756 Name the message share menu item and offer a link from the share dialog 2026-09-18 18:27:58 +00:00
Coracle-Bot
ec835f7cfb Pin the e2e relay to a zooid digest (#578) 2026-09-18 18:18:32 +00:00
Coracle-Bot
0776befc17 Inline the room tag name to break the rooms/routes import cycle (#579) 2026-09-18 18:08:00 +00:00
Gaurav Chaudhary
2c980caa00 Added a source-buildable F-Droid Android distribution (#527) 2026-09-18 17:48:04 +00:00
Coracle-Bot
5d40a9bdc5 Carry a room loading state in the page bar instead of the transcript (#569) 2026-09-18 17:41:46 +00:00
Coracle-Bot
74a914eb5b Rebuild the room feed when the url anchor changes after mount (#571) 2026-09-18 17:07:25 +00:00
Coracle-Bot
e5debeacd8 Gate each space admin control on the management method behind it 2026-09-18 08:42:42 +00:00
Jon Staab
4fc3c02ced Load gitea token from .env.local 2026-09-17 16:36:31 -07:00
Coracle-Bot
0922a98eb6 Publish signed Android APKs as gitea releases for Obtainium 2026-09-17 23:16:47 +00:00
Jon Staab
05cb5c29f7 Spiff up readme 2026-09-17 13:40:35 -07:00
Coracle-Bot
049ecc8151 Give each direction of a feed its own span width 2026-09-17 20:17:11 +00:00
Jon Staab
95b92a07f1 Show ANAME for apex domains 2026-09-17 11:53:03 -07:00
Coracle-Bot
5ca8eeff51 Scroll the space rail instead of hiding spaces behind an overflow menu 2026-09-17 18:36:03 +00:00
Coracle-Bot
f30ef6d344 Give a comment the same send delay a chat message gets (#562) 2026-09-17 17:46:55 +00:00
Coracle-Bot
caab696d36 Render the hosting panel relay icon with RelayIcon 2026-09-17 17:41:22 +00:00
Coracle-Bot
fe15d36a45 Publish featured content as the space owner rather than as the relay (#564) 2026-09-17 17:26:40 +00:00
Jon Staab
b6ee3504be Fix warning 2026-09-17 10:05:54 -07:00
Jon Staab
42ee057800 Add members_can_invite 2026-09-17 09:16:52 -07:00
Coracle-Bot
218afd48a5 Let any member curate the space library 2026-09-17 15:49:03 +00:00
Jon Staab
f727f2fcd1 Add plugins to dev globals 2026-09-16 15:27:12 -07:00
Coracle-Bot
17302a75ee Read route params from the params prop (#559) 2026-09-16 17:14:43 +00:00
Gaurav Chaudhary
ed7641117a Added desktop tray controls and reliable notification activation (#550) 2026-09-16 16:59:22 +00:00
Coracle-Bot
a7ca6bb9fb Build a space page once when it opens 2026-09-16 16:19:43 +00:00
Coracle-Bot
0ba6dc864c Wait for the article page before naming its action bar (#556) 2026-09-16 15:53:50 +00:00
Coracle-Bot
c234f9f198 Enter a space on the page it was left on or its details page 2026-09-16 15:37:02 +00:00
Coracle-Bot
903c5cb19a Name the resource in a console line that carries only its status 2026-09-16 06:48:40 +00:00
Coracle-Bot
10350dd622 Ask whether to transcribe a recording or send it as a voice note 2026-09-16 00:30:24 +00:00
Jon Staab
e6f277eeca Update changelog 2026-09-15 15:35:52 -07:00
Coracle-Bot
661b90dafa Let a feed span finish once half the relays it asked have answered 2026-09-15 22:10:28 +00:00
Jon Staab
c35a3c2ff0 Bump version 2026-09-15 14:42:24 -07:00
Jon Staab
698c38d487 Add mirror workflow 2026-09-15 13:51:38 -07:00
Coracle-Bot
97ccfc4d5c Keep the leave-space spinner up until the navigation home lands 2026-09-15 19:06:47 +00:00
Jon Staab
980003e836 Fix a test 2026-09-15 11:52:11 -07:00
Jon Staab
46097f3a15 remove dead code 2026-09-15 11:49:40 -07:00
Coracle-Bot
e6f0b6ec8a Cover dictation in the e2e suite (#545) 2026-09-15 17:59:36 +00:00
Coracle-Bot
d1040932a5 Assert the quote placeholder on a quote nothing can answer (#544) 2026-09-15 17:59:05 +00:00
Coracle-Bot
57177a0a88 Wait for a reordered space list to land before dragging again (#543) 2026-09-15 17:58:48 +00:00
Coracle-Bot
f30081a2f7 Recreate the zooid container in teardown and let its network churn settle 2026-09-15 17:37:13 +00:00
Coracle-Bot
7aba172a9c Fall back to the link when an encrypted image cannot be fetched, and drop writes whose database closed 2026-09-15 17:07:43 +00:00
Coracle-Bot
9f7bd3ba2e Fail an e2e test when the page threw or the browser refused our code 2026-09-15 17:07:40 +00:00
Coracle-Bot
1881a3710f Offer a space section when the relay holds one or something under it is unread 2026-09-15 15:33:32 +00:00
Coracle-Bot
241d2b3737 Derive the CSP script hashes from app.html 2026-09-15 07:14:24 +00:00
Jon Staab
7dffabd9d9 Bump welshman, use new message kind 2026-09-14 16:34:45 -07:00
Jon Staab
75451b71e3 Add some skill files 2026-09-14 14:24:36 -07:00
Jon Staab
40889a91cf Update skills 2026-09-14 14:24:36 -07:00
Coracle-Bot
0ffeccb372 Name the hidden reply count on the show-earlier control 2026-09-14 21:11:21 +00:00
Coracle-Bot
6be382a6d1 Drop thread pagination for one continuous list of replies under the root post 2026-09-14 21:10:06 +00:00
Coracle-Bot
a444a8e352 Stop bolding the url while a link preview loads (#534) 2026-09-14 21:02:29 +00:00
Coracle-Bot
9e9c38f9b2 Redesign the calendar with month, week and agenda views (#531) 2026-09-14 21:01:53 +00:00
Coracle-Bot
846480b71e Render a goal progress bar and its fixtures in the same units as its label 2026-09-14 19:29:05 +00:00
Coracle-Bot
3ff985e117 Bump welshman to 0.10.7 2026-09-14 19:23:58 +00:00
Coracle-Bot
0aeca3af3e Let welshman decide where a zap receipt should land 2026-09-14 19:22:51 +00:00
Coracle-Bot
801fbb8918 Total zap goals in millisats through welshman 2026-09-14 19:22:13 +00:00
Coracle-Bot
38bf7bd566 Page room history back from the anchor instead of rendering it as it arrives 2026-09-14 18:28:36 +00:00
Gaurav Chaudhary
8147d50c9f Added cross-platform desktop packaging (#526) 2026-09-14 17:58:14 +00:00
Coracle-Bot
3acc447a87 Attach the browser console to a failing e2e test (#525) 2026-09-14 17:29:55 +00:00
Coracle-Bot
8c81550695 Add relay data import and export to the hosting panel 2026-09-14 17:29:01 +00:00
Gaurav Chaudhary
0460c94395 Fix desktop dialog focus and room layout overflow (#517) 2026-09-12 17:03:24 +00:00
Coracle-Bot
a2c4dcec44 Take the timing races out of US-024's message order and US-119's play toggle (#519) 2026-09-12 13:24:06 +00:00
Coracle-Bot
0420c59c5a Read supportedmethods for the logged in pubkey rather than for the relay 2026-09-12 08:31:37 +00:00
Coracle-Bot
f613e7cadd Show a clickable card naming the url while a link preview loads (#516) 2026-09-12 03:44:21 +00:00
Coracle-Bot
2464389e2b Keep modal state in a plain module instead of a rune file (#514) 2026-09-12 03:44:06 +00:00
Jon Staab
c50be161d2 Add feature matrix 2026-09-11 16:47:17 -07:00
Jon Staab
1a3f9da806 Tweak pricing page 2026-09-11 15:13:55 -07:00
Jon Staab
7896d8ce7a Remove logging 2026-09-11 15:13:55 -07:00
Coracle-Bot
e5808c11ac Read the modal stack from page state instead of the deprecated page store 2026-09-11 17:10:41 +00:00
Coracle-Bot
be4a4e0805 Link the user's own picture and name in settings to their profile page 2026-09-11 16:53:29 +00:00
Coracle-Bot
f11be3be8a Stop the remove icon being clipped on profile multi-select badges 2026-09-11 16:31:21 +00:00
Coracle-Bot
32e46da440 Stack the hosting relay row and corner its alert dismiss on mobile 2026-09-11 16:30:52 +00:00
Coracle-Bot
cfae0a105c Open an article card away from its footer 2026-09-11 16:09:13 +00:00
Gaurav Chaudhary
9b43ff72a9 Simplify desktop development workflow after review
Refs #51

Signed-off-by: Gaurav Chaudhary <chaudharygaurav2004@gmail.com>
2026-09-11 17:59:23 +05:30
Gaurav Chaudhary
f8565aecb0 Add desktop live reload and explicit local startup
Refs #51

Signed-off-by: Gaurav Chaudhary <chaudharygaurav2004@gmail.com>
2026-09-11 17:23:30 +05:30
Coracle-Bot
19f5491024 Widen the library grid so a pinned note is not squished 2026-09-11 01:35:46 +00:00
Coracle-Bot
791c6f1a04 Size content grids to their container instead of the window 2026-09-11 01:20:10 +00:00
Jon Staab
bd78b6cd59 Bump deps 2026-09-10 18:01:11 -07:00
Coracle-Bot
fc86977a47 Show a person's most recent pinned note on their profile dialog 2026-09-11 00:56:32 +00:00
Coracle-Bot
e8a1f0bba7 Keep a dictation running when the composer that started it goes away (#500) 2026-09-11 00:48:11 +00:00
Coracle-Bot
5520b851de Keep a profile pinned note out of the feed store so it renders once (#499) 2026-09-11 00:47:26 +00:00
Coracle-Bot
ad480ad4a0 Tag a comment into the room the event it answers lives in (#497) 2026-09-11 00:32:41 +00:00
Coracle-Bot
e052191bb2 Show a person's NIP-38 status on their profile page and preview 2026-09-11 00:12:25 +00:00
Coracle-Bot
e8a4535030 Wrap an article card overflowing topic row instead of widening it past the card 2026-09-10 23:42:31 +00:00
Coracle-Bot
daad7bf5a2 Keep the feed spinner up while a list is still paging 2026-09-10 23:40:10 +00:00
Coracle-Bot
259091ba64 Land on a page of the space after deleting the room you are in 2026-09-10 23:14:52 +00:00
Aditya Chaudhary
e6ce3e5ec8 Redesign classifieds as a browsable marketplace (#383) 2026-09-10 18:21:49 +00:00
Coracle-Bot
56ee9b4c44 Share the e2e locators and relay list fixtures across specs 2026-09-10 18:20:03 +00:00
Jon Staab
6b49ef498d Use sveltekits' push/pop state helpers for modals 2026-09-10 10:04:06 -07:00
Jon Staab
246bdfcf7b Clean up navigation 2026-09-10 10:04:06 -07:00
Coracle-Bot
d6d77f629c Wait for a space entry redirect before opening search in the people specs 2026-09-10 13:35:07 +00:00
Coracle-Bot
214ef0f50c Seed a follow graph in the e2e harness 2026-09-10 03:58:59 +00:00
Coracle-Bot
c401cd8bd8 Serve every blossom spec from one mock 2026-09-10 03:34:55 +00:00
Coracle-Bot
6e6c1612fe Follow the mobile nav losing its home button in US-105 2026-09-10 03:31:06 +00:00
Coracle-Bot
fa5a9f6a1e Capture the scroll element in its effect so teardown does not read a cleared binding 2026-09-10 03:31:06 +00:00
Coracle-Bot
68dd74d1fd Ask the relays a feed event was seen on for its reactions and replies 2026-09-10 03:14:22 +00:00
Coracle-Bot
fa6e60ad05 Drop a conversation from the list when its last message is removed 2026-09-10 02:42:41 +00:00
Coracle-Bot
7e3a0c9741 Re-anchor the room feed on the present when jumping to newest 2026-09-10 01:51:18 +00:00
Coracle-Bot
528a04de38 Say what US-057 covers now that the server decides 2026-09-10 01:39:13 +00:00
Coracle-Bot
b8088762f2 Update the attach spec to the policy where the server decides 2026-09-10 01:35:59 +00:00
Coracle-Bot
936a66a86c Answer the blossom upload probe in the composer spec mock 2026-09-10 01:32:09 +00:00
Coracle-Bot
13c538b901 Let an image or a video past the editor uploader mime gate again 2026-09-10 01:26:01 +00:00
Coracle-Bot
ad630df98f Retry a failed publish with the thunk event so it keeps its id 2026-09-10 00:15:03 +00:00
Coracle-Bot
9b0d8a5533 Use welshman summarize instead of flotilla having its own copy 2026-09-09 22:32:03 +00:00
Coracle-Bot
9c5110d5c1 Size the thread detail post off its own column, not the screen 2026-09-09 22:16:09 +00:00
Jon Staab
b48f69cfa9 Fix audio control warning 2026-09-09 15:05:20 -07:00
Coracle-Bot
dc746ca93b Split HomeInboxItem into one component per conversation kind 2026-09-09 21:58:57 +00:00
Coracle-Bot
b1117d601a Use PLATFORM_NAME instead of hard-coding the app name 2026-09-09 21:58:57 +00:00
Coracle-Bot
e04516f4f0 Center the home hosting call to action and give it more vertical room 2026-09-09 21:58:57 +00:00
Coracle-Bot
24f60b70db Center the home page content and let its section rules reach the page edge 2026-09-09 21:58:57 +00:00
Coracle-Bot
32fa1635a7 Scope the home feed assertions to the Network section 2026-09-09 21:58:57 +00:00
Coracle-Bot
569d11a328 Drop the date from the home header and lay the network feed out in one column 2026-09-09 21:58:57 +00:00
Coracle-Bot
3e265a8a39 Show a timestamp on every note card and widen the home network feed to all content kinds 2026-09-09 21:58:57 +00:00
Coracle-Bot
b79025f827 Show a reply count on home network notes and stop virtualizing the feed 2026-09-09 21:58:57 +00:00
Coracle-Bot
9027634567 Remove the space recent activity view in favor of the home inbox 2026-09-09 21:58:57 +00:00
Coracle-Bot
0d1e60bf1d Add a scroll to top button, reorder the home dashboard on narrow screens, and paint cards further ahead 2026-09-09 21:58:57 +00:00
Coracle-Bot
ff3461a020 Show two levels of comments under each home network note 2026-09-09 21:58:57 +00:00
Coracle-Bot
27fa25c35c Load the home network feed with the app feed helpers 2026-09-09 21:58:57 +00:00
Coracle-Bot
124f9b47b2 Widen the home dashboard right rail 2026-09-09 21:58:57 +00:00
Coracle-Bot
4f32f6a3df Lay the home network out as a masonry of cards that loads as you scroll 2026-09-09 21:58:57 +00:00
Coracle-Bot
b50d35e5fa Reduce the home inbox to unread conversations and restyle the dashboard 2026-09-09 21:58:57 +00:00
Coracle-Bot
7825477155 Split space activity out of the home inbox into its own section 2026-09-09 21:58:57 +00:00
Coracle-Bot
a3d9106a43 Show the home hosting section whether or not the user hosts a space 2026-09-09 21:58:57 +00:00
Coracle-Bot
c4934db5ff Lay the home dashboard out as full-width sections 2026-09-09 21:58:57 +00:00
Coracle-Bot
d3ceaac1b9 Replace the home welcome screen with a dashboard 2026-09-09 21:58:57 +00:00
Coracle-Bot
2c06a107f2 Let space icons be dragged to reorder in the sidebar 2026-09-09 21:24:39 +00:00
Coracle-Bot
d1da119389 Parse content seeded into the editor so it contributes tags 2026-09-09 21:11:35 +00:00
Coracle-Bot
32878e6e11 Name a hashtag in a preview without eating its first letter 2026-09-09 21:09:56 +00:00
Coracle-Bot
dd7c15153e Pair content-visibility with its render-ahead observer in one component 2026-09-09 20:53:25 +00:00
Coracle-Bot
1485e290e9 Put the compose bar chrome in one component 2026-09-09 20:27:58 +00:00
Coracle-Bot
68a4eee8ba Keep the space menu open when a space is picked from its rail 2026-09-09 19:24:44 +00:00
Coracle-Bot
16cc2109d5 Drop the e2e specs that assert layout rather than behavior 2026-09-09 17:56:16 +00:00
Coracle-Bot
56c45fe386 Key each thread board by its room so a room named general does not collide with the always-present board 2026-09-09 17:51:52 +00:00
Coracle-Bot
3739ea7119 Switch the thread board to the table when the board is wide enough, not the page 2026-09-09 17:50:52 +00:00
Coracle-Bot
057f0bc2bb Stop repeating the thread title in the opening post 2026-09-09 17:33:01 +00:00
Coracle-Bot
98290e1a9b Put a create thread button on every board and drop the room picker 2026-09-09 17:22:07 +00:00
Coracle-Bot
76bbacdbd6 Preview a notification by what the message says rather than by the entity it quotes 2026-09-09 16:59:54 +00:00
Coracle-Bot
78472f58b0 Read entities aloud by name, and give the audio a length the player can trust 2026-09-09 16:49:47 +00:00
Coracle-Bot
89cc1b337c Synthesize speech with a model OpenRouter serves, as playable mp3 2026-09-09 16:49:47 +00:00
Coracle-Bot
e66dde1ea0 Read a message out loud from the message menu 2026-09-09 16:49:47 +00:00
Coracle-Bot
7e82429a1a Let the threads page choose which board a new thread goes to 2026-09-09 15:22:44 +00:00
Coracle-Bot
5cf598d2ac Show the thread created from a message on the message 2026-09-09 15:06:00 +00:00
Coracle-Bot
caafda9553 Break same-second event ties by id so every client agrees on order 2026-09-09 03:45:03 +00:00
Coracle-Bot
f750bf8fd4 Show a message preview in web notifications 2026-09-09 03:31:20 +00:00
Coracle-Bot
0b4bb849f3 Upgrade welshman to 0.10.3 2026-09-09 02:51:51 +00:00
Coracle-Bot
4992b05ef1 Render command invocations from parsed content 2026-09-09 02:47:22 +00:00
Coracle-Bot
7eb1c8c141 Upgrade welshman to 0.10.2 2026-09-09 02:47:22 +00:00
Jon Staab
00413df68f Remove home button on mobile nav 2026-09-08 19:34:18 -07:00
Coracle-Bot
e3e688699f Size reaction pills to match the add reaction button 2026-09-09 01:14:24 +00:00
Jon Staab
696f9a3043 Fix tests 2026-09-08 18:08:20 -07:00
Coracle-Bot
03c5e7fd6e Add a home button to the mobile bottom nav 2026-09-08 23:21:23 +00:00
Coracle-Bot
6b324b3232 Drop the desktop smoke job from CI 2026-09-08 23:04:09 +00:00
Coracle-Bot
21aa5b165e Fall back to an inline link when a link preview fails 2026-09-08 22:36:23 +00:00
Coracle-Bot
78fe1e11f4 Let the blossom server decide which files can be attached 2026-09-08 22:12:45 +00:00
Coracle-Bot
1da59fb322 Resolve a link content type per url rather than per event 2026-09-08 21:59:09 +00:00
Coracle-Bot
8e46f5a15b Style the command arg bar like the reply bar 2026-09-08 21:14:31 +00:00
Coracle-Bot
2d92c21490 Wait on the composer rather than the send button in e2e specs (#434) 2026-09-08 18:17:30 +00:00
Coracle-Bot
90ba6cff4c Offer Send Message in the profile menu where there is no Message button (#432) 2026-09-08 18:04:37 +00:00
Coracle-Bot
9810695782 Open the space menu in a drawer instead of navigating away from the room (#425) 2026-09-08 17:23:22 +00:00
Coracle Bot
6818aa240f Hide the room jump-to-newest button once the loaded window reaches the present (#431) 2026-09-08 15:03:43 +00:00
Jon Staab
5639380380 Fix badges on muted rooms 2026-09-08 08:02:38 -07:00
Jon Staab
95621c936d Search dialog loading and escape to close 2026-09-08 08:02:38 -07:00
Jon Staab
f81453c612 Tweak search relay presentation and use 2026-09-08 08:02:38 -07:00
Coracle-Bot
7b145c525d Give a space with no icon a colored tile with its initials (#428) 2026-09-07 23:09:02 +00:00
Coracle-Bot
3bd821efc1 Render audio attachments as a player (#429) 2026-09-07 22:08:50 +00:00
Jon Staab
898ac18450 Tweak chat menu wording 2026-09-07 14:47:18 -07:00
Jon Staab
39a4798546 Get rid of relay settings 2026-09-07 14:42:06 -07:00
Jon Staab
9c5b3d42d2 Rework search dialogs 2026-09-07 14:39:43 -07:00
Coracle Bot
cfb5dd53b1 Pair the chat header plus button with a popover overflow menu (#427) 2026-09-07 20:39:58 +00:00
Coracle Bot
6da908edd0 Give the desktop smoke test the Electron runtime CI never installed (#426) 2026-09-07 19:42:39 +00:00
Gaurav Chaudhary
cec08e3353 Added minimal Capacitor Electron desktop baseline (#424) 2026-09-07 17:42:52 +00:00
Jon Staab
57905be904 Bump welshman 2026-09-07 10:34:40 -07:00
Gaurav Chaudhary
cd1fbca21d Close image modals with Escape (#415) 2026-09-07 17:04:54 +00:00
Jon Staab
17467808ca Add slash commands 2026-09-07 09:48:36 -07:00
Coracle-Bot
801568d590 Keep livekit and the icon data urls out of the eager bundle (#423)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-04 15:36:19 +00:00
Coracle-Bot
27fd98408f Use the event in the push payload when the relay sends one (#420)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-03 21:59:59 +00:00
Coracle-Bot
434fc954ce Keep the zap dialog open while connecting a wallet (#421)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-03 21:57:18 +00:00
Coracle-Bot
bec000b05c Show which item raised an unread dot on every content board (#419)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-03 20:13:21 +00:00
Coracle-Bot
4d7b052724 Show which threads are unread instead of clearing them on arrival (#417)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-03 18:50:02 +00:00
Jon Staab
15fcaf1279 Log unsigned events 2026-09-03 08:45:21 -07:00
Coracle-Bot
d87e516ffa Route inline quote links in-app instead of out to coracle (#409)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 23:47:15 +00:00
Coracle-Bot
097cde0201 Push a history entry when switching spaces instead of replacing the current one (#407)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 23:42:29 +00:00
Coracle-Bot
70e3b2bd32 Ignore the app icons and splash images the build generates (#408)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 23:23:20 +00:00
Coracle-Bot
f15f3b9c95 Never play the notification sound on native platforms (#406)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 22:59:56 +00:00
Coracle-Bot
4578644924 Show nav notification badges for activity under the current section (#404)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 22:43:18 +00:00
Coracle-Bot
c02072863a Make the e2e suite boot, and fix the specs that outlived their UI (#403)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 22:21:18 +00:00
Coracle-Bot
1d4be33326 Make the tab title unread count match the notification dots (#402)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 18:25:03 +00:00
Coracle-Bot
a4cec9caf2 Fix push notification taps landing on the space, and DM notifications never firing (#401)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 18:09:17 +00:00
Coracle-Bot
133cd1bba2 Ellipsize badge content instead of overflowing the container (#400)
Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/400
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 15:13:22 +00:00
Coracle-Bot
a56755f8f7 Restructure the mobile message menu around react, reply and zap (#397)
Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/397
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 15:12:31 +00:00
Coracle-Bot
b2e5378c22 Show a connection status toast on mobile while a space reconnects (#399)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 15:01:45 +00:00
Coracle-Bot
15f5d55075 Tighten the e2e harness comments (#398)
Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/398
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 13:50:57 +00:00
Coracle-Bot
ebde17fed0 Persist direct messages so history survives relay retention (#393)
Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/393
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 13:27:35 +00:00
Coracle-Bot
cffc0acfde Match relay rejection prefixes with a label tolerance (#395)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 00:27:29 +00:00
Jon Staab
cec44ab78b Bump welshman 2026-09-01 17:27:00 -07:00
Coracle-Bot
fde4b696f9 Make the spinner visible on filled buttons (#396)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-02 00:00:57 +00:00
Coracle-Bot
97d4222d35 Add an image by upload or url from one dialog (#388)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-01 23:55:01 +00:00
Coracle-Bot
7db286ed2d Give editor mention pills a distinct background (#394)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-01 22:44:58 +00:00
Coracle-Bot
368e984d17 Add a mute setting for rooms (#391)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-09-01 22:43:57 +00:00
Jon Staab
f83019a401 Fix some build warnings 2026-09-01 15:12:21 -07:00
Jon Staab
2d20985c62 Switch plausible domain 2026-09-01 11:37:09 -07:00
Jon Staab
4d517798ad Fix initial profile/relays load 2026-09-01 10:40:28 -07:00
Jon Staab
f2b13c6ab9 Bump welshman 2026-09-01 10:29:14 -07:00
Jon Staab
d974b98c1a Revert "Adjust primary nav icon sizes and fix alignment"
This reverts commit f474ca29e1.
2026-09-01 08:05:45 -07:00
Jon Staab
aa61a231ec Hide user favorite on own relays 2026-09-01 08:05:33 -07:00
Jon Staab
0ce9084e1d Add voice dictation 2026-08-31 22:12:34 -07:00
Coracle Bot
19f6516a4b Upload each PR build as a preview artifact (#390)
Co-authored-by: Coracle Bot <329+coracle-bot@noreply.coracle.social>
2026-09-01 04:51:57 +00:00
userAdityaa
89da2ac96a Move the article composer to a full page 2026-08-31 21:35:26 -07:00
Jon Staab
784072229b Bump welshman to get better reconnect policy 2026-08-31 20:58:40 -07:00
Jon Staab
7f3126ff11 Fix squished quote in DMs and a crash on refresh 2026-08-31 20:58:40 -07:00
userAdityaa
f474ca29e1 Adjust primary nav icon sizes and fix alignment 2026-08-31 20:39:51 -07:00
Jon Staab
44f1a186f5 Handle failed loaders, normalize relay urls to fix dms bug 2026-08-31 20:20:17 -07:00
Seydi Charyyev
88d8bee015 Format expected dates in the page rather than in node (#385)
Co-authored-by: Seydi Charyyev <seydi.charyev@gmail.com>
2026-08-31 23:18:07 +00:00
Coracle-Bot
ee5d71dc1d Show a loading state on settings save buttons (#387)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
2026-08-31 23:03:20 +00:00
Jon Staab
b4c36c2108 Deduplicate subscriptions in SpaceMenuNavItems 2026-08-31 15:10:46 -07:00
Jon Staab
4780727f09 Add content-visibility in a few places 2026-08-31 15:05:06 -07:00
Jon Staab
4f584e3f5d Optimize deriveDisplaysByPubkey 2026-08-31 13:56:42 -07:00
Jon Staab
8cb89f362c Add deriveLatestEvent for ProfileBadges 2026-08-31 10:31:58 -07:00
Jon Staab
3bf3927896 Optimize ThunkStatusOrError 2026-08-31 10:03:40 -07:00
Jon Staab
a14f599fcb only store room add/remove events for the current user 2026-08-31 09:07:06 -07:00
Jon Staab
2fb2b59a6c refactor rooms a tad 2026-08-31 08:57:39 -07:00
Jon Staab
fe3afa248d Fix scrolling to targeted message 2026-08-31 08:47:55 -07:00
Jon Staab
f5bd6e3852 Fix article touch target 2026-08-31 08:12:54 -07:00
Jon Staab
9f3521779c Add virtualization to room chat 2026-08-31 08:07:09 -07:00
Jon Staab
251e73663f Re-work feeds for performance 2026-08-29 19:58:47 -07:00
Jon Staab
8e33a722bb Remove spinner on empty state 2026-08-29 10:22:57 -07:00
Jon Staab
a6bf874280 Simplify tippy api 2026-08-29 10:20:39 -07:00
Jon Staab
749e87f228 Optimize tippy by lazily instantiation 2026-08-29 10:12:09 -07:00
Jon Staab
ee09c975f8 Thread context through to EventActivity 2026-08-29 09:48:04 -07:00
Jon Staab
0dab2cc779 Add feed context to improve performance of ReactionSummary 2026-08-29 09:34:44 -07:00
Jon Staab
7d436c482c Linting 2026-08-29 07:06:39 -07:00
Jon Staab
a2c5ef57fe remove old logging 2026-08-29 06:59:28 -07:00
Jon Staab
8dca2a7035 Fix cancelled delayed DM lingering on sender's screen, open the failed-delivery detail on click rather than hover, fix lightning address not showing after an edit, and zap-preset selector. 2026-08-28 16:54:46 -07:00
Jon Staab
43110f5356 Fix chat identity bug 2026-08-27 20:52:11 -07:00
Jon Staab
ac7108c2c4 Sync modal state with page state 2026-08-27 19:31:50 -07:00
Jon Staab
24a506ea85 Fix double-mounting of entire app when using goto with hashes in dev mode 2026-08-27 19:31:50 -07:00
Jon Staab
9b094b85e1 Update tests to work with new zooid version 2026-08-27 19:31:50 -07:00
Jon Staab
d4114bb2c6 Fix notification permission thing 2026-08-27 19:31:50 -07:00
Jon Staab
fd8a70c6d4 Display lightning address 2026-08-27 18:03:29 -07:00
Jon Staab
94b23557cf Fix icon picker, remove duplicate icons 2026-08-27 17:20:46 -07:00
Jon Staab
e79c97d73f Switch default relay icon fallback 2026-08-27 17:08:36 -07:00
Jon Staab
31890dfe90 Clean up voice room/chat layout 2026-08-27 17:03:06 -07:00
Aditya Chaudhary
2974faa2e9 fix: voice room UI overlap, sizing, and scroll button issues (#371)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-27 23:06:10 +00:00
Jon Staab
fe9b0fe87a Fix several tests 2026-08-27 15:51:31 -07:00
Agent
e3e2e2f57c Onboarded to fragua
Some checks failed
CI / lint-check-build (push) Has been cancelled
2026-08-25 10:23:35 -04:00
Jon Staab
637be41a8b Await deletions
Some checks are pending
CI / lint-check-build (push) Waiting to run
2026-08-22 12:36:25 -07:00
Jon Staab
35ed7bc09f React to addressed events getting updated for delete status 2026-08-22 11:40:05 -07:00
Jon Staab
735231f8bc Make some event rendering components reactive 2026-08-22 11:30:30 -07:00
Jon Staab
8d7b640d0f Fix thread permalinks 2026-08-22 11:20:33 -07:00
Jon Staab
99be43f396 Fix pagination collapse 2026-08-22 11:11:57 -07:00
Jon Staab
2edcfd9a79 Fix a test, avoid link nesting in article cards 2026-08-22 11:00:22 -07:00
Jon Staab
08821d27f4 Bump eslint 2026-08-22 10:32:51 -07:00
Jon Staab
5f3b964eec Bump welshman 2026-08-22 10:06:29 -07:00
Jon Staab
e869b2631e Fix US-039 spec 2026-08-22 09:24:36 -07:00
Jon Staab
ef1981c685 Fix the admin spec 2026-08-22 09:10:47 -07:00
Jon Staab
a4193c8629 Report clock drift in tests 2026-08-22 09:00:18 -07:00
Jon Staab
1c58c075a4 Bump welshman 2026-08-21 16:41:57 -07:00
Jon Staab
a28a4fd72a Show error toast on failed report resolution 2026-08-21 15:54:11 -07:00
Jon Staab
0e35cadebe Fix role create/edit colors 2026-08-21 14:11:13 -07:00
Jon Staab
3e4ae49eed Include space urls in hints for profile derivation 2026-08-21 13:47:06 -07:00
Jon Staab
5ecb811b24 Auto-cleanup 2026-08-21 11:29:15 -07:00
Jon Staab
4c21de57e2 Cleanup prose in e2e files 2026-08-20 14:58:26 -07:00
Jon Staab
196bea79b6 Add ci job 2026-08-20 14:43:33 -07:00
Jon Staab
aa2483fe78 Add tests for all user stories 2026-08-20 14:32:30 -07:00
Jon Staab
c53aaf8df8 Add e2e user stories 2026-08-19 10:32:47 -07:00
Jon Staab
d95ea3c905 Bump version 2026-08-18 15:22:25 -07:00
Jon Staab
e8455f2c8d Add a script to bump the version across web, ios, android 2026-08-18 15:22:14 -07:00
Jon Staab
d5dbb7fba1 Update server.js to use new welshman implementation 2026-08-18 15:13:12 -07:00
Jon Staab
56b9e6f031 Update changelog, bump version 2026-08-18 14:58:41 -07:00
Jon Staab
ce4ecedf7e De-emphasize link meta 2026-08-18 14:44:43 -07:00
Jon Staab
0a9d3cfb49 Use nip 86 for claim management 2026-08-18 12:55:08 -07:00
Jon Staab
8a0918d551 Merge tracker/events store into a single indexeddb store 2026-08-18 11:32:39 -07:00
Jon Staab
45cbd1d6ba Fix loading events to avoid lost provenance 2026-08-18 09:20:30 -07:00
Jon Staab
20f12a48a4 Fix session persistence on android 2026-08-17 16:10:00 -07:00
Jon Staab
b8ec94e548 Patch requestIdleCallback for android 2026-08-17 15:33:12 -07:00
Jon Staab
f71cdf3f8d Add lnvps link 2026-08-17 15:30:45 -07:00
Jon Staab
c62ef3fc3c Undo reaction by tapping 2026-08-17 15:25:53 -07:00
Jon Staab
f5d22664ac Bump welshman 2026-08-17 15:17:16 -07:00
Jon Staab
847d89845e Use new welshman membership helpers 2026-08-17 15:14:18 -07:00
Jon Staab
3dfb5e60de Hide emoji picker border in dialog 2026-08-17 15:14:12 -07:00
Jon Staab
974c6413af Add article support 2026-08-17 14:12:33 -07:00
Jon Staab
4c4b6b50a7 Fix author hint for quotes 2026-08-17 11:24:49 -07:00
Jon Staab
b06b855a42 Clean up thread create initial values 2026-08-17 11:18:38 -07:00
Aditya Chaudhary
dfb971e7dd feat: add "Convert to Thread" action on room messages (#370)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-17 18:13:37 +00:00
Jon Staab
01a79d570b Cache space nav items and fix meta storage 2026-08-17 10:49:28 -07:00
Aditya Chaudhary
0f448e0b5a Restore mic state on full voice reconnect after a dropout (#373)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-17 17:19:13 +00:00
Jon Staab
25f7d6892d Fix nip 55 2026-08-17 09:44:13 -07:00
Jon Staab
4ee05fd21c Fix unread notification count 2026-08-17 09:41:38 -07:00
Jon Staab
3d66fb3109 Use welshman's pendingJoins util 2026-08-17 09:21:30 -07:00
Jon Staab
da64b46494 Show notification badges on thread items 2026-08-17 08:55:54 -07:00
Jon Staab
922a0996e5 Tweak how the library page renders 2026-08-14 16:06:17 -07:00
Matt Lorentz
3d2ec8c030 Trying new video tile layout algorithm (#355)
Co-authored-by: Matt Lorentz <5+mplorentz@noreply.coracle.social>
2026-08-14 17:43:19 +00:00
Aditya Chaudhary
9d8be25bae feat: add minimal pin renderer (#368)
Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/368
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-14 17:36:33 +00:00
Jon Staab
31471b50b5 Fix deriving stuff in social.ts 2026-08-14 10:12:18 -07:00
Jon Staab
8e2284a2fe Fix comment display in RoomItem 2026-08-14 08:51:01 -07:00
Jon Staab
50554ec479 Small route utils refactor 2026-08-14 08:51:01 -07:00
Aditya Chaudhary
d23a6f6b75 Fix leave button requiring two clicks when screen sharing (#367)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-14 15:46:25 +00:00
Aditya Chaudhary
f5159a3b5e Show notification badge on Threads nav item (#360)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-14 15:37:27 +00:00
Jon Staab
0a85983633 Bump welshman 2026-08-13 15:13:05 -07:00
Jon Staab
cf9b6f7193 Fix network load/request calls 2026-08-13 15:06:13 -07:00
Jon Staab
29afa08053 Update link_deps for pnpm 11 2026-08-13 14:35:51 -07:00
Jon Staab
db68aa4a68 Add log sending 2026-08-13 14:35:51 -07:00
Jon Staab
2d89881aac Add links to website 2026-08-13 14:35:51 -07:00
Aditya Chaudhary
84fee9de18 Fix room icons not rendering on iOS (#361)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-13 18:09:01 +00:00
Aditya Chaudhary
f411973409 feat: add rendering for library shelves (#359)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-13 16:53:38 +00:00
Aditya Chaudhary
fc768b6393 Replace sidebar voice widget with in-room floating call controls (#348)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-13 16:01:08 +00:00
Jon Staab
d61c0d00fb Handle blocked upgrades 2026-08-12 17:37:57 -07:00
Jon Staab
620f7593e7 Add theme selector 2026-08-12 16:37:51 -07:00
Jon Staab
9cd717d5b3 Add native sharing handler 2026-08-12 16:04:57 -07:00
Aditya Chaudhary
a572918e6c Persistent call banner (#341)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-11 17:04:47 +00:00
Aditya Chaudhary
c8d003524b Fix/spurious relay membership prompt (#340)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-11 16:45:49 +00:00
Jon Staab
8243f4d245 Defer rendering a chat room for a frame 2026-08-10 15:14:32 -07:00
Aditya Chaudhary
72ee0c9ed6 Reacquire mic after screen lock kills capture mid-call (#339)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-10 18:32:11 +00:00
Aditya Chaudhary
7dcc713ec2 Refine calls: accessible controls, mic level check before joining (#331)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-10 18:14:46 +00:00
Aditya Chaudhary
65c9eac5e5 Show new-messages badge in tab title when re-foregrounding (#332)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-10 16:16:30 +00:00
Jon Staab
b2f58b11c0 Fix touch target for space menu 2026-08-07 14:10:43 -07:00
Aditya Chaudhary
749989f56b Fix DM compose bar being hidden/cut off on mobile (#335)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-07 21:10:32 +00:00
Aditya Chaudhary
9c68f66c39 fix: replace mobile space menu popover with a bottom-sheet action menu (#330)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-08-07 21:05:17 +00:00
Jon Staab
85a4ba127a Add e2e tests 2026-08-07 13:11:12 -07:00
672 changed files with 46621 additions and 8718 deletions

View file

@ -0,0 +1,341 @@
---
name: flotilla-architecture
description: "Use this skill when deciding where new code belongs in flotilla: which layer (routes, app/components, app, lib) or welshman package owns it, which src/app module to extend, or how a kind-based space feature is laid out across the layers. Also use it for the layer and import rules, path aliases, the boot sequence, platform-specific behavior (Capacitor, Android, iOS, Electron, PWA), the link-preview server, env and branding variables, the e2e harness, and the lint/check tooling."
---
# Flotilla architecture
Flotilla is a client-only SvelteKit app, built with `adapter-static` and an `index.html` fallback,
with `ssr = false` in `src/routes/+layout.ts`. The same build ships as a web app/PWA, inside
Capacitor for Android and iOS, and inside Electron for desktop. Welshman does almost all of the
nostr work. Flotilla's own code is app policy (what to sync, when to authenticate, what to show)
and UI.
## The layers
| Layer | Path | Import as | May import |
|---|---|---|---|
| Routes | `src/routes` | — | anything |
| App components | `src/app/components` | `@app/components/X.svelte` | `@app`, `@lib` |
| App modules | `src/app/*.ts`, `editor/`, `push/` | `@app/x` | `@lib`, each other |
| Lib | `src/lib` | `@lib/x` | external packages only |
`svelte.config.js` defines the aliases `@src`, `@app`, `@lib` and `@assets`. Use `@lib`.
SvelteKit's built-in `$lib` also resolves, but only four stray imports use it (in `relays.ts`,
`callEngine.ts` and `VoiceRoomJoinDialog.svelte`). There is no barrel file, so import each
component by its path (`@lib/components/Button.svelte`), not from `$lib/components` as the
AGENTS.md example has it. Icons come from `@assets/icons/<name>.svg?dataurl`.
SvelteKit's `$app/*` (`$app/navigation`, `$app/state`, `$app/stores`) is an external dependency,
unrelated to flotilla's `@app/*`. App modules use it freely, for example `modal.ts`, `routes.ts`
and `sync.ts`.
### Why the graph is one-way
- `src/lib` stays reusable by other apps.
- `src/app` modules can be imported from anywhere (routes, components, other modules, the boot
sequence) without pulling in UI.
- `core.ts` can't import the policy modules that depend on it, so they push themselves onto
`appPolicies` when imported, and `core.ts` builds the `App` lazily after they have registered.
`flotilla-state` covers this under "App policies".
### Exceptions
No lint rule enforces the layers (`eslint.config.js` has no import restrictions), so review is the
only gate.
- **lib → app.** `Link.svelte` imports `navigate` from `@app/modal`, and `ImageInputButton.svelte`
and `IconPickerButton.svelte` open app modals. Don't copy them. A lib component that needs app
behavior takes it as a prop, or moves to `src/app/components`.
- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`), `share.ts` (`Share`,
`ShareEvent`) and `speech.ts` (`OpenRouterEnable`) import a component so they can open a modal
mid-flow. `editor/` holds `.svelte` files of its own (suggestion popovers), which `makeEditor`
mounts.
- Nothing under `src/app` or `src/lib` imports from `src/routes`.
## Top-level layout
| Path | What it is |
|---|---|
| `src/routes` | SvelteKit pages; the root `+layout.svelte` also runs the boot sequence |
| `src/app` | Flotilla's state, policies and feature logic, plus `components/` |
| `src/lib` | App-agnostic utilities and the design-system components |
| `src/assets/icons` | SVG icons |
| `static/` | Logo, PWA icons, fonts, sounds |
| `android/`, `ios/` | Capacitor native projects, with flotilla's own plugins and iOS share extension |
| `electron/` | Desktop shell on `@capawesome/capacitor-electron`; a separate npm project |
| `server.js` | Optional node server: serves `build/` and adds link-preview metadata |
| `e2e/` | Playwright suite against a real relay; start with `e2e/ARCHITECTURE.md` |
| `scripts/` | Build, desktop, version-bump and welshman-linking scripts |
| `docs/feature_matrix.html` | Standalone feature matrix page |
## `src/app` by concern
`src/app/components` is flat except for `hosting/`. `flotilla-views` covers component conventions.
**Core and session**
- `core.ts`: the `App` store, plugin stores, `login`, and the `reader`/`writer`/`command` shortcuts
- `session.ts`: restores the saved session at boot; `logout`
- `policies.ts`: the ingest, auth and socket policies installed on every app
- `storage.ts`: `kv`/`ss` (Capacitor Preferences and SecureStorage) and the per-user IndexedDB
cache
- `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs
- `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings
- `repository.ts`: `derive*` helpers over the current app's repository
- `thunks.ts` (publish status by event id), `signer.ts` (signer request tracking)
- `env.ts`: every `VITE_` value, parsed
- `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a
device id)
**Navigation and UI plumbing**
- `routes.ts`: path builders (`makeSpacePath`, `makeContentPath`, …), `goTo*`, history tracking
- `modal.ts`, `modal.svelte.ts`: `pushModal`, `popModal`, `navigate`, and the modal stack
- `toast.ts`, `title.ts`, `theme.ts`, `icons.ts` (icon picker options), `drafts.ts`
- `editor/`: flotilla's `@welshman/editor` setup (`makeEditor`), with its suggestion popovers and
node views
**Spaces, rooms and administration**
- `relays.ts`: relay URL encoding for routes, socket status, LiveKit detection
- `rooms.ts`: helpers over `rooms.get()`, and the user's rooms and spaces
- `access.ts`: joining, invites, relay auth errors
- `management.ts` (NIP-86 admin checks, bans), `roles.ts` (member roles)
- `actionItems.ts`: the admin review queue (reports and pending joins)
- `featured.ts` (the space owner's featured content), `roomPins.ts`, `commands.ts` (NIP-CD slash
commands)
- `hosting.ts`: client for the hosting backend's HTTP API
**Content**
- `content.ts`: kind lists (`CONTENT_KINDS`, `REACTION_KINDS`, `DM_KINDS`) and comment/delete
filters
- `feeds.ts`: `makeFeed`, `makeFeedContext`, `makeScrollLoader`, `makeCalendarFeed`
- `classifieds.ts`, `articles.ts`, `pins.ts` (a person's pinned notes), `pinboards.ts`
- `reactions.ts`, `social.ts` (display names, comment trees, muting), `render.ts` (events as
text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom)
- `notifications.ts` (unread state, badges), `inbox.ts` (the home inbox)
**Messaging and calls**
- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices)
**Identity and payments**
- `nip46.ts`, `pomade.ts` (email login), `lightning.ts` (wallet, invoices), `healthChecks.ts`
(prompts for missing inbox/outbox relays)
**Platform and voice**
- `push/` (notification adapters), `share.ts`, `keyboard.ts`
- `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through OpenRouter
A feature gets a `src/app/<feature>.ts` only when it has non-UI logic to hold. Polls, goals,
threads and calendar events have no module; their components use domain readers directly.
## `src/lib`
Lib code is app-agnostic. It may use svelte, SvelteKit, Capacitor and welshman, but never `@app`,
env, or the `App` instance. A good test is whether it would work unchanged in another nostr
client.
- `util.ts`: small helpers (`errorMessage`, `AbortError`/`TimeoutError`, `buildUrl`,
`normalizeTopic`)
- `html.ts`: DOM helpers such as `isMobile`, `createScroller`, `copyToClipboard`, `compressFile`
- `indexeddb.ts`: the `IDB` wrapper that `storage.ts` builds on
- `feeds.ts`: saved feed definitions (kind `FEED`) over `@welshman/feeds`. It is unrelated to
`@app/feeds`, which loads events.
- `livekit.ts`: finds a relay's LiveKit endpoint
- `currency.ts`, `transition.ts`, `implicit.ts` (hands state from one page to the next)
- `test/`: the DEV-only hooks the e2e harness injects through
- `components/`: the design system, entered through `theme.css` (see `flotilla-views`)
## Boot sequence
`src/routes/+layout.svelte` runs the boot sequence. It imports `@app/policies` for its side effect,
and `@app/storage`, which registers `storagePolicy` the same way, so every `AppPolicy` is on
`appPolicies` before anything calls `app.get()`. Then, in order:
1. `restoreSession()` restores the saved session, if there is one, which builds a user-scoped
`App` through `login`.
2. The device, wallet and notification stores sync to `kv`/`ss`.
3. It waits for storage, then handles a cold-start deep link.
4. Each long-running subscription goes onto one `unsubscribers` list: `setupHistory`,
`syncApplicationData`, `setupShareIntents`, `syncKeyboard`, badges, `Push.sync()`.
When login swaps in a new `App`, the layout runs `syncApplicationData` again. Routes render inside
`AppContainer`, behind the login gate, and `ModalContainer` renders outside it. `flotilla-state`
covers the gate, login and logout.
## Platform layer
One web build runs in several shells:
- **Web/PWA.** `SvelteKitPWA` in `vite.config.ts` generates the service worker and manifest,
except when `FLOTILLA_DESKTOP=1`. `src/service-worker.js` only claims clients.
- **Android/iOS.** Capacitor wraps `build/` (`capacitor.config.ts`). `scripts/build.sh` runs the
web build, `cap sync`, and native asset generation.
- **Desktop.** `electron/main.ts` starts the Capawesome Electron platform, driven by
`scripts/build-desktop.sh` and `scripts/dev-desktop.mjs`.
- **`server.js`.** A Hono server that serves `build/`. For `/join` and `/spaces/...` URLs it
rewrites the OpenGraph tags from the relay's NIP-11 document, fetched through welshman's
`Relays`. `vite.config.server.ts` bundles it and the `Dockerfile` runs it. It is not an API, and
the app works from any static host.
Platform checks call Capacitor directly. There is no wrapper module:
```ts
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios" // src/app/env.ts
export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios" // src/app/hosting.ts
if (!Capacitor.isPluginAvailable("Keyboard")) return noop // src/app/keyboard.ts
```
The iOS flags exist because of App Store payment policy, so anything that takes money checks
`ENABLE_ZAPS` or `HOSTING_ENABLED`. `isMobile` from `@lib/html` detects a touch screen and says
nothing about the platform.
Flotilla's own native code:
- `android/app/src/main/java/social/flotilla/`: `AndroidPushFallbackPlugin` and its worker (push
without FCM), and `ShareIntentPlugin`. `MainActivity.java` registers them, and JS binds them with
`registerPlugin` (`push/adapters/android.ts`, `share.ts`).
- `ios/App/ShareExtension/`: the extension can't call into the app, so it opens a
`flotilla://share` URL. `handleDeepLink` in the root layout passes that to `shareFromNative`.
`Push` in `src/app/push/index.ts` chooses an adapter at runtime: the Android fallback, Capacitor
`PushNotifications` (FCM/APNs through `PUSH_SERVER`), or web notifications.
## Env and branding
`src/app/env.ts` reads the `VITE_` values and exports them as typed constants, with relay lists
parsed by `fromCsv` and `normalizeRelayUrl`. In DEV each lookup checks `window.__TEST_ENV__` first,
which lets the e2e harness point a browser at its own relays. Elsewhere, only `logger.ts` and the
about page read a `VITE_` value (`VITE_BUILD_HASH`); other `import.meta.env` reads are `DEV`
guards.
- `.env` is committed and holds working defaults. `.env.local` (gitignored) overrides it. There is
no `.env.template`, though AGENTS.md and the README refer to one.
- Env is read at build time. `scripts/build-web.sh` sources `.env` without overwriting variables
already set, then fills the `{NAME}`, `{URL}`, `{ACCENT}` and `{DESCRIPTION}` placeholders from
`src/app.html` in `build/index.html`. `server.js` reads `VITE_PLATFORM_NAME` and
`VITE_PLATFORM_DESCRIPTION` at runtime.
- A non-empty `VITE_PLATFORM_RELAYS` turns on platform mode, which disables space browsing and
makes the first platform relay the home page (`goToHome` in `routes.ts`, `PrimaryNav`,
`sync.ts`).
- `VITE_THEME`, exported as `FL_THEME`, selects the design preset in
`src/lib/components/theme.css`.
- The native app name is hard-coded in `capacitor.config.ts` (`appName: "Flotilla"`), outside the
env system.
To add a variable, give it a default in `.env` and export a parsed constant from `env.ts`. If it
names a relay or host, the e2e harness has to override or mock it (see "Containment" in
`e2e/ARCHITECTURE.md`).
## Tooling and tests
- `pnpm run lint` runs prettier and eslint over `src`, `e2e` and the configs, `pnpm run check`
runs svelte-check, and `pnpm run format` formats changed files.
- `.husky/pre-commit` runs lint and check, and refuses to commit while a `link:` override is in
place. CI (`.gitea/workflows/ci.yml`) runs lint, check and the Electron TypeScript build, plus a
full build on pushes to `dev`.
- Flotilla has no unit tests. `e2e/` is a Playwright suite against a real zooid relay in Docker;
`e2e/ARCHITECTURE.md` explains the harness and `e2e/USER_STORIES.md` lists the stories the specs
cite. Agents don't run it. Its only footprint in the app is `src/lib/test/`.
- `scripts/link-deps.mjs` links `../welshman/packages/*` by writing temporary `link:` overrides
into `pnpm-workspace.yaml`, installing, and restoring the file. Without those overrides welshman
comes from the registry, so read its source under `node_modules/@welshman/*/dist`, or in
`../welshman` when that checkout matches the installed version.
## Principles behind placement
**Check welshman before writing flotilla code.** Several commits replace app code with welshman
primitives: `render.ts` uses welshman's `summarize` (`9b0d8a55`), `actionItems.ts` uses
`rooms.get().pendingJoins` (`3d66fb31`), and `rooms.ts` uses the membership helpers (`847d8984`).
**When welshman lacks something, add it there.** The maintainer also maintains welshman, so a
missing primitive goes upstream rather than into an `@app` workaround. The `Command` and
`Pinboard` kinds live in `@welshman/domain`, and flotilla's `commands.ts` and `pinboards.ts` only
consume them. Expect rejection for app-level retry loops, liveness heuristics, or registries that
duplicate what `@welshman/net` already tracks.
**Add indirection only when it pays for itself.** `core.ts` exports the `reader`, `writer` and
`command` shortcuts because "almost every read or write goes through one of them". Platform checks
stay inline rather than going through a platform module, and `drafts.ts` is a module-level `Map`
rather than a persisted store.
## Placement guide
- **Parsing or building a nostr kind** → upstream in `@welshman/domain`, used through `reader` and
`writer` from `@app/core`. See `flotilla-model` ("Adding a kind") and `welshman-domain`.
- **A space section for a kind** → a route under `src/routes/spaces/[relay]/`, with the kind in
`CONTENT_KINDS`. The walkthrough below names every file involved, and `flotilla-model` ("Adding
a kind") and `flotilla-views` ("Adding a space content page") have the checklists. Add the
section to the regexes in `server.js`, or its link previews are titled as a room.
- **Non-UI logic for one feature** (scoring, filtering, stores keyed by URL) →
`src/app/<feature>.ts`, with no component imports.
- **A keyed collection of one kind** → a plugin. A generic kind's plugin goes upstream in
`@welshman/app`; a flotilla-specific one is a `DerivedPlugin` in `src/app`, exposed with
`usePlugin`. See `flotilla-state`.
- **A preference** → a `SettingsValues` field in `settings.ts` if it follows the user, or a
`kv`/`ss` store if it belongs to the device. See `flotilla-state`.
- **A relay or network policy** (what to ingest, when to AUTH, which sockets may open) → an
`AppPolicy` in `policies.ts`. See `flotilla-state` and `welshman-net`.
- **Data every joined space needs locally** → the filters in `syncSpace` in `sync.ts`. Data that
one page needs is loaded by that page. See `flotilla-state` and `flotilla-views`.
- **A modal or dialog** → `src/app/components/<Name>.svelte`, opened with `pushModal`. See
`flotilla-views`.
- **A generic UI primitive** → `src/lib/components/<Name>.svelte` with a CSS family file next to it
(`Button.svelte` and `button.css`), and no `@app` imports. See `flotilla-views`.
- **A non-nostr HTTP service** → its own module with typed request functions, a typed error class
and a base URL from env, as in `hosting.ts` (`hostingFetch`, `HostingError`,
`HOSTING_BACKEND_URL`).
- **A native capability** → JS in `src/app/<capability>.ts`, with inline `Capacitor` checks and a
web fallback. If no Capacitor plugin fits, write one under
`android/app/src/main/java/social/flotilla/`, register it in `MainActivity.java`, and bind it
with `registerPlugin`. An iOS extension reaches the app through a `flotilla://` deep link.
- **A deployment setting** → a `VITE_` variable (see Env and branding).
- **Startup wiring** → a `setup*` or `sync*` function in the owning module that returns an
`Unsubscriber`, called from the root layout (`setupHistory`, `syncKeyboard`, `Push.sync`).
## Walkthrough: classifieds
Classifieds (NIP-99, kind 30402, `CLASSIFIED`) touch every layer and follow current conventions
(`e6ce3e5e` is their redesign). Polls, goals, threads and calendar have the same shape without the
app module.
**Domain.** `Classified` in `@welshman/domain` pairs a `ClassifiedReader` (`title()`,
`summary()`, `price()`, `status()`, `images()`, `topics()`) with a `ClassifiedWriter` that has the
matching setters.
**Kind registries.** `CONTENT_KINDS` in `src/app/content.ts` drives sync, notifications, push,
search and the space nav entry. The kind also appears in `CONTENT_NOUNS`, the kind dispatch in
`NoteContent.svelte` and `NoteContentMinimal.svelte`, `makeClassifiedPath` and `makeContentPath` in
`routes.ts`, `title.ts`, `NIP46_PERMS` in `nip46.ts`, and the section regexes in `server.js`.
`NIP46_PERMS` leaves out polls, articles and goals, so listing a new kind there is optional.
**App module.** `src/app/classifieds.ts` holds the listing logic the page would otherwise inline
(`partitionListings`, `deriveTopicCounts`, `getStatus`, `matchesTopic`, `matchesQuery`). Each is a
small function over a domain reader:
```ts
export const getStatus = (event: TrustedEvent) => reader(Classified)(event).status() ?? "active"
```
**Components.** `ClassifiedForm` builds and publishes the event, and `ClassifiedCreate` and
`ClassifiedEdit` wrap it, supplying only the header. The list page and `ComposeMenu` open
`ClassifiedCreate` as a modal. From a room, `ComposeMenu` sets `shareToChat`, which also quotes the
new listing into the room. `ClassifiedActions` opens `ClassifiedEdit`. `ClassifiedItem` is the
card, and `NoteContentClassified` renders a listing wherever `NoteContent` is used.
**Routes.** `src/routes/spaces/[relay]/classifieds/+page.svelte` loads listings and their comments
with `makeFeed` and filters them with the `classifieds.ts` helpers. `[address]/+page.svelte` reads
one listing with `deriveEvent(address, [url])`.
Articles are composed on a full page (`spaces/[relay]/articles/create`, built by
`makeArticleCreatePath`) instead of in a modal.
## Related skills
- `flotilla-state`: the `App` instance and plugins, policies, persistence, sync, publishing
- `flotilla-views`: routes and layouts, components, modals, loading data from components
- `flotilla-model`: spaces as relays, NIP-29 rooms, NIP-86 management, content kinds, routing
- `welshman`: overview of the packages
- `welshman-app`: `App`, `use()`, `AppPolicy`, `DerivedPlugin`, commands and thunks
- `welshman-domain`: readers, writers, and adding a kind
- `welshman-net`: the pool, sockets and socket policies
- `welshman-util`: kind constants, tag specs, `RelaySelection`

View file

@ -0,0 +1,318 @@
---
name: flotilla-model
description: "Use this skill when working on how flotilla speaks nostr: publishing or reading content in a space or room, adding or changing an event kind, NIP-29 room state, moderation and room invites, NIP-43 space membership and invite links, NIP-86 relay management and admin gating, NIP-42 auth or NIP-70 protected events, deciding which relays an event goes to, or replacing code that hand-parses tags or builds events with makeEvent."
---
# Flotilla's nostr model
Flotilla treats a relay as a community (a space) and a NIP-29 group on that relay as a channel (a
room). Most of the protocol logic lives below the app: `@welshman/domain` has a Reader/Writer pair
per kind, and `@welshman/app` plugins (`Rooms`, `RelayMemberLists`, `RelayManagement`, …) assemble
relay state out of those readers. Flotilla's own protocol code is a thin layer on top, mostly in
`src/app/access.ts`, `src/app/rooms.ts`, `src/app/management.ts` and the components that publish.
The per-feature kind inventory is in [kinds.md](kinds.md).
## Spaces and rooms
**A space is a relay URL.** No event defines one; the normalized URL is the identity, and routes
carry it as `encodeRelay(url)` (`src/app/relays.ts`) under `/spaces/[relay]`. Any relay can be
opened as a space. NIP-29 support only decides whether it has rooms.
**A room is a NIP-29 group `h` on that relay.** The same `h` can exist independently on several
relays, so anything room-scoped is keyed by both: `makeRoomKey(url, h)` from `@welshman/app` gives
`${url}'${h}`, and `isRoomId` in `src/app/rooms.ts` tests for the `'`. Other relay-scoped keys use
`|` (`${url}|${d}` for relay roles and NIP-CD commands) so the two can't collide. Room content
carries `["h", h]`; content with no `h` belongs to the whole space. `/spaces/[relay]/chat`
(`makeSpaceChatPath`) is the space-wide chat, which is `RoomChat` with no `h`.
**The user's spaces are their kind 10009 `ROOMS` list** (`RoomList` factory, `RoomLists` plugin):
`r` tags for spaces, `group` tags for rooms with the space URL as the hint. `userSpaceUrls` and
`deriveUserRooms(url)` in `src/app/rooms.ts` read it. "Joined" in the UI means "in this list",
which is separate from NIP-43 membership on the relay: `src/routes/spaces/[relay]/+layout.svelte`
prompts `SpaceJoin` for any URL not in `userSpaceUrls`. A deployment with `VITE_PLATFORM_RELAYS`
uses `PLATFORM_RELAYS` in place of the list for sync and navigation.
### NIP-11 relay info
The `Relays` plugin (`relays` in `src/app/core.ts`) fetches each relay's NIP-11 document into a
domain `Relay`. These fields drive protocol decisions:
| Read | Decides |
|---|---|
| `hasNip(29)` | whether the space has rooms. Without it everything lives in the space chat: `makeSpaceEntryPath` (`src/app/routes.ts`), `shareEvent` (`src/app/share.ts`), room search, notification grouping, `SpaceMenuRooms` |
| `hasNip(70)` | whether space content is marked protected (below) |
| `self` | the relay's own pubkey, the trust anchor for relay-signed state |
| `pubkey` | the space's owner, who writes space-wide content that has no other author (`deriveUserIsSpaceOwner`) |
| `redirect_to` | the relay has moved. The space layout offers `SpaceRedirect`, which runs `roomLists.migrateRelay` and `goToMovedSpace` |
| `hasNip(50)`, `hasNip("BUD-02")`, `hasNip("9a")` | search, blossom uploads, push |
## Relay-signed state
The relay publishes NIP-29 and NIP-43 state under its NIP-11 `self` key: room metadata, admins,
members and pins, the space member list, and roles. Anyone can publish events of those kinds, so
readers must check the author. The welshman collections do: `Rooms` and every
`RelaySignedDerivedPlugin` (`RelayMemberLists`, `RelayRoles`, `RoomPinLists`) drop events whose
author isn't the relay's `self`, and re-check when NIP-11 loads. For a relay-authored kind with no
plugin, filter `deriveEventsForUrl(url, filters)` on that `self` key yourself rather than reading a
bare `deriveEventsForUrl`.
The app signs everything it publishes with the user's own key. Space-wide content with no author of
its own belongs to the space's owner, the pubkey NIP-11 names: featured content
(`setFeaturedContent` in `src/app/featured.ts`) is published by that person and read back scoped to
them, so `deriveUserIsSpaceOwner(url)` is what shows the editor.
The library is written by its members. A shelf or a pin is signed with the member's own key and
published to the space like any other space content, so the library reads every `PINBOARD` seen on
the relay rather than only the relay's own. Whoever signed a shelf is the only one who can edit or
delete it, and anyone can pin to it.
## NIP-29 rooms
| Constant | Kind | Factory | Author | Flotilla use |
|---|---|---|---|---|
| `ROOM_META` | 39000 | `RoomMeta` | relay | name, about, picture, flags |
| `ROOM_ADMINS` | 39001 | `RoomAdmins` | relay | room admins |
| `ROOM_MEMBERS` | 39002 | `RoomMembers` | relay | member snapshot |
| `ROOM_PINS` | 39005 | `RoomPins` | relay | pinned messages |
| `ROOM_ADD_MEMBER` / `ROOM_REMOVE_MEMBER` | 9000 / 9001 | `RoomAddMember` / `RoomRemoveMember` | admin | `addRoomMembers`, `RoomMemberMenu` |
| `ROOM_EDIT_META` | 9002 | `RoomEdit` | admin | `rooms.editRoom` |
| `ROOM_CREATE` / `ROOM_DELETE` | 9007 / 9008 | `RoomCreate` / `RoomDelete` | admin | `RoomForm`, `RoomDetailMenu` |
| none (`ROOM_CREATE_INVITE` in `src/app/access.ts`) | 9009 | none | admin | `publishRoomInvite` |
| `ROOM_UPDATE_PINS` | 9010 | `RoomUpdatePins` | admin | `roomPinLists.setPins` |
| `ROOM_JOIN` / `ROOM_LEAVE` | 9021 / 9022 | `RoomJoin` / `RoomLeave` | user | `joinRoom` / `leaveRoom` in `src/app/access.ts` |
| `ROOM_CREATE_PERMISSION` | 19004 | `RoomCreatePermission` | not checked | `deriveUserCanCreateRoom` |
`@welshman/util` also defines `ROOM_ADD_PERM` (9003), `ROOM_REMOVE_PERM` (9004),
`ROOM_DELETE_EVENT` (9005) and `ROOM_EDIT_STATUS` (9006); flotilla uses none of them. An admin
removes a message with NIP-86 `banEvent` instead (`RoomItemMenu`, `EventMenu`).
### How `Rooms` builds a room
`rooms.get().forRoom(url, h)` yields `{id, url, h, meta, members, admins}` from the three
relay-signed state kinds. A `ROOM_DELETE` tombstones the room when it is at least as new as all of
that state, so a room re-created after deletion comes back. Membership (`members(url, h)`,
`membershipStatus(url, h)`) replays the 39002 snapshot, then newer 9000/9001 ops authored by an
admin or the relay, then pending 9021/9022 requests, into `MembershipStatus.Initial | Pending |
Granted`. `pendingJoins(url, h?)` lists unanswered join requests, and `deriveSpaceActionItems`
(`src/app/actionItems.ts`) merges them with reports into the admin queue, keeping the half the
user holds a method for — reports under `banevent`, join requests under `allowpubkey`.
`src/app/rooms.ts` puts space authority on top:
- `deriveUserIsRoomAdmin`: a space's staff administer every room.
- `deriveUserRoomMembershipStatus`: an admin is always `Granted`.
- `addRoomMembers`: allows each non-member at the relay (NIP-86 `allowPubkey`) before publishing
9000, because a room member the relay won't serve can't read the room.
- `deriveUserRooms`, `deriveOtherRooms`, `deriveOtherVoiceRooms`: rooms from the user's 10009
list and the rest of the space, limited to rooms the relay still advertises. `meta.hasLivekit()`
marks a voice room.
### Room flows
- **Create** (`RoomForm.svelte`): `rooms.createRoom` (9007, `h` from `randomId()`), tolerating an
"already" error, then `rooms.editRoom` (9002) with metadata and flags, then `joinRoom`.
- **Delete** (`RoomDetailMenu.svelte`): `rooms.deleteRoom` (9008), then `roomLists.removeRoom`.
- **Join and leave** (`joinRoom`, `leaveRoom` in `src/app/access.ts`): two publishes, the
9021/9022 the relay may refuse and the user's 10009 list, which is what puts the room in the
sidebar. `isMembershipRefusal` counts `duplicate:` and "already a member" replies as success.
- **Invite** (`publishRoomInvite`): a 9009 with a random `code` tag. The link carries `h` and
`code`, and `joinRoom(url, h, code)` sends the code as the join's `claim`.
- **Pins**: `roomPinLists.setPins(url, h, pins)` sends 9010 and the relay republishes 39005.
`deriveRoomPinnedEvents` (`src/app/roomPins.ts`) loads the pinned events from the room's relay.
The relay enforces the `RoomMetaReader` flags (`isClosed`, `isHidden`, `isPrivate`,
`isRestricted`); the UI only reflects them. Until membership is `Granted`, `RoomChat` hides a
private room's messages and blocks posting to a restricted room, and `RoomDetail` describes the
flags.
## NIP-43 space membership
| Constant | Kind | Factory | Flotilla use |
|---|---|---|---|
| `RELAY_MEMBERS` | 13534 | `RelayMembers` | relay-signed member list, `relayMemberLists.forUrl(url)` |
| `RELAY_ADD_MEMBER` / `RELAY_REMOVE_MEMBER` | 8000 / 8001 | `RelayAddMember` / `RelayRemoveMember` | membership ops; the space chat shows 8000 the way a room shows 9000 |
| `RELAY_JOIN` | 28934 | `RelayJoin` | join request carrying a `claim` (`publishJoinRequest`) |
| `RELAY_INVITE` | 28935 | `RelayInvite` | unused since claims moved to NIP-86 |
| `RELAY_LEAVE` | 28936 | `RelayLeave` | `publishLeaveRequest` |
| `RELAY_ROLE` | 33534 | `RelayRole` | relay-signed role definitions (`relayRoles`) |
Roles are a flotilla extension. A member tag is `["member", pubkey, ...roleIds]`, and
`deriveSpaceMemberRoles` in `src/app/roles.ts` parses the role ids because `RelayMembersReader`
has no getter for them yet. Roles are only ever changed over
NIP-86 (`createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole`), never by publishing
33534.
Joining a space (`attemptRelayAccess` and `Access` in `src/app/access.ts`):
1. Open the socket and drive NIP-42 auth, retrying up to three times.
2. Publish `RelayJoin` with the claim. The writer protects the event and requires a forced relay.
3. Translate refusals: "invite code" means rejected, "claim" means the space needs an invite.
4. `completeJoin`: `roomLists.addRelay(url)`, restart sync, and `Sync.push` the user's `RELAYS`,
`MESSAGING_RELAYS`, `FOLLOWS` and `PROFILE` to the space so other members can see them.
Invite links are `${PLATFORM_URL}/join?r=<relay>&c=<claim>`, plus `h` and `code` for a room
(`makeInviteLink`; `parseInviteLink` also accepts a bare relay URL). `src/routes/join` renders
`SpaceInviteAccept`, which calls `Access.acceptInvite` to join the space and then the room.
`Access.prepareInvite` gets a claim over NIP-86 (`supportedmethods`, then `listclaims`, then
`createclaim`); this replaced reading `RELAY_INVITE` events. Leaving (`SpaceExit`,
`SpaceAuthError`) is `roomLists.removeRelay(url)` plus `publishLeaveRequest(url)`.
## NIP-86 relay management
`relayManagement.get().forUrl(url)` returns welshman's `ManagementApi`: JSON-RPC over HTTP at the
relay's URL, each call signed with a fresh NIP-98 event. Every method resolves to
`{result, error}`.
| Methods | Called from |
|---|---|
| `supportedMethods` | `deriveSpaceSupportedMethods` (`src/app/management.ts`), `Access.prepareInvite` |
| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `SpaceInvite`, `ReportMenuList`, `addRoomMembers` |
| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenuList`, `RoomJoinItem` (dismissing a join request) |
| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles`, `RoleAddMembers` |
| `listClaims`, `createClaim` | `Access.prepareInvite` |
| `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` |
A relay answers `supportedmethods` with what the authenticated pubkey may call, so every control
is gated on the method behind it: `deriveSpaceSupportedMethods(url)` (re-checked at most every
five minutes per pubkey and URL) and `$supportedMethods.includes("banpubkey")`. Still handle an
error from the call, since a listed method can be refused for a particular event or target.
`deriveUserIsSpaceStaff(url)` is only "the list came back non-empty", which is all there is to go
on for the room permissions NIP-86 has no method for — `deriveUserIsRoomAdmin` and
`deriveUserCanCreateRoom`, which also takes `ROOM_CREATE_PERMISSION` grants.
The hosting backend in `src/app/hosting.ts` is a separate HTTP API at `HOSTING_BACKEND_URL` for
relays the platform hosts. It authenticates with one NIP-98 header per pubkey, cached for a TTL,
instead of signing every call. `spaces/[relay]/admin` is its page, not a NIP-86 console. Hosting is
off on iOS (`HOSTING_ENABLED`).
## Auth, trust and protected events
- **NIP-42.** `authPolicy` in `src/app/policies.ts` never authenticates to a relay on the user's
blocked-relay list and always does under `relay_auth: aggressive`. Under the default
`conservative`, it authenticates only to relays in the user's room, relay or messaging-relay
lists, or ones they have published to this session. `attemptRelayAccess` authenticates
explicitly before a join.
- **Refusals.** `mostlyRestrictedPolicy` counts `restricted:` and `blocked:` replies per socket.
Once most requests fail, `relaysMostlyRestricted` turns the space status to "Access Denied" and
the layout shows `SpaceAuthError`.
- **Unsigned events.** Some relays strip signatures (hosted relays have a
`policy_strip_signatures` flag). `ingestPolicy` and `trustPolicy` hold back unsigned events
unless the relay is in the `trusted_relays` setting, while `SpaceTrustRelay` asks the user.
- **NIP-70.** Space content is protected exactly when the relay advertises NIP-70, so every space
publish passes `setProtected(await relays.hasNip(url, 70))`. Per NIP-70 (a protocol claim not
verified in this repo) a relay then accepts the event only from its author, which keeps space
content from being copied elsewhere. The `RelayJoin`, `RelayLeave` and `RelayMembers` writers
protect themselves, and `publishReaction` and `retractReaction` in `src/app/reactions.ts` check
the relay for their callers.
## Which relays an event goes to
Space content goes to the space relay and nowhere else. The writer's routes decide where
`command.publish()` sends an event. See flotilla-state for the publishing pipeline itself.
| Tool | Effect | Use for |
|---|---|---|
| `writer.setRoom(url, h)` | adds `["h", h]` and forces `relay(url)` | anything in a room |
| `writer.forceRoutes(relay(url))` | forces the relay, no `h` | space-wide content, NIP-43 requests |
| default routes | the user's outbox plus inboxes of `p`-tagged pubkeys | profile, lists, settings, anything outside a space |
| `command.publishToRelays(urls)` | ignores the writer's relays | reactions, deletes, reports, comments, replies |
| `wraps.get().publish({event, recipients})` | a NIP-59 wrap per recipient, to their `MESSAGING_RELAYS` | DMs and DM reactions and deletes |
`validate()` throws when an `h` tag has no forced route, and every room and relay-membership writer
sets `requiresRelays`, so a missing relay fails loudly. A content form forces the space relay and
adds `setRoom` only when it is posting into a room, as `ThreadCreate`, `ClassifiedForm`,
`CalendarEventForm`, `GoalCreate`, `PollCreate` and the article create route all do.
Some routing is built into welshman. `Reactions.react` and `Deletes.deleteEvent` find the target's
relay in the tracker and copy its `h`. The 10009 writer publishes to the user's outbox and to every
space it lists or used to list, so each relay hears about joins and leaves. Kind 9 has no factory,
so `RoomChat` and `publishRoomQuote` publish raw templates through `Thunks`.
Reads are scoped the same way. Request from the space relay (`relays: [url]`, plus `"#h": [h]` for
a room) and read results with `deriveEventsForUrl(url, filters)`, which uses the tracker to keep
only events seen on that relay. A repository-wide query would mix rooms that share an `h` across
relays. `src/app/sync.ts` pulls each space's room state, membership and recent content in the
background; see flotilla-state.
## Content kinds
The space sections are the kinds in `CONTENT_KINDS` (`src/app/content.ts`): `ZAP_GOAL`,
`EVENT_TIME`, `THREAD`, `CLASSIFIED`, `POLL`, `PINBOARD` and `LONG_FORM`. Each has list and detail
routes under `spaces/[relay]/`, reached through `makeContentPath` in `src/app/routes.ts`. Chat is
`MESSAGE` (kind 9). Comments (`COMMENT`, NIP-22) thread under any content kind, and
`makeCommentFilter(kinds)` selects them by `#K`. A content form with `shareToChat` set also posts
a kind 9 quoting the new event (`publishRoomQuote`), so clients that only render chat still see
it. Zaps and goals are off on iOS (`ENABLE_ZAPS` in `src/app/env.ts`).
[kinds.md](kinds.md) lists every kind by feature, with its factory, module and route.
### Adding a kind
1. Add the constant to `@welshman/util` and a factory to `@welshman/domain` upstream (see
welshman-domain, "Adding a new kind").
2. Publish through `writer(Factory)` and `command(...)`, with `setRoom` or `forceRoutes` and
`setProtected(await relays.hasNip(url, 70))` for space content.
3. For a new space section, add the kind to `CONTENT_KINDS` (which feeds sync, notifications, push,
search and `SpaceMenuNavItems`), `CONTENT_NOUNS` and `makeContentPath`, and add routes under
`spaces/[relay]/`.
4. If users sign it through a remote signer, add it to `NIP46_PERMS` in `src/app/nip46.ts`.
5. If it is relay-scoped state that has to survive a reload, add it to the `kinds` map in
`src/app/storage.ts`.
6. If the relay signs it, read it through a `RelaySignedDerivedPlugin`.
## Parsing and building events
AGENTS.md gives the rule. In protocol code a hand-built event also loses behavior the writer
carries:
`RelayJoinWriter` adds `-`, `DeleteWriter.addEvent` copies the target's `h` and routes to its
relay, `RoomListWriter` publishes to every listed space, and `validate()` refuses a room event with
no relay.
In order of preference:
1. The kind's reader: `reader(Thread)(event).title()`, `reader(TimeEvent)(event).start()`.
2. Base reader getters when the kind is known (`room()`, `protect()`, `expiration()`, `imeta()`),
or the standalone helpers when it isn't (`getImeta`, `getExpiration`, `getReplyTags`,
`getCommentTagValues`, `getEmojis`).
3. `tagValue(tagSpec("h"), event.tags)` in code that handles many kinds at once (notifications,
`makeEventPath`, feed grouping), where no single reader applies.
When welshman lacks a kind or a getter, add it there rather than working around it in the app. A
tag spec is the right tool only for a tag nothing else will read, like the `content` tags on
featured-content app data.
### Known exceptions
These predate the rule or are waiting on welshman. Don't copy them; fix one when you touch it.
- **No factory yet:** `MESSAGE` (9) in `RoomChat` and `publishRoomQuote`; 9009 room invites in
`src/app/access.ts`; `LIVEKIT_PARTICIPANTS` (39004, a local constant in `src/app/call.ts`);
`STATUS` (30315) in `src/app/statuses.ts`, where `ProfileStatus` still uses `tags.find`; push
subscriptions (a literal 30390) in `src/app/push/adapters/capacitor.ts`; `DIRECT_MESSAGE_FILE`
(15) in `Chat.svelte`.
- **Factory exists but bypassed:** DMs built with `makeEvent` in `Chat.svelte` (`DirectMessage`);
relay lists in `SignUp.svelte` (`RelayList`, `MessagingRelayList`); deletes in
`ProfileDelete.svelte` and the push adapter (`Delete`); the vanish request as a literal `62`
(`VANISH`).
- **Getter exists but bypassed:** titles in `src/app/title.ts`, `NoteContentThread` and the
threads pages; calendar `start`/`end` in `src/app/feeds.ts` and the calendar page; poll `response`
tags in `PollVotes`; `p` tags of `RELAY_ADD_MEMBER`, `ROOM_ADD_MEMBER` and
`ROOM_CREATE_PERMISSION` (`SpaceMembersSummary`, `RoomItemAddMember`, `src/app/management.ts`);
comment `E`/`A` tags in the list pages and `src/app/classifieds.ts`; `imeta` unpacking in
`src/app/content.ts` and `Chat.svelte`.
- **Getter missing upstream:** role ids on `member` tags (`src/app/roles.ts`); the legacy `name`
fallback for calendar titles (`CalendarEventHeader`).
Mind the name clash: `ROOM` from `@app/rooms` is the tag name `"h"`, while `ROOM` from
`@welshman/util` is kind 35834.
## Related skills
- flotilla-architecture: where protocol code sits among the layers and modules
- flotilla-state: the `App`, plugins, background sync, persistence, and the publishing pipeline
- flotilla-views: routes, components, and loading data from components
- welshman-domain: readers, writers, and adding a kind
- welshman-util: kind constants, tag specs, `RelaySelection` routing, NIP-42/86/98 helpers
- welshman-app: `Rooms`, `RelayManagement`, `RelaySignedDerivedPlugin`, `Command`, `Wraps`
- welshman-net: sockets, auth state and socket policies

View file

@ -0,0 +1,55 @@
# Flotilla kinds by feature
The kinds flotilla reads and writes, grouped by feature. Constants come from `@welshman/util` and
factories from `@welshman/domain` unless noted. Room and space-membership kinds are in the NIP-29
and NIP-43 tables in [SKILL.md](SKILL.md). Routes are under `src/routes/`.
## Space content
| Feature | Constant (kind) | Factory | Module | Where |
|---|---|---|---|---|
| Chat | `MESSAGE` (9) | none | `src/app/rooms.ts` | `spaces/[relay]/chat`, `spaces/[relay]/[h]` (`RoomChat`) |
| Threads | `THREAD` (11) | `Thread` | none | `spaces/[relay]/threads`, `threads/[id]` (`ThreadCreate`) |
| Comments | `COMMENT` (1111) | `Comment` | `src/app/content.ts` (`makeCommentFilter`) | `CommentCompose`, `EventReply` |
| Articles | `LONG_FORM` (30023) | `Article` | `src/app/articles.ts` | `spaces/[relay]/articles`, `articles/create`, `articles/[address]` |
| Calendar | `EVENT_TIME` (31923) | `TimeEvent` | `src/app/feeds.ts` (`makeCalendarFeed`) | `spaces/[relay]/calendar`, `calendar/[address]` (`CalendarEventForm`) |
| Classifieds | `CLASSIFIED` (30402) | `Classified` | `src/app/classifieds.ts` | `spaces/[relay]/classifieds`, `classifieds/[address]` (`ClassifiedForm`) |
| Goals | `ZAP_GOAL` (9041) | `ZapGoal` | none | `spaces/[relay]/goals`, `goals/[id]` (`GoalCreate`) |
| Polls | `POLL` (1068), `POLL_RESPONSE` (1018) | `Poll`, `PollResponse` | none | `spaces/[relay]/polls`, `polls/[id]` (`PollCreate`, `PollVotes`) |
| Library | `PINBOARD` (30067), `PIN` (39067) | `Pinboard`, `Pin` | `src/app/pinboards.ts` | `spaces/[relay]/library` (`PinboardEdit`, `PinAdd`); published by any member |
| Room pins | `ROOM_PINS` (39005), `ROOM_UPDATE_PINS` (9010) | `RoomPins`, `RoomUpdatePins` | `src/app/roomPins.ts` | `RoomItemMenu`, `RoomPinnedMessagesAll` |
| Featured content | `APP_DATA` (30078), `d` = `flotilla/featured-content` | `AppData` | `src/app/featured.ts` | `SpaceFeaturedContent`; published as the relay |
| Bot commands (NIP-CD) | `COMMAND` (31992) | `Command` | `src/app/commands.ts` | `RoomCompose`, `ContentCommand` |
| Voice room participants | `LIVEKIT_PARTICIPANTS` (39004, defined in `src/app/call.ts`) | none | `src/app/call.ts` | rooms where `meta.hasLivekit()` |
## Interactions
| Feature | Constant (kind) | Factory | Module | Where |
|---|---|---|---|---|
| Reactions | `REACTION` (7) | `Reaction`, through the `Reactions` plugin | `src/app/reactions.ts` | `RoomItem`, `EventReactButtons`, the `*Actions` components |
| Zaps | `ZAP_REQUEST` (9734), `ZAP_RECEIPT` (9735) | `ZapRequest`; receipts checked by `Zappers.validZapReceipts` | `src/app/lightning.ts` (wallets) | `Zap`, `ZapButton`, `GoalSummary`; off on iOS |
| Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenuList` |
| Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenuList` |
## Direct messages
| Feature | Constant (kind) | Factory | Module | Where |
|---|---|---|---|---|
| DMs | `DIRECT_MESSAGE` (14), `DIRECT_MESSAGE_FILE` (15), wrapped as `WRAP` (1059) | `DirectMessage` for 14, bypassed in `Chat.svelte`; none for 15 | `src/app/chats.ts` | `chat`, `chat/[chat]` (`Chat`) |
Gift wraps are only synced once the user opts in (`shouldUnwrap` in `src/app/sync.ts`), and
`goToChat` in `src/app/routes.ts` asks for messaging relays (`ChatEnable`) before opening a chat.
## User data
| Feature | Constant (kind) | Factory | Module | Where |
|---|---|---|---|---|
| Profile | `PROFILE` (0) | `Profile` | none | `settings/profile`, `SignUp`, `ProfileDelete` |
| Settings | `APP_DATA` (30078), `d` = `flotilla/settings`, encrypted | `AppData` | `src/app/settings.ts` | `settings/*` |
| Status (NIP-38) | `STATUS` (30315), `d` = `general` | none | `src/app/statuses.ts` | `ProfileStatus` |
| Profile pins | `PINS` (10001) | `PinList` | `src/app/pins.ts` | profile pages |
| Spaces and rooms | `ROOMS` (10009) | `RoomList` | `src/app/rooms.ts` | the sidebar, `spaces` |
| Follows and mutes | `FOLLOWS` (3), `MUTES` (10000) | `FollowList`, `MuteList` | `src/app/social.ts` | people pages, muting |
| Relay lists | `RELAYS` (10002), `MESSAGING_RELAYS` (10050), `SEARCH_RELAYS` (10007), `BLOCKED_RELAYS` (10006), `BLOSSOM_SERVERS` (10063) | `RelayList`, `MessagingRelayList`, `SearchRelayList`, `BlockedRelayList`, `BlossomServerList` | plugins in `src/app/core.ts` | `settings/*` |
| Account deletion | `VANISH` (62, written as a literal), `DELETE` (5) | none; `Delete` bypassed | none | `ProfileDelete` |
| Push subscriptions | 30390 (a literal, no constant) | none | `src/app/push/adapters/capacitor.ts` | background |

View file

@ -0,0 +1,451 @@
---
name: flotilla-state
description: "Use this skill when deciding where a piece of state belongs in Flotilla, or when touching state: reading or adding stores in src/app, reaching the App instance and welshman plugins (usePlugin, fromApp, deriveUserItem), writing code that must survive login swapping the app or run signed out, adding an app policy or a flotilla plugin, persisting data (IndexedDB storage, kv/ss, synced stores, published settings, drafts), changing what src/app/sync.ts pulls in the background, and publishing (domain writer → Command → thunk, optimistic updates, undo, showing publish status)."
---
# Flotilla state
State in Flotilla flows one way. Events arrive from relays, pass the ingest policy, and land in
the current app's repository. Plugin indexes and derived stores read the repository, and
components subscribe to those. Writes go the other way: a domain writer becomes a `Command`, the
command becomes a thunk, and the thunk writes its event into the repository before any relay has
seen it.
Almost everything per-identity hangs off one welshman `App`, and signing in replaces that app.
## The app instance
`src/app/core.ts` builds the app lazily. `app` is a hand-written `ReadableWithGetter<App>`
whose first `get()` or `subscribe` builds an anonymous app. `App` runs its policies in its
constructor. Flotilla's own policies live in modules that import `core.ts` and push themselves
onto `appPolicies` when imported, so the first app has to be built after those imports run (see
[App policies](#app-policies)).
- **Login swaps the app.** `login(session)` builds a `User` from the session, cleans up the old
app, builds a new one with that user, then sets `session`. There is no account switching, so
`login` only runs while signed out: `restoreSession` at boot, the `LogIn*`/`SignUp*` flows,
and `loginWithPomade`.
- **Logout reloads the page.** `logout` in `src/app/session.ts` clears `kv`, `ss`, the user's
IndexedDB and `localStorage`, cleans up the app, then sets `window.location.href = "/"`.
The app is therefore stable for as long as anything under the login gate is mounted.
| Export | What it is | Signed out |
|---|---|---|
| `app` | the current `App` | an anonymous app |
| `session` | the persisted `Session` | `undefined` |
| `user` | `User.require($app)`, derived | subscribing or `.get()` throws |
| `usePlugin(Plugin)` | a store holding `$app.use(Plugin)` for the current app | safe |
| `profiles`, `rooms`, `relays`, `thunks`, … | `usePlugin` for 27 welshman plugins | safe |
| `fromApp(read)` | a store that re-reads `read($app)` when the app changes | safe |
| `deriveUserItem(Plugin)` | the signed-in user's entry in a keyed plugin | `undefined` |
| `userSearchRelayUrls` | the user's search relays, or `DEFAULT_SEARCH_RELAYS` | the defaults |
| `reader`, `writer`, `command` | `Domain` entry points on the current app | — |
| `login`, `appPolicies` | see above and below | — |
AGENTS.md lists `pubkey` and `signer` stores, but neither exists. Read `$app.user?.pubkey` where
absence is legitimate, and `$user.pubkey` or `user.get().signer` behind the login gate.
## Signed in vs signed out
`src/app/components/AppContainer.svelte` renders the route (`children`) only when
`$app.user?.pubkey` is set, and shows the `Landing` dialog otherwise. Route pages, and
everything under `PrimaryNav`, can assume a user.
The following run outside that gate:
- the root `src/routes/+layout.svelte` and what it starts: `restoreSession`,
`syncApplicationData`, the `notifications.sync*` functions, `Push.sync`, logging
- `ModalContainer` and every modal, including the `LogIn*`/`SignUp*` flows. Modals stay mounted
across a login.
- `Toast`, `CallBanner`, `SpeechBanner`, `NewNotificationSound`
- every app policy
This code reads `app.get().user?.pubkey` and bails when it is missing, as
`syncUserSpaceMembership` in `src/app/sync.ts` and `nip98Header` in `src/app/notifications.ts`
do.
Stores derived from `user` throw as soon as they are subscribed signed out. That includes
`isEventMuted` (`social.ts`), `deriveUserIsRoomAdmin` (`rooms.ts`) and `deriveUserCanCreateRoom`
(`management.ts`), so only use them from gated components.
## Reaching plugins
The code reaches plugins three ways:
```typescript
// Components: a store exported from core.ts, when there is one
const display = $profiles.display(pubkey, [url]).$
// Components: $app.use() for plugins core.ts doesn't export (Zappers, Feeds, Pinboards)
const zapper = $app.use(Zappers).forPubkey(pubkey, removeUndefined([url])).$
// Module scope: always through a store that rebinds when the app changes
const profileIndex = fromApp($app => $app.use(Profiles).index.$)
```
The rule is about when a binding is made:
- **Module scope, and anything outside the gate**, goes through `app`, `usePlugin`, `fromApp`
or `deriveUserItem`. A module-level `app.get()` builds the first app before the policies
register, and a binding made that way keeps reading the discarded app after login. Long-lived
listeners re-bind on `app.subscribe`, as `chatsById` in `src/app/chats.ts`,
`syncCheckedRemote` in `notifications.ts` and the resync in the root layout do.
- **Code under the gate** can bind at call time. `rooms.get().forUrl(url).$` inside
`deriveUserRooms`, or `$app.use(X)` in a component's script, is fine there, because the app
cannot change while that code is mounted.
For a new module-level store over a welshman plugin, use the `usePlugin` export in `core.ts` if
there is one. Add an export when several modules need the plugin, and otherwise write
`fromApp($app => ...)` where the store is defined.
### Per-identity caches
Bookkeeping for one identity lives on a plugin instance, so it is discarded with the app.
`Commands` in `src/app/commands.ts` keeps its `pulled` set on the plugin for this reason.
Module-level caches are fine when their contents do not depend on who is signed in, or when what
they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores.
`hasBlossomSupport` (`uploads.ts`) and `deriveHasLivekit` (`relays.ts`) use `simpleCache` from
`@welshman/lib` to share one store per url across every component that asks.
## Flotilla's own plugins
| Plugin | Base | What it is |
|---|---|---|
| `Settings` (`settings.ts`) | `DerivedPlugin` | encrypted app-data settings, plus a `values` projection |
| `Statuses` (`statuses.ts`) | `DerivedPlugin` | NIP-38 general status, keyed by pubkey |
| `Commands` (`commands.ts`) | `RelayScopedDerivedPlugin` | slash-command definitions, keyed per relay |
| `HealthChecks` (`healthChecks.ts`) | none | a plain class over `IApp` exposing `Projection`s |
Each is exposed with `usePlugin`. `Statuses` is the minimal shape:
```typescript
export class Statuses extends DerivedPlugin<TrustedEvent> {
constructor(app: IApp) {
super(app, {filters: [filter], eventToItem: event => event, getKey: event => event.pubkey})
}
fetch(pubkey: string, hints: string[] = []) {
return this.app.use(Network).loadUsingOutbox(pubkey, filter, hints)
}
}
export const statuses = usePlugin(Statuses)
```
A plugin fits a keyed collection of an event kind that needs `index`, `one` and `load`, or
per-identity logic with its own caches. Expose derived views as projections with
`projectFrom(this.index, ...)`, as `Settings.values` and `Commands.forUrl` do.
A generic nostr kind belongs upstream in `@welshman/app`, with its reader in `@welshman/domain`
(see flotilla-model), because the maintainer prefers fixing welshman to working around it here.
`Statuses` could move. `Settings`, keyed on the `flotilla/settings` d-tag, stays.
## App policies
An `AppPolicy` is `(app) => Unsubscriber`, and `app.cleanup()` tears policies down in reverse.
`core.ts` seeds `appPolicies` with welshman's `appPolicyWraps`, `appPolicyRelayStats`,
`appPolicyCacheDecrypt` and `appPolicyLogSignerMethods`. It leaves out welshman's
`appPolicyIngest` and `appPolicyAuthUnlessBlocked`, because flotilla replaces them:
| Policy | Module | What it adds |
|---|---|---|
| `ingestPolicy` | `policies.ts` | drops DVM and ephemeral kinds; skips signature checks for trusted relays |
| `authPolicy` | `policies.ts` | NIP-42 by the `relay_auth` setting, conservative or aggressive |
| `socketPolicy` | `policies.ts` | blocked relays, `relaysPendingTrust`, `relaysMostlyRestricted` |
| `storagePolicy` | `storage.ts` | the per-user IndexedDB cache, only when the app has a user |
The root layout imports `@app/policies` and `@app/storage` before anything touches the app. To
add a policy:
1. Define it in the module that owns the concern.
2. Push it onto `appPolicies` at the bottom of that module.
3. Make sure the root layout imports that module ahead of the first `app.get()`.
Inside a policy, use the `$app` argument. During construction the new app is not in the store
yet, so `app.get()` returns the previous, cleaned-up app. On first boot there is no previous
app, and `app.get()` recurses into building another one.
## The repository and derived state
Events enter `app.repository` from `ingestPolicy` (which calls `tracker.track`, then
`repository.publish`), from `Storage` loading the cache at startup, and from thunks publishing
optimistically. The tracker records which relays each event was seen on, which is what lets
space content be keyed by relay.
`src/app/repository.ts` wraps the `@welshman/store` derivations in `fromApp`:
- `deriveEvent`, `deriveEvents`, `deriveEventsById`, `deriveIsDeleted`
- relay-scoped: `deriveEventsForUrl`, `deriveEventsByIdForUrl`, `deriveEventsByIdByUrl`,
`getEventsForUrl`
- `deriveLatestEvent`
Use these for raw event queries. Welshman's `Events` plugin has the same surface returning
projections, and flotilla does not use it. Plugin reads (`get`, `one`, `load`, `index`) are
documented in welshman-app.
### Free functions in an app module
Most derived state is a plain function in the app module that owns its domain, composing plugin
projections and repository derivations:
```typescript
// src/app/actionItems.ts
export const deriveSpaceActionItems = (url: string) =>
derived(
[
deriveEventsForUrl(url, [{kinds: [REPORT]}]),
rooms.get().pendingJoins(url).$,
deriveSpaceSupportedMethods(url),
],
([$reports, $pendingJoins, $methods]) =>
sortEventsDesc([
...($methods.includes("banevent") ? $reports : []),
...($methods.includes("allowpubkey") ? $pendingJoins : []),
]),
)
```
`src/app/rooms.ts` is the fullest example. Names follow the return type:
- `derive*` returns a store: `deriveUserRooms`, `deriveUserRoomMembershipStatus`
- `get*` and `display*` return a snapshot: `displayRoom`
- plain verbs mutate: `addRoomMembers`, `reorderSpaceUrls`
Rules that involve more than one plugin belong in these functions rather than in components.
"A space's staff are room admins" lives in `deriveUserIsRoomAdmin`.
### Hand-built indexes for hot paths
A derivation that every row subscribes to, or that joins large sets, is built by hand:
- `chatsById` (`chats.ts`) updates incrementally from repository `update` events rather than
re-querying.
- `thunksByEventId` (`thunks.ts`) indexes thunk history once, and hands back the previous array
wherever an event's thunks are unchanged so rows don't churn.
- `latestActivityByPath` (`notifications.ts`) joins chats, room lists, relay info, events and
settings behind `throttled(1000, …)`.
- `deriveLatestEvent` (`repository.ts`) shares one repository listener across every watched
author.
## Local and persisted state
| State | Where | Per user | On logout |
|---|---|---|---|
| repository, tracker, relays, relay stats, handles, zappers, plaintext, wraps | IndexedDB | yes | deleted |
| settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted |
| `session`, `wallet` | `ss` | no | cleared |
| `theme`, `flTheme`, `checked`, `shouldUnwrap`, `device`, `notificationSettings`, push state | `kv` | no | cleared |
| drafts, dictations | a module `Map`, lost on reload | no | page reloads |
### IndexedDB (`src/app/storage.ts`, `src/lib/indexeddb.ts`)
`storagePolicy` builds a `Storage` only for an app with a user, so a signed-out app caches
nothing. Each identity gets its own database, `flotilla-9gl-<pubkey>`. `IDB` reconciles object
stores by bumping the database version, so adding or removing a table needs no migration.
`shouldPersistEvent` keeps:
- profiles and metadata lists (follows, mutes, relay lists, app data, room lists) from any author
- alert kinds
- relay- and room-scoped kinds
- DMs
- room membership changes, only when they tag the user
Room messages, threads and other content are not kept, and background sync pulls them again.
- Rows keep each event's relays inline. A relay-scoped event without them can never be keyed to
a space again, so it is dropped on load.
- `COMMAND` definitions expire after a week.
- Boot waits only for events and relays (`storage.get()?.ready`). The other tables load on the
next tick.
To persist another kind, add it to `kinds` in `storage.ts`. To persist a new map-backed plugin,
add a `TABLES` entry and an `init*` method shaped like `initHandles`: load the rows, subscribe
to `onItem`, and batch the writes.
### kv, ss and the two ways to bind them
`kv` wraps Capacitor `Preferences` and `ss` wraps `SecureStorage`. Both are exported from
`storage.ts`, queue their writes, and JSON-encode values. Neither is namespaced per user, so
anything in them outlives a login and is cleared only by logout. Secrets go in `ss`.
- `synced({key, storage, defaultValue})` creates a store that persists itself. It emits the
default first, and the stored value arrives later (`.ready`). `theme` and `flTheme`
(`theme.ts`), `checked` (`notifications.ts`) and `shouldUnwrap` (`sync.ts`) use it.
- `sync({key, store, storage})` binds a store that already exists. The root layout awaits it
for `device`, `wallet`, `notificationSettings` and `pushState` before first render, so boot
code sees the restored values. It also binds `shouldUnwrap`, which `synced` already persists.
Raw `localStorage` holds only `theme`, `fl-theme` and `font-size`. The root layout mirrors them
there to apply them synchronously before `kv` loads, which avoids a flash of the wrong theme.
### Settings (`src/app/settings.ts`)
Settings are an encrypted app-data event with d-tag `flotilla/settings`, read through the
`Settings` plugin:
- `userSettingsValues` is the current user's values merged over `defaultSettings`. `getSetting`
is its snapshot, and there are derived helpers such as `deriveShouldNotify`.
- `publishSettings(partial)` calls `forceLoad` first, so a write merges onto the latest event
rather than a stale cache.
- Settings pages bind `createSettingsForm()`. The form adopts the real values when the event
finishes decrypting, but only while untouched, so defaults never overwrite real settings.
A preference that should follow the user across devices goes in `SettingsValues` and
`defaultSettings`. One that belongs to a device goes in a `kv` store, as push, sound and badge do
in `notificationSettings`. Per-space alert preferences are published (`alerts`); the device's
push permission is not.
`checked`, the read markers behind badges, lives in `kv`, and `syncCheckedRemote` mirrors it to
dufflepud's `kv/checked` with NIP-98 auth. That makes it cross-device without publishing an
event on every read.
### Drafts (`src/app/drafts.ts`)
`DraftKey<T>` is a typed handle over an in-memory `Map`. A draft survives the composer
unmounting and a navigation, but not a reload. Key it by context: `RoomCompose` uses
`room:${url ?? ""}:${h ?? ""}` and `EventReply` uses `reply:${event.id}:${parent?.id || ""}`.
The dictation registry in `dictation.ts` works the same way, so a transcription can finish after
its composer has gone.
## Background sync (`src/app/sync.ts`)
The root layout calls `syncApplicationData()` once the session is restored and storage is ready,
and again after every app swap. `Access.completeJoin` calls it after a space is joined. Each
call tears down the previous run.
- `syncRelays` loads NIP-11 for the indexer relays, the current route's relay and the user's
spaces.
- `syncUserData` loads the user's relay list, then on each relay-list change their other lists,
profile and `Settings`. It also pulls the user's own space and room membership events, and
their follows' follow and mute lists.
- `syncSpaces` covers each joined space plus the current route's space. It pulls membership,
roles, room metadata, pins and livekit state in full, and recent content: a month of it, or a
week for reactions and comments.
- `syncDMs` pulls gift wraps from the user's messaging relays, only when `shouldUnwrap` is on.
`pullAndListen` is a negentropy `Sync.pull` plus a live `limit: 0` request, both stopped through
an `AbortController`. `syncSpaces` and `syncUserData` diff their `unsubscribersBy*` maps against
the room list, so a new filter goes into the right `pullAndListen` call.
Background sync keeps badges, navigation, the inbox and notifications correct on any page. Data
that must be current app-wide belongs here. Data only one page shows is loaded by that page's
components; see flotilla-views.
## Mutations
The prevailing path runs from a domain writer to a command to a thunk, adapted from
`ThreadCreate.svelte`:
```typescript
const eventWriter = writer(Thread)
.setContent(content)
.setTitle(title)
.setProtected(protect)
.forceRoutes(relay(url))
if (room) {
eventWriter.setRoom(url, room)
}
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
if (error) {
return pushToast({theme: "error", message: error})
}
```
`publish` sends to the writer's own routes, which is why the excerpt forces them with
`forceRoutes`. `publishToRelays(urls)` overrides those routes instead.
flotilla-model's "Which relays an event goes to" says which one each kind needs.
Plugin mutators already return a `Command`: `roomLists.get().addRelay(url).then(publish)`,
`rooms.get().addMember(url, room, pubkey)`, `reactions.get().react(event, content, ...)`,
`deletes.get().deleteEvent(event, w => w.setProtected(protect))`. Their `update`-style methods
`forceLoad` before writing. A replaceable event you build yourself needs the same, as in
`publishSettings`.
Some call sites call `thunks.get().publish({event, relays, delay})` directly. Anything that
honours the `send_delay` window does, because `Command` cannot carry it: room chat
(`RoomChat.svelte`), the comment composers (`CommentCompose.svelte` and `EventReply.svelte`) and
`publishRoomQuote` in `rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through
`wraps.get().publish({event, recipients})`, which returns a merged thunk (see `reactions.ts`).
NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return
`{result, error}`, and the caller handles `error`.
### Optimistic updates, undo and status
- **Optimistic writes.** `Thunks` writes the event into the repository and tracks it against its
relays when it is enqueued, so every derived store sees it immediately. Signing then swaps the
unsigned event for the signed one.
- **Undo.** `thunk.abort()` during the `delay` removes the event from the repository and from
`history`. When `send_delay` is set, room chat shows a `ThunkToast` whose Cancel button aborts,
and a comment carries the same Cancel in the `ThunkPending` row under it.
- **Editing.** Editing a message deletes it and republishes with the same `created_at` (see
`RoomChat.svelte`).
- **Status in rows.** Rows look up `$thunksByEventId.get(event.id) ?? noThunks` and pass
`$thunks.merge(...)` to `ThunkStatus`, or to `ThunkFailure`, which retries per relay.
`ThunkStatusOrDeleted` combines publish status with deletion. `ChatMessage.svelte` filters the
whole `history` per row instead.
- **Status in forms.** Forms await `waitForError()` and toast the message, as in the excerpt
above.
## Other app-level stores
- **A join over many sources.** `notifications.ts` derives `latestActivityByPath`, then
`allNotifications`, then `notifications` and the counts. `inbox.ts` derives from the same two
stores, so the inbox matches the badges.
- **Singleton session state.** `call.ts` keeps call state in plain writables (`callState`,
`currentCallSession`, …).
- **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`.
- **A controller per flow.** `Access` (`access.ts`) and `Nip46Controller` (`nip46.ts`) are
classes a component instantiates (`new Access(url)`). They hold the writables and actions for
a multi-step flow.
- **Module-owned values.** `wallet` in `lightning.ts` is a `withGetter(writable(...))` that the
root layout persists.
## Runes and stores
Modules in `src/app` use svelte stores. The one `.svelte.ts` module is `src/app/modal.svelte.ts`:
its modal registry is `$state`, and the open stack is `$derived` from `page.state` in
`$app/state`, SvelteKit's rune-based replacement for the deprecated `$app/stores`. A rune-only
source is what justifies the exception. `sync.ts` and `notifications.ts` read `page` from
`$app/stores` because they subscribe to it outside a component.
Component-local `$state` covers UI state that dies with the component. Everything else is a
store, consumed in components with `$store`.
## Where does this state belong?
Take the first answer that fits:
1. **It is an event, or derived from events.** It is already in the repository, or should be.
Read it with a plugin or an `@app/repository` derivation, and put the domain logic in a
`derive*` function in the owning app module (`deriveUserRooms`). Don't copy it into a
writable.
2. **It is a keyed collection of one kind, loaded by key.** Write a plugin. A generic kind goes
upstream in `@welshman/app`. A flotilla-specific one is a `DerivedPlugin` here, exposed with
`usePlugin` (`Settings`, `Statuses`, `Commands`).
3. **It is bookkeeping for one identity.** Put it on a plugin instance (`Commands.pulled`) or in
a policy, never in a module-level map that outlives login. Retry or resume logic around
welshman behaviour is a fix for welshman instead.
4. **It is a preference.** If it follows the user, it is a `SettingsValues` field. If it is per
device, it is a `synced` store in `kv`. A secret goes in `ss`.
5. **It is app-wide state that does not come from nostr.** Make it a writable in the owning app
module (`callState`, `toast`, `relaysPendingTrust`).
6. **It must outlive a component but not a reload.** Use a module map, as `DraftKey` and the
dictation registry do.
7. **It is one component's UI.** Use `$state` in the component.
## Related skills
- `flotilla-architecture`: the layer rules, what each `src/app` module is for, boot at a glance
- `flotilla-views`: routes, components, and how components load data and show state
- `flotilla-model`: spaces, rooms, NIP-43/29/86, which relays events go to, domain kinds
- `welshman-app`: `App`, plugins, `Command`, thunks, `Network`/`Sync`, `Events`
- `welshman-store`: `deriveEventsById`, `deriveItemsByKey`, `synced`, `throttled`, `withGetter`
- `welshman-domain`: the readers and writers behind `reader`, `writer` and `command`
- `welshman-net`: the repository, tracker and socket policies under the app

View file

@ -0,0 +1,467 @@
---
name: flotilla-views
description: "Use this skill when adding or changing a route, layout, page, or component in flotilla: deciding between src/lib/components and src/app/components, naming a component, choosing its props, navigating or opening a modal, drawer, popover or toast, building a create/edit form, loading data from a page or component (detail pages, feeds, infinite scroll), wiring a button to a mutation with loading and error states, or styling a component."
---
# Flotilla views: routes, components, and how they reach state
Pages read their params, load what they show, and hand identifiers to components. Components are
flat, noun-first, and derive the rest from those identifiers.
## Routing
### No load functions
Nothing renders on a server (`ssr = false`; see flotilla-architecture for the build), so there are
no `+page.ts` files. Every page loads its own data in `onMount` or an `$effect`, and every layout
is a `+layout.svelte`.
### The tree
```
/ redirect: goToHome() /home dashboard (Home* sections)
/spaces your spaces + discovery /spaces/create
/spaces/[relay] mobile space menu; desktop redirects to goToSpace()
/spaces/[relay]/[h] room chat /spaces/[relay]/chat space-level chat
/spaces/[relay]/{about,admin,directory,library}
/spaces/[relay]/{threads,goals,polls}[/[id]]
/spaces/[relay]/{classifieds,articles,calendar}[/[address]] articles/create
/chat, /chat/[chat] DMs /people/[npub] profile page
/settings/{profile,alerts,wallet,hosting,content,privacy,theme,about}
/join invite link landing /share share-intent landing
/[bech32] any nip19 entity, resolved and redirected
```
### Params
- `[relay]`: `encodeRelay(url)` and `decodeRelay(param)` in `src/app/relays.ts`. Encoding strips
`wss://` and the trailing slash and URI-encodes the rest; decoding normalizes it back. Build
paths with the helpers in `src/app/routes.ts` rather than by hand.
- `[h]`: the NIP-29 room id, unencoded (`makeRoomPath(url, h)`). Static segments win over it,
which `makeSpaceChatPath(url)` relies on: it is `makeRoomPath(url, "chat")` and lands on the
static `chat` page. A new static segment under `[relay]` shadows any room with that id.
- `[id]` for regular events, `[address]` for addressable ones. `makeSpacePath(url, ...extra)`
URI-encodes each extra segment and drops `undefined`, so `makeClassifiedPath(url, address)` is
safe. `[chat]` is `makeChatId(pubkeys)` from `src/app/chats.ts`, and `[npub]` comes from
`makeProfilePath(pubkey)`.
- State that shouldn't be a route goes in the query string: `?at=` (jump to a message), `?topic=`,
`?board=`, `?page=`, and `?h=&shareToChat=1` on article create.
Pages read params once into constants:
```typescript
const {relay, address} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay)
```
That is safe because the layouts remount their children when a param changes.
`spaces/+layout.svelte` keys on `relay`, `spaces/[relay]/+layout.svelte` and
`chat/+layout.svelte` key on the pathname, `[h]/+layout.svelte` keys on `?at=`, and
`people/[npub]/+layout.svelte` keys on `npub`. A new param-bearing route needs the same `{#key}`,
or its page has to derive from `$page` instead. Routes read `$page` from the deprecated
`$app/stores`; only the two modal modules use `page` from `$app/state`.
### Layouts
- `src/routes/+layout.svelte` runs the boot sequence, renders `AppContainer` and
`ModalContainer`, and sets `document.title` from `getPageTitle`. `AppContainer` gates the route
on a signed-in user (flotilla-state), showing `Landing` in a `noEscape` dialog otherwise.
- `spaces/[relay]/+layout.svelte` gates the space, pushing one modal at a time, once per url:
`SpaceRedirect` (the NIP-11 document carries a `redirect_to`), `SpaceJoin` (the space is not in
the user's room list, checked after a `forceLoad`), `SpaceAuthError`, `SpaceTrustRelay`. It
renders `SecondaryNav` with `SpaceMenu` and wraps the page in `Page`, except at the space root.
- `settings/+layout.svelte` is a `SecondaryNav` of `SecondaryNavItem` links. A new settings page
needs an entry there.
- `chat/+layout.svelte` is the conversation list, plus a FAB to start a chat.
Since the space layout supplies `Page`, a space page renders a `SpaceBar` and a `PageContent`:
```svelte
<SpaceBar>
{#snippet leading()}<Icon icon={CaseMinimalistic} />{/snippet}
{#snippet title()}<strong>Classifieds</strong>{/snippet}
{#snippet action()}
<Button class="button button-primary button-sm" onclick={createClassified}>Create</Button>
{/snippet}
</SpaceBar>
<PageContent bind:element class="@container flex flex-col gap-3 p-2 sm:gap-4 sm:p-4">
```
Detail pages also pass `back`, a handler that calls `history.back()`, which `SpaceBar` shows on
mobile. Pages outside a space use `Page`, `PageBar` and `PageContent` themselves.
### URL builders, titles, deep links
`src/app/routes.ts` is the only place paths are built:
- `makeSpacePath`, `makeRoomPath`, `makeSpaceChatPath`, `makeProfilePath`, `makeChatPath`, and
one builder per content page (`makeThreadPath`, `makeClassifiedPath`, `makeArticlePath`,
`makeCalendarPath`, `makeGoalPath`, `makePollPath`, `makeLibraryPath`, `makeArticleCreatePath`).
- `makeContentPath(url, kind, idOrAddress)` maps a kind to its page. `makeEventPath` builds on it
for any event, covering DMs, room messages (`?at=`) and comments (through the parent's `K`,
`A` and `E` tags), and falls back to `entityLink` from `src/app/env.ts`, an external link.
- `goToEvent(event)` scrolls to the event if it is already rendered with a `data-event` attribute
and navigates otherwise; `makeEventPermalink` is the shareable form.
- `goToSpace(url)` goes to `makeSpaceEntryPath(url)`: the last page visited in that space, which
`setupHistory` records, else chat or about. `goToChat(pubkeys)` checks for messaging relays
first, pushing `ChatEnable` if there are none.
`src/app/title.ts` maps route ids to tab titles. A new static route needs a `staticTitles` entry,
and a new event detail route needs an `eventRoutes` entry and a branch in `getPageTitle`.
Without one the tab shows only `PLATFORM_NAME`.
Deep links arrive in `handleDeepLink` in the root layout: push-notification links (`?relay=&id=`),
the iOS share extension (the `share` host), signer returns (`x-callback-url`), and otherwise a
plain path. Nostr entities go through `/[bech32]`, which sends profiles to `makeProfilePath`, and
loads events before calling `goToEvent`.
### Adding a space content page
1. `src/routes/spaces/[relay]/<name>/+page.svelte`, plus `[id]` or `[address]` for detail.
2. `make<Name>Path` in `src/app/routes.ts`, and a case in `makeContentPath`, so notifications and
permalinks land there.
3. Titles in `src/app/title.ts`.
4. A `SecondaryNavItem` in `SpaceMenuNavItems.svelte`. Content entries appear only once the space
holds that kind, which comes from `CONTENT_KINDS` in `src/app/content.ts`.
The registries outside the view layer, including the link-preview server, are in
flotilla-architecture and flotilla-model.
## Navigation, modals, popovers, toasts
### navigate, not goto
`navigate(path, {replaceState, keepModal})` in `src/app/modal.ts` wraps `goto`. With a modal open
it replaces the modal's history entry, so Back doesn't reopen it, and `keepModal` changes the
page underneath while leaving the stack open (`goToHome` uses it). `Link` calls `navigate` and
stops propagation, so a link inside a clickable card only follows the link. Plain `goto` survives
in pages for query-string updates (`replaceState`, `noScroll`, `keepFocus`) and redirects, where
no modal can be open.
### pushModal
```typescript
pushModal(ClassifiedEdit, {url, event}) // replaces any open modals
pushModal(WalletConnect, {}, {nested: true}) // stacks on top of the current one
pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true}) // swaps out the current one
pushModal(SpaceMenuDrawer, {url: spaceUrl}, {drawer: true}) // side drawer instead of a dialog
```
Open modal ids live in SvelteKit page state (`App.PageState.modals` in `src/app.d.ts`), the
components live in a `$state` record in `src/app/modal.svelte.ts`, and `ModalContainer` mounts
each one inside `Dialog` or `Drawer`. Each modal owns a history entry, so Back closes it, and a
push without `nested` replaces the whole stack. `replaceState` suits mobile menus that open a
follow-up (`RoomItemMenuMobile`) and multi-step flows. `noEscape` removes the close button and
ignores Escape and the backdrop; the space gates use it. `ModalOptions.path` is never read.
Modal components take identifiers like any other component. `Dialog` supplies the chrome, and
`/join` renders `SpaceInviteAccept` in a `Dialog` directly. The usual shape:
```svelte
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Room</ModalTitle>
<ModalSubtitle>On <span class="text-primary">{displayRelayUrl(url)}</span></ModalSubtitle>
</ModalHeader>
...fields
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>Go back</Button>
<Button type="submit" class="button button-primary" disabled={loading}>
<Spinner {loading}>Create Room</Spinner>
</Button>
</ModalFooter>
</Modal>
```
A modal closes itself with `history.back()`, in 116 call sites. `clearModals()` ends a flow that
may sit on top of other modals, such as a delete confirmed from a menu. `popModal()` closes the
modal before doing something else in the same handler, where `history.back()` would race it:
`ProfileDetail` pops before `goToChat`, and `SearchBody` pops before `goToEvent`.
For yes/no questions, push `Confirm` from lib with `{title, message, confirm}`; it runs `confirm`
behind its own loading state. Reusable confirmations get a `*Confirm` wrapper
(`EventDeleteConfirm`).
### Popover menus
`MenuButton` renders a `Tippy` popover around the component you pass, adding an `onClick` prop
that hides it:
```svelte
<MenuButton component={ChatMenu} aria-label="Chat options" />
```
`EventMenu` attaches `onClick` to its `<ul>`, so any item closes the popover, and each item
usually pushes a modal. On mobile, rows push a `*MenuMobile` modal instead: `RoomItem` pushes
`RoomItemMenuMobile` on tap.
### Toasts
`pushToast` in `src/app/toast.ts` shows one toast at a time:
```typescript
pushToast({message: "Role created!"})
pushToast({theme: "error", message, action: {message: "Details", onclick}})
pushToast({timeout: 30_000, children: {component: ThunkToast, props: {thunk}}})
```
A `children` component receives the `toast` as a prop, so it can pop itself. `clip(value)` copies
to the clipboard and toasts.
## lib vs app components
`src/lib/components` knows nothing about the app instance, stores, or nostr kinds. Its components
import third-party libraries, `@welshman/lib` helpers, `@lib/*` and `@assets/*`, plus
`$app/stores` in `PrimaryNavItem` and `SecondaryNavItem`, which highlight the active path. What
lives there:
- page chrome: `Page`, `PageBar`, `PageContent`, `SecondaryNav*`, `PrimaryNavItem`, `FAB`
- modal chrome: `Dialog`, `Drawer`, `Modal`, `ModalBody`, `ModalHeader`, `ModalTitle`,
`ModalSubtitle`, `ModalFooter`, `Confirm`
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`,
`Popover`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
- the CSS component families (`button.css`, `card.css`, …) and the theme tokens
Anything that takes an identifier and reads a store, publishes, or knows a kind is an app
component. A lib component that needs app behavior takes it as a prop, such as `MenuButton`'s
`component`. Three lib components import `@app` anyway; flotilla-architecture lists them under
its layer exceptions.
`src/app/components` is flat, and `hosting/` is the only subdirectory it has ever had, brought in
whole by the caravel port (cf938c63) with names that would blur into the flat `Relay*` family.
New components go in the flat folder.
## Naming
Names are `<Entity><Qualifier>`: the prefix says what it's about, the suffix what it does, which
keeps families adjacent in a flat listing. `ClassifiedActions`, `ClassifiedCreate`,
`ClassifiedEdit`, `ClassifiedForm`, `ClassifiedItem`, `ClassifiedStatus`.
Name a modal for what it does (`SpaceJoin`, `ClassifiedCreate`, `EventDeleteConfirm`); only a
handful carry a `Modal` or `Dialog` suffix. The suffix vocabulary, the prefix families, and the
names that break the pattern are in [naming.md](naming.md).
## Props
### Identifiers first, the event when you have it
`url` is a prop in 159 components, `h` in 32 and `pubkey` in 28. Relays, rooms and profiles have
stores, so a component takes the key and looks up the rest: `RoomName` takes `{url, h}` and reads
`$rooms.forRoom(url, h)`, `ProfileName` takes `pubkey` and reads `$profiles.display`.
Events are the exception, and 66 components take `event: TrustedEvent`, since the parent already
holds the event from a feed or a `deriveEvent`. An event prop usually travels with `url`, the
relay it lives on, which routes its replies and reactions, and with `context: FeedContext`, the
shared loader below. A component takes a pointer instead only when it has to load the event
itself, as `ContentQuote` does. Anything with neither a store nor an event is passed as its
domain reader: `RoleEdit` takes `role: RelayRoleReader`.
Identifier props are read once, `const room = $rooms.forRoom(url, h)`, since pages remount on a
param change and lists key by id. A prop that does change while mounted needs `$derived`:
`ClassifiedActions` reads its event that way, because editing a listing hands it a new version.
### The rest of the vocabulary
- Callbacks are camelCase `on*`: `onSubmit`, `onClose`, `onCancel`, `onReply`, `onSelect`,
`onResolved`, plus the `onClick` that closes a popover. `RoomForm` and `ProfileEditForm` take a
lowercase `onsubmit`, a leftover.
- Steps in a flow take `next`, and the signup steps add `step` and `totalSteps`.
- Snippets: `children`; `header` and `footer` on forms; `customActions`, which adds items to
`EventActions`, `EventMenu` and `ProfileMenu`; `leading`, `title` and `action` on `SpaceBar`. A
snippet can take arguments, as `RoomForm`'s `footer: Snippet<[{loading: boolean}]>` does.
- `$bindable` is for input-like components: `value` on `TopicMultiSelect` and
`ProfileMultiSelect`, `element` on `PageContent`, `notifications` on `SpaceJoinNotifications`.
- Display flags are fine (`showRoom`, `showActivity`, `hideZap`, `class`); derived data is not.
- 24 app components and 20 lib components declare `interface Props`, against 232 using `type`.
## Reading state in a component
Components read plugins through the `usePlugin` stores exported from `src/app/core.ts`
(`$profiles`, `$relays`, `$rooms`, `$roomLists`, `$network`, `$thunks`, `$deletes`,
`$relayManagement`, …). `$app.use(X)` covers plugins with no export, such as `Zappers`,
`Pinboards` and `Feeds`; flotilla-state has the rules about which to use where.
How you bind depends on what the method returns:
```svelte
<script lang="ts">
const room = $rooms.forRoom(url, h) // Readable: one, forRoom
const display = $profiles.display(pubkey, removeUndefined([url])).$ // Projection: take .$
const shouldProtect = $relays.hasNip(url, 70) // Promise: load, hasNip
</script>
{$room?.meta?.name() || h} · {$display}
```
In a handler, call `.get()` on a projection instead of subscribing. The app modules add `derive*`
factories over the same data: `deriveEvent` and `deriveEventsById` in `src/app/repository.ts`,
`deriveSpaceSupportedMethods` in `src/app/management.ts`, `deriveRelayAuthError` in
`src/app/access.ts`. Call them at the top of the script with fixed arguments, and wrap one in
`$derived` only when its arguments change, as the thread page does for filters that wait on the
root event.
Read an event's tags through a domain reader, `$derived(reader(Classified)(event))`, with
`reader` from `@app/core`. `$user` throws signed out, so use it only under the login gate.
## Loading from the network
A page or component loads what it displays; a layout loads what it gates on. Background sync for
data every page needs lives in `src/app/sync.ts`.
**One entity, or a handful of lists:** call plugin `load` in `onMount`, and toast on failure, as
`people/[npub]/+page.svelte` does for the profile, relay list, follow, pin, room and messaging
lists before loading the author's outbox with `$network.loadLenient`.
**A detail page:** `deriveEvent(address, [url])` loads when nothing local matches. While it is
empty, seven pages show a spinner that turns into a failure message:
```svelte
{#await sleep(5000)}
<Spinner loading>Loading listing...</Spinner>
{:then}
<p>Failed to load classified listing.</p>
{/await}
```
**Related events:** request them, abort on teardown, and read them back from the repository with
`deriveEventsAsc(deriveEventsById(filters))`.
```typescript
onMount(() => {
const controller = new AbortController()
$network.request({relays, filters, signal: controller.signal})
return () => controller.abort()
})
```
That is `EventComments`. The thread detail page does the same in an `$effect`.
**A list that pages as you scroll:** `makeFeed`, `makeScrollLoader` and `makeFeedContext` from
`src/app/feeds.ts`. Every space list page uses them, as do `HomeNetwork`, `ProfilePageNotes` and
`RoomChat`. The threads and classifieds pages are the reference:
```typescript
const context = makeFeedContext({relays: [url]})
onDestroy(context.cleanup)
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $state(readable([]))
const loading = $derived(isFeedLoading($older))
const exhausted = $derived($older?.status === "exhausted")
onMount(() => {
const feed = makeFeed({relays: [url], onEvent: context.add, filters})
events = feed.events
older = makeScrollLoader(element!, feed.loadOlder)
return () => {
older?.stop()
feed.cleanup()
}
})
```
The feed is built in `onMount` because the loader needs the bound scroll element from
`<PageContent bind:element>`. `context.add` batches the reactions, comments and deletions for
every event the feed yields, rows get the same `context`, and their `*Actions` read
`context.related(event)` and `context.deleted(event)`. Close the list with `<Spinner {loading}>`
and an `{#if}` chain over loading, empty and exhausted. Calendars use
`makeCalendarFeed`, which pages by date tag rather than `created_at`.
`ProfileFeed` still drives welshman's `FeedController` through
`$app.use(Feeds).makeFeedController` and `createScroller`, but 27fa25c3 moved the home feed onto
the app helpers, which is the direction for new lists. Only `RoomChat` virtualizes; other long
lists wrap each row's root in `Cv`, which applies `content-visibility` and paints two viewports
ahead. All ten `*Item` components that appear in a list use it.
## Mutations from a component
flotilla-state covers the writer → command → thunk path. The component around it owns a
`loading` flag, awaits the first error, toasts it, and closes:
```typescript
const submit = async () => {
loading = true
try {
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
if (error) {
return pushToast({theme: "error", message: error})
}
history.back()
} finally {
loading = false
}
}
```
Bind the flag to the button with `disabled={loading}` and `<Spinner {loading}>`. Plugin mutators
return a `Command` as well: `$deletes.deleteEvent(event, w => w.setProtected(protect))` then
`.publishToRelays([url])` in `EventDeleteConfirm`, or `$rooms.createRoom(url, room)` then
`.publish()` in `RoomForm`. NIP-86 calls resolve to `{error}` instead of a thunk:
`$relayManagement.forUrl(url).createRole(...)` in `RoleCreate`.
The hosting backend isn't nostr. Toast a readable message, and `console.error` anything that
isn't a `HostingError`, as `HomeHosting` and `hosting/CustomDomainModal` do. Publishes are
optimistic, so rows show progress in place: the seven per-kind `*Actions` components wrap their
contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`.
## Forms
`Field` puts a label above its control, with optional `secondary` and `info` snippets.
`FieldInline` puts the label left and the control right, as settings and detail rows do. Controls
are plain elements styled by class (`<label class="input …">`, `<select class="select input">`),
or bindable inputs from lib (`ImagesInput`, `IconInput`) and app (`TopicMultiSelect`).
Create/edit pairs come in three shapes:
- **Fields only.** `RoleForm` exports `Values` from `<script module>`, takes
`initialValues?: Partial<Values>`, `loading` and `onSubmit(values)`, and renders the footer.
`RoleCreate` and `RoleEdit` each own their mutation, toast and close. `hosting/RelayForm` does
the same with `Pick<HostedRelay, …>`. Use this shape when create and edit differ.
- **The form owns the mutation.** `RoomForm` creates, edits and joins, while `RoomCreate` and
`RoomEdit` supply `header`, `footer({loading})` and where to go next.
- **The form owns the mutation and a draft.** `ClassifiedForm` persists fields with `DraftKey`
and republishes the same `d` on edit. `ClassifiedCreate` passes a header, and `ClassifiedEdit`
turns a reader into `initialValues`. Its `Values` type stays local, so `ClassifiedEdit`
restates the shape; export it instead.
Rich text uses `makeEditor` from `src/app/editor` with `EditorContent`.
## Styling
- Classes come from the component families in `src/lib/components/*.css`, which `theme.css`
imports: `button` with `button-primary|neutral|link|ghost|error` and
`button-sm|xs|circle|square`, `card`, `badge`, `input`, `select`, `textarea`, `menu`. These are
flotilla's own; daisyUI is not installed.
- Colors are the semantic tokens registered in `base.css`: `bg-surface`, `bg-surface-more`,
`text-content`, `text-content-muted`, `border-line`, `text-primary`, `text-error`. The clay,
flat and navy themes supply the values through `data-fl-theme`.
- Seven `class:` directives remain as leftovers; everything else builds classes with `cx`.
- Container queries go on the `PageContent`, as in `@2xl:grid-cols-2` for the classifieds grid.
- Icons are `import X from "@assets/icons/<name>.svg?dataurl"` with `<Icon icon={X} size={4} />`,
where `size` counts 4px steps. List entries animate with `in:fly` from `@lib/transition`.
## Related skills
- `flotilla-architecture`: layer rules and exceptions, the `src/app` modules, boot, placement
- `flotilla-state`: plugin stores, `derive*` stores, drafts, settings, thunks and commands
- `flotilla-model`: spaces, rooms, NIP-43/29/86, content kinds, and where events are published
- `welshman-app`: plugins, projections, `Command`, thunks, `Feeds`
- `welshman-store`: `deriveEventsById`, `deriveEventsAsc`, the other repository stores
- `welshman-domain`: the readers and writers behind `reader` and `writer`
- `welshman-feeds`: `FeedController`, still used by `ProfileFeed`
- `welshman-editor`: the composer behind `makeEditor`

View file

@ -0,0 +1,61 @@
# Component naming vocabulary
Companion to `flotilla-views`. Names in `src/app/components` are `<Entity><Qualifier>`, flat and
PascalCase. This is what the qualifiers mean, drawn from the ~320 components there.
## Suffixes
| Suffix | Means | Examples |
|---|---|---|
| `Item` | one row or card in a list | `ClassifiedItem`, `NoteItem`, `PeopleItem`, `ReportItem`, `RoomItem` (a message in a room), `SpaceMenuRoomItem` |
| `Actions` | the footer row under an event: reactions, status, overflow menu | `ClassifiedActions`, `ThreadActions`, `GoalActions`, `EventActions` |
| `Menu` | the contents of a popover, taking an `onClick` that closes it | `EventMenu`, `ChatMenu`, `RoomItemMenu`, `SpaceMemberMenu` |
| `MenuList` | the popover contents when `*Menu` is the trigger instead | `ProfileMenu` → `ProfileMenuList`, `ReportMenu` → `ReportMenuList` |
| `Mobile` | the same actions as a modal, pushed on tap instead of hovered | `RoomItemMenuMobile`, `ChatMessageMenuMobile`, `SpaceMenuMobile` |
| `Create` / `Edit` | a modal that publishes a new or edited event | `ClassifiedCreate`, `RoomEdit`, `PinEdit` |
| `Form` | the fields shared by a Create/Edit pair | `ClassifiedForm`, `RoomForm`, `RoleForm` |
| `Detail` | a modal with everything known about an entity | `ProfileDetail`, `RoomDetail`, `ContentLinkDetail` |
| `Info` | a modal with an event's underlying data | `EventInfo`, `ProfileInfo` |
| `Summary` | a compact read-only digest, inline | `RelaySummary`, `GoalSummary`, `ReactionSummary` |
| `Status` | a small badge or indicator | `ClassifiedStatus`, `SignerStatus`, `ThunkStatus` |
| `Card` | a framed presentation of an event | `NoteCard`, `HomeInboxItemCard` |
| `Bar` | a horizontal strip across the top or bottom of something | `SpaceBar`, `ComposeBar`, `EventActionBar` |
| `Compose` | a message composer | `ChatCompose`, `RoomCompose`, `CommentCompose` |
| `Confirm` | the step that confirms an action or a code | `EventDeleteConfirm`, `LogInOTPConfirm`, `SignUpEmailConfirm` |
| `Add` | a modal that adds something to a collection | `SpaceAdd`, `PinAdd`, `RoomMembersAdd` |
| `Select` | a picker, as a modal or a bindable input | `PinboardSelect`, `TopicMultiSelect` |
| `Button` | a self-contained trigger | `ZapButton`, `DictationButton`, `RoomItemEmojiButton` |
| `Name`, `Image`, `Icon`, `Link`, `Circle` | one identifier rendered | `RoomName`, `RelayIcon`, `ProfileLink`, `ProfileCircle` |
| `Page` | the body of a route, when the page file delegates | `ProfilePage`, `ProfilePageNotes` |
| `Enable` | a gate that sets a feature up before letting you in | `ChatEnable`, `OpenRouterEnable` |
Verbs also work as qualifiers where no noun fits: `SpaceJoin`, `SpaceExit`, `SpaceInvite`,
`ProfileDelete`, `RoomSearch`.
## Prefix families
Most prefixes are the entity (`Space` 34 components, `Room` 28, `Profile` 26, `Chat` 11, plus one
per content kind). Four are families instead:
- `Event*` is kind-agnostic event UI: `EventMenu`, `EventActions`, `EventInfo`, `EventComments`.
- `Note*` renders an event as a note: `NoteItem`, `NoteCard`, `NoteContent`, one
`NoteContent<Kind>` per kind, and a compact `NoteContentMinimal<Kind>`.
- `Content*` renders parsed content tokens: `ContentMention`, `ContentQuote`, `ContentTopic`.
- `Home*` are the dashboard's sections; `LogIn*` and `SignUp*` are auth steps; `Thunk*` show
publish status; `Info*` are explainer dialogs (`InfoNostr`, `InfoKeys`, `InfoRelay`).
Eleven components are a bare entity, being the root of their feature: `Chat`, `Content`,
`Landing`, `Profile`, `Reaction`, `Report`, `Search`, `Share`, `Toast`, `Zap`, `Banner`.
## Outliers
Don't follow these:
- Verb-first or verb-in-the-middle: `EditFeaturedContent`, `ShareEvent`, `RoleAddMembers` (next
to `RoomMembersAdd`), `WalletUpdateReceivingAddress`, `NewNotificationSound`, `MenuSettings`.
- `Modal` and `Dialog` suffixes: `IconPickerModal`, `ImageInputModal`, `VoiceRoomJoinDialog`,
`VoiceCallAudioSettingsDialog`, and hosting's `PaymentDialog`, `PlanModal`,
`CustomDomainModal`. Everything else is named for what it does, not for being a modal.
- `ReportDetails`, plural, next to `ProfileDetail` and `RoomDetail`.
- `SpaceMenu` is the space's sidebar navigation, not a popover, and it has its own family:
`SpaceMenuHeader`, `SpaceMenuNavItems`, `SpaceMenuRooms`, `SpaceMenuDrawer`.

View file

@ -1,279 +1,457 @@
--- ---
name: welshman-app name: welshman-app
description: "Use this skill when working with @welshman/app: the App instance and its plugins, sessions and login, publishing via Commands and Thunks, app policies, WoT, feeds, sync, or relay selection at the app layer." description: "Use this skill when working with @welshman/app: the instance-based client for building nostr applications — creating an App instance, the use() plugin registry, User & sessions, reactive data stores (profiles, follows, mutes, relay lists, handles, zappers), optimistic publishing with thunks, outbox-model requests, routing, web of trust, feeds, and search."
--- ---
# welshman/app — The App Instance and its Plugins # welshman/app — Instance-Based Nostr App
`@welshman/app` composes `net`, `store`, `domain`, `signer`, and `feeds` into an application ## Overview
framework built around a single `App` object.
`@welshman/app` is the high-level app layer of welshman. It ties `util`, `net`, `store`, `domain`, `signer`, and `feeds` together behind a single **`App`** instance. Everything — the event repository, connection pool, the signed-in user, and all features — hangs off that instance. There are **no module-level globals**: you create an app and reach everything through `app.use(...)`.
## Installation ## Installation
```bash ```bash
npm i @welshman/app npm install @welshman/app
# or
pnpm add @welshman/app
yarn add @welshman/app
``` ```
## The App Peer deps: `svelte` (4 or 5), all `@welshman/*` workspace packages, and `@pomade/core`.
## Core mental model
1. **An app is an `App` instance.** It owns per-identity state (`repository`, `pool`, `tracker`, `wrapManager`), a `config`, and at most one `User`. Two apps never share data.
2. **Features are plugins**, resolved lazily and memoized via `app.use(SomeClass)`. Each plugin is constructed with the app and cached per app.
3. **`Projection<T>` is the universal accessor.** It has `.get()` (sync snapshot) and `.$` (Svelte `Readable`). Bind `.$` in components; call `.get()` in callbacks/hot paths.
4. **Reads are reactive and lazy-loading.** `app.use(Profiles).one(pubkey)` returns a store that fetches over the network (outbox model) and updates as events arrive.
5. **Writes are optimistic.** Publishing goes through *thunks*: the event hits the local repository immediately, signs lazily, and reports per-relay progress, with an abortable delay for soft-undo.
## Creating an app
```typescript ```typescript
import {App, createApp, User} from "@welshman/app" import {createApp} from "@welshman/app"
// Batteries-included: installs default policies (event ingestion, relay stats,
// gift-wrap unwrapping, NIP-42 auth-unless-blocked).
const app = createApp({ const app = createApp({
user: await User.fromSigner(signer), // omit for a signed-out app user, // optional User
config: { config: {
dufflepudUrl: "https://dufflepud.example.com", dufflepudUrl: "https://dufflepud.example", // optional: batches NIP-05/zapper lookups
getDefaultRelays: () => ["wss://relay.example.com"], getDefaultRelays: () => [...],
getIndexerRelays: () => ["wss://indexer.example.com"], getIndexerRelays: () => [...], // discovery relays for profiles/relay lists
getSearchRelays: () => ["wss://search.example.com"], getSearchRelays: () => [...], // NIP-50 search relays
}, },
getAdapter, // optional: custom net adapters (tests, mocks)
policies, // optional: overrides the defaults
}) })
// Bare app with NO side effects (tests, or custom policies):
import {App} from "@welshman/app"
const bare = new App()
// Always tear down when discarding an app (e.g. switching identities):
app.cleanup()
``` ```
An `App` owns everything scoped to one identity: `AppOptions` is `{user?, config?, getAdapter?, policies?}`, `AppConfig` is the `config` field above, and `AppPolicy` is `(app: IApp) => Unsubscriber`.
| Property | What it is | `IApp` (what plugins/policies depend on): `{user?, config, use, onCleanup, netContext, pool, tracker, repository, wrapManager}`. A plugin registers teardown with `app.onCleanup(unsubscriber)`; `app.cleanup()` runs them in reverse, then clears the pool, tracker, repository and wrap manager.
|---|---|
| `app.user` | the signed-in `User`, or `undefined` |
| `app.config` | the `AppConfig` above |
| `app.repository` | this identity's event store |
| `app.tracker` | which relays each event was seen on |
| `app.pool` | socket pool |
| `app.wrapManager` | NIP-59 gift wrap bookkeeping |
| `app.netContext` | `{pool, repository, getAdapter}` for the net layer |
| `app.use(Plugin)` | resolve a per-app plugin singleton |
| `app.cleanup()` | run policy teardown and clear pool/tracker/repository |
`createApp` is `new App` plus `defaultAppPolicies`. Use `new App({policies: [...]})` for a bare app. ## User & sessions
**An app is scoped to one identity.** To log in, build a *new* app and `cleanup()` the old one — A `User` is `{pubkey, signer}`. A `Session` is a serializable `{method, data}` descriptor you persist; session handlers turn it back into a signer.
never attach a user to an existing app. That's what keeps one account's data out of another's
repository.
## Plugins
`app.use(Ctor)` constructs the plugin on first use and memoizes it per app, so calling it inline
is cheap and idiomatic:
```typescript ```typescript
app.use(Profiles).load(pubkey) import {createApp, User, toSession, nip07} from "@welshman/app"
app.use(RelayLists).writeUrls(pubkey).get() import {getNip07} from "@welshman/signer"
// Build a User from a live signer...
const user = await User.fromSigner(getNip07())
// ...or from a persisted session
const session = toSession(nip07, {}) // serializable, store this
localStorage.setItem("session", JSON.stringify(session))
const restored = await User.fromSession(JSON.parse(localStorage.getItem("session")!)) // User | undefined
const app = createApp({user: restored})
// Gate user-only actions (throws if no user):
const u = User.require(app)
await u.sign(stampedEvent)
await u.nip44EncryptToSelf(payload) // encrypt to self (private list entries)
``` ```
### Plugin base classes Built-in session handlers (auto-registered): `nip01` `{secret}`, `nip07` `{}`, `nip46` `{clientSecret, signerPubkey, relays}`, `nip55` `{pubkey, signer}`, `pomade` `{clientOptions, email}`. Register custom ones with `defineSessionHandler` + `registerSessionHandler`.
| Base | Shape | `nip55` additionally needs the Capacitor plugin passed to `@welshman/signer` once at startup, or building its signer throws `"Nip55 is not enabled"`:
|---|---|
| `MapPlugin<T>` | a plain keyed map of non-event data (relay stats, NIP-11 info) |
| `LoadableMapPlugin<T>` | a `MapPlugin` that knows how to `fetch(key)` from the network |
| `DerivedPlugin<T>` | a keyed collection **derived from the repository** — the repository is the source of truth, never a duplicated map |
| `RelayScopedDerivedPlugin<T>` | keyed by `getKey(item, url)` per relay, for data that only means something relative to a relay |
| `RelaySignedDerivedPlugin<T>` | the same, but only accepts events authored by the relay's NIP-11 `self` pubkey (NIP-29 room state, relay membership/roles) |
Derived plugins expose: ```ts
import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin"
import {setNip55Plugin} from "@welshman/signer"
- `index` — `Projection<ItemsByKey<T>>` setNip55Plugin(NostrSignerPlugin)
- `all` — `Projection<T[]>` ```
- `one(key)` — a store for a single key, loading it on first subscribe
- `get(key)` — synchronous snapshot
- `load(key)` / `forceLoad(key)` — network fetch (cached / uncached)
- `project(key, read)` — a `Projection` derived from one key
A **`Projection<T>` is `{get(): T, $: Readable<T>}`** — bind `.$` in markup, call `.get()` in ## Data plugins (reactive collections)
callbacks and hot paths. Build new ones with `projection(store)` or `projectFrom(source, read)`.
### Available plugins All follow the same shape — `get(key)` (sync), `one(key)` (reactive, lazy-loads), `load(key)`/`forceLoad(key)` (promises), plus convenience accessors returning `Projection`. Resolve with `app.use(...)`.
**Core:** `Network`, `Router`, `Domain`, `Thunks`, `Sync`, `Logger`, `Plaintext` Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) is `async` and returns a **`Command`**, not a `Thunk` — it builds the event but does not publish it. Call `.publish()` on the result to actually send it. See [Commands](#commands-deferred-publishing) below.
**Relays:** `Relays` (NIP-11), `RelayStats`, `RelayManagement` (NIP-86), `RelayLists`, | Plugin | Data | Notable accessors |
`BlockedRelayLists`, `SearchRelayLists`, `MessagingRelayLists`, `BlossomServerLists` |---|---|---|
| `Profiles` | kind-0 profiles | `display(pk)`, `update(fn)` → `Command`; `profileSearch` |
**People:** `Profiles`, `FollowLists`, `MuteLists`, `Handles`, `Zappers`, `Wot`, `Topics` | `FollowLists` | kind-3 follows | `follow(pk, hint?, petname?)`, `unfollow(pk)`, `update(fn)` → `Command` |
| `MuteLists` | kind-10000 mutes (private = encrypted) | `mutePublicly(tag)`, `mutePrivately(tag)`, `unmute(v)`, `setMutes(...)` → `Command` |
**Content:** `Reactions`, `Deletes`, `Pins`, `Pinboards`, `Feeds`, `FeedLists`, `Wraps` | `PinLists` | kind-10001 pins | `pin(tag)`, `unpin(value)` → `Command` |
| `RelayLists` | NIP-65 (kind 10002) | `urls(pk)`, `readUrls(pk)`, `writeUrls(pk)`, `addReadUrl`/`addWriteUrl`, `removeReadUrl`/`removeWriteUrl`, `setReadUrls`/`setWriteUrls` → `Command` |
**NIP-29 / membership:** `Rooms`, `RoomLists`, `RoomPinLists`, `RelayMemberLists`, `RelayRoles` | `BlockedRelayLists` | kind-10006 | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` |
| `MessagingRelayLists` | kind-10050 (NIP-17 DM relays) | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` |
## Sessions and login | `SearchRelayLists` | kind-10007 | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` |
| `BlossomServerLists` | kind-10063 media servers | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` |
A `Session` is `{method, ...data}`, serializable so you can persist it. Handlers convert one into | `FeedLists` | kind-10014 saved-feed lists | list accessors + `update(fn)` → `Command` |
a signer: `nip01`, `nip07`, `nip46`, `nip55`, `pomade`, plus `registerSessionHandler` for your own. | `RoomLists` | kind-10009 room lists | `addRoom`/`removeRoom`/`addRelay`/`removeRelay`/`setRelays` → `Command` |
| `Feeds` | kind-31890 saved feeds (keyed by address) | `forAuthor(pk)`, `loadForAuthor(pk)`, `create(fields)`, `update(addr, fn)` → `Command`; `makeFeedController(...)` |
| `Pinboards` | kind-30067 pinboards (many per author, keyed by address) | `forAuthor(pk)`, `loadForAuthor(pk)`, `create(fields)`, `update(addr, fn)` → `Command` |
| `Pins` | kind-39067 pins (keyed by address; each pin has its own `d` tag) | `forBoard(addr)`, `forProfile(pk)`, `loadForBoard(addr)`, `loadForProfile(pk)`, `create`, `update`, `addToBoard`, `removeFromBoard` → `Command` |
| `Relays` | NIP-11 relay info (HTTP) | `display(url)`, `hasNip(url, n)`, `hasNegentropy(url)`; `relaySearch` |
| `RelayManagement` | NIP-86 mgmt API | `forUrl(url)` → a `ManagementApi` client that signs auth as the app's user (role/member ops, ban/allow, …) |
| `RelayStats` | per-relay connection counters | `get(url)`, `getQuality(url)` (0–1, drives router ranking) |
| `RelayRoles` / `RelayMemberLists` / `RoomPinLists` | relay-signed state, keyed per relay | relay-scoped collections (see `RelaySignedDerivedPlugin`) |
| `Handles` | NIP-05 (HTTP, batched) | `forPubkey(pk)`, `display(nip05)`, `loadForPubkey(pk)` |
| `Zappers` | LNURL zapper info (HTTP) | `forPubkey(pk)`, `validateZapReceipt(...)`, `validateZapReceipts(...)`, `validZapReceipts(...)` |
| `Topics` | hashtags w/ counts | `all`, `byName` (`Projection`s); `topicSearch` |
| `Reactions` / `Deletes` | kind-7 reactions and kind-5 deletes over the repository | reactive lookups |
| `Rooms` | NIP-29 rooms, keyed `${url}'${h}` | `forRoom(url, h)`, `forUrl(url)`, `members(url, h)`, `membershipStatus(...)`, `pendingJoins(url, h?)`, `createRoom`/`editRoom`/`deleteRoom`/`joinRoom`/`leaveRoom`/`addMember`/`removeMember(url, room, …)` → `Command` |
| `Plaintext` | decrypted-content cache, keyed by ciphertext | `ensure(ciphertext, decrypt)`, `get(ciphertext)` |
```typescript ```typescript
import {User, createApp, nip07, toSession} from "@welshman/app" import {createApp, Profiles, RelayLists} from "@welshman/app"
const session = toSession(nip07, {pubkey})
const user = await User.fromSession(session) // undefined if the handler can't build a signer
const app = createApp({user}) const app = createApp({user})
// Reactive (Svelte): subscribe or use $ in a component
const profile$ = app.use(Profiles).one(pubkey) // Readable<Maybe<Profile>>, lazy-loads
const name$ = app.use(Profiles).display(pubkey).$ // Readable<string>
// Synchronous snapshot (no load)
const profileNow = app.use(Profiles).get(pubkey)
// Explicit load
await app.use(Profiles).load(pubkey)
// Relay selections (outbox model)
const writeRelays = app.use(RelayLists).writeUrls(pubkey).get() // string[]
// Mutations return a Command — build it, then decide how to publish it
const command = await app.use(RelayLists).addWriteUrl("wss://relay.example")
command.publish() // normal outbox/relays flow via Thunks
// or: command.publishToRelays(["wss://relay.example"]) // send straight to one relay
// Since these methods are async, `publish`/`publishToRelays` free functions avoid a double-await:
import {publish} from "@welshman/app"
await app.use(RelayLists).addWriteUrl("wss://relay.example").then(publish)
``` ```
`User` wraps a signer and pubkey: ## Publishing (optimistic thunks)
- `User.fromSigner(signer)` / `User.fromSession(session)`
- `User.require(app)` — the signed-in user or **throws**; use on paths that require login
- `user.sign(event)`, `user.wrapSigner(fn)`
Persist the `Session`, not the `User` — rebuild the user on startup and construct the app with it.
## Publishing
Two layers, and you usually want the first.
### Commands
A `Command` owns a rendered event plus the relays routing resolved for it:
```typescript ```typescript
import {Domain, publish} from "@welshman/app" import {Thunks, Router} from "@welshman/app"
import {Note} from "@welshman/domain" import {makeEvent, NOTE, userOutbox} from "@welshman/util"
const writer = app.use(Domain).writer(Note).setContent("hello") // There's no dedicated outbox helper on Thunks — resolve write relays yourself via the
const command = await app.use(Domain).command(writer) // Router's Resolver + the RelaySelection DSL (this is what Command.publish() does under the
// hood for every data-plugin mutation, whose `relays` come from the writer's own routes):
command.publish() // to the resolved relays const thunk = app.use(Thunks).publish({
command.publishToRelays(urls) // to specific relays event: makeEvent(NOTE, {content: "hi"}),
command.publishAsRelay(url) // signed by the relay itself (NIP-86) relays: await app.use(Router).resolver.relays([userOutbox()]), // Promise<string[]>
``` delay: 3000, // abortable soft-undo window (ms)
Plugin mutators already return a `Command`, so `.then(publish)` is the common shape:
```typescript
await app.use(FollowLists).follow(["p", pubkey]).then(publish)
await app.use(RelayLists).addWriteUrl(url).then(publish)
```
Free-function forms exist for pipelines: `publish`, `publishToRelays(urls)`,
`publishAsRelay(url)`, `signAsRelay(url)`.
### Thunks
`app.use(Thunks).publish({event, relays, delay})` publishes optimistically: the event lands in the
local repository immediately, so the UI updates before the network settles. The returned `Thunk`
is a store you can render:
```typescript
const thunk = app.use(Thunks).publish({event, relays})
thunk.getUrlsWithStatus(PublishStatus.Success)
thunk.getFailedUrls()
thunk.isComplete()
await thunk.waitForError() // "" when everything succeeded
await thunk.waitForCompletion()
```
`app.use(Thunks).history` is a writable of every thunk this app has published — useful for a
"sending" indicator or deciding which relays the user has actually written to.
## Requests
```typescript
const network = app.use(Network)
network.load({relays, filters}) // batched, deduped, shared loader
network.request({relays, filters, onEvent})
network.publish({event, relays})
network.loadUsingOutbox(pubkey, filter) // newest matching event from the author's write relays
network.loadAllUsingOutbox(pubkey, filter) // every matching event
```
Prefer a plugin's `one(key)` / `load(key)` when one exists — they handle outbox routing and
caching for you. The bare `load`/`request`/`publish` from `@welshman/net` need an explicit
`context`; `Network` supplies `app.netContext`.
## Relay selection
Routing is the `RelaySelection` DSL from `@welshman/util`, resolved by `app.use(Router)`:
```typescript
import {outbox, inbox, seen, userOutbox, indexers, relay, relays} from "@welshman/util"
const scenario = await app.use(Router).resolve([userOutbox(), outbox(pubkey)])
const urls = scenario.getUrls()
// single best relay for a route
const hint = await app.use(Router).resolver.relay([outbox(event.pubkey)])
```
Selections are weighted (`outbox(pubkey, 2)`), and resolution is **async** — it may need to load
the target's relay list first.
## App policies
An `AppPolicy` is `(app) => Unsubscriber`, applied once at construction and torn down by
`cleanup()`. Policies own everything that subscribes or wires components together, keeping the
data classes free of side effects.
Built-ins: `appPolicyIngest`, `appPolicyRelayStats`, `appPolicyWraps`, `appPolicyCacheDecrypt`,
`appPolicyLogSignerMethods`, plus auth: `appPolicyAuthNever`, `appPolicyAuthAlways`,
`appPolicyAuthUnlessBlocked`, and `makeAppPolicyAuth(shouldAuth)` for a custom predicate.
```typescript
const app = createApp({
user,
policies: [...defaultAppPolicies, appPolicyAuthUnlessBlocked, myPolicy],
}) })
const myPolicy: AppPolicy = app => { // To specific relays:
const unsubscribe = on(app.repository, "update", handleUpdate) app.use(Thunks).publish({event, relays: ["wss://relay.example"]})
return unsubscribe // A thunk is a Svelte store with per-relay status:
} thunk.subscribe(t => console.log(t.results))
thunk.abort() // effective only before `delay` elapses
await thunk.waitForCompletion()
thunk.getError() // string | undefined
app.use(Thunks).history // writable<Thunk[]> — optimistic log
app.use(Thunks).retry(thunk)
// Gift-wrapped (NIP-59): single recipient via `recipient`, or many via Wraps:
app.use(Thunks).publish({event, relays, recipient: theirPubkey})
const merged = await app.use(Wraps).publish({event: rumor, recipients: [a, b]})
// Proof of work (NIP-13):
app.use(Thunks).publish({event, relays, pow: 20})
``` ```
**Ordering gotcha:** policies run in the `App` constructor. If a policy module imports something `ThunkOptions`: `{event, relays?, recipient?, delay?, pow?, ...PublishOptions}` (`app` is injected). Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`.
that transitively imports your app module, construct the app lazily (on first access) so every
policy has registered by the time it's built. ## Commands (deferred publishing)
Data-plugin mutation methods (`create`, `update`, `follow`, `addRelay`, `setRelays`, `Rooms.*`, …) don't publish — they build the `EventTemplate` and the relays it would go to, and hand back a **`Command`** for you to decide what to do with:
```typescript
import type {Command} from "@welshman/app"
const command: Command = await app.use(FollowLists).follow(["p", otherPubkey])
command.app // the IApp it was built for
command.event // EventTemplate — unsigned, inspectable before publishing
command.relays // string[] — where publish() will send it
command.publish() // normal path: app.use(Thunks).publish({event, relays: command.relays})
command.publishToRelays(urls) // publish to a specific relay set instead of command.relays
```
This lets a caller preview/log a command, choose a different transport, or drop it entirely, instead of every plugin method publishing unconditionally. `Wraps.publish` is the one exception — it fans a single rumor out to a `MergedThunk` of per-recipient wraps (each with its own relays), which doesn't fit the one-event/one-relay-set `Command` shape, so it still publishes directly.
`publish`/`publishToRelays` are also exported as free functions (e.g. `(command) => command.publish()`, `(urls) => (command) => command.publishToRelays(urls)`) so you can chain straight off the mutation method's promise instead of double-awaiting:
```typescript
import {publish, publishToRelays} from "@welshman/app"
await app.use(FollowLists).follow(["p", otherPubkey]).then(publish)
await app.use(Rooms).leave(relayUrl, roomMeta).then(publish)
await app.use(Rooms).join(relayUrl, roomMeta).then(publishToRelays([relayUrl]))
```
## Requests & sync
```typescript
import {Network, Sync} from "@welshman/app"
const net = app.use(Network)
const events = await net.load({filters: [{kinds: [1], authors: [pk]}], relays})
await net.request({filters, relays, autoClose: true})
// Outbox-model author load (resolves the author's write relays automatically).
// loadUsingOutbox returns the newest matching event; loadAllUsingOutbox returns them all.
const profileEvent = await net.loadUsingOutbox(pk, {kinds: [0]})
const allFeeds = await net.loadAllUsingOutbox(pk, {kinds: [31890]})
// A loader with different batching, still bound to this app's net context:
const slowLoad = net.makeLoader({delay: 500, timeout: 5000, threshold: 0.5})
// Negentropy-aware reconciliation (falls back to request/publish when unsupported):
await app.use(Sync).pull({relays, filters: [{authors: [pk]}]})
await app.use(Sync).push({relays, filters: [{authors: [pk]}]})
```
## Querying the repository (`Events`)
`Network` fetches; `Events` reads what's already local. Every method binds this app's repository
and tracker and returns a `Projection` — `.get()` for a snapshot, `.$` to subscribe — so there's no
get/derive pair to keep in sync.
```typescript
import {Events} from "@welshman/app"
const events = app.use(Events)
events.byId(filters).$ // Map<id, TrustedEvent>
events.all(filters).$ // repository order
events.asc(filters).$ // oldest first
events.desc(filters).$ // newest first
events.one(idOrAddress, hints) // one event, loaded on first read if missing
events.isDeleted(event).$
// Scoped to a relay, via the tracker
events.byIdForUrl(url, filters).$
events.forUrl(url, filters).$
events.byIdByUrl(filters).$ // Map<url, Map<id, TrustedEvent>>
events.relaySignedForUrl(url, filters).$ // only what the relay itself signed
```
`relaySignedForUrl` is the loose counterpart to `RelaySignedDerivedPlugin` — relay-generated kinds
mean nothing from another author, so anything not signed by the relay's NIP-11 `self` is dropped.
## Routing & tags
`app.use(Router)` turns the declarative **`RelaySelection`** DSL (from `@welshman/util`) into scored relay urls. It exposes a `Resolver` (`router.resolver`) plus a `resolve(selections)` shortcut. That same `resolver` is injected into every `@welshman/domain` kind by `app.use(Domain)`, so writers/readers route through it too.
```typescript
import {Router} from "@welshman/app"
import {userOutbox, outbox, seen, relay, addMinimalFallbacks} from "@welshman/util"
const router = app.use(Router) // per-app; NOT Router.get()
// resolver.relays(...) -> Promise<string[]>; resolver.relay(...) -> Promise<string | undefined>
const writeRelays = await router.resolver.relays([userOutbox()])
const hint = await router.resolver.relay([seen({id: event.id})])
// resolve(...) -> Promise<RelayScenario>; then tune fallbacks/limit and read urls
const relays = (await router.resolve([userOutbox()])).policy(addMinimalFallbacks).limit(8).getUrls()
// DSL selectors: userInbox/userOutbox/userMessaging, inbox(pk)/outbox(pk)/messaging(pk),
// inboxes(pks), eventInbox(ref)/eventOutbox(ref), seen(ref), relay(url)/relays(urls),
// indexers(), searchRelays() — each returns a RelaySelection (relays/inboxes return arrays).
```
Event tagging (reply/quote/reaction threading, p-tags, zap splits) now lives on the domain **writers** — `writer.tagPubkey(pk)`, `writer.addQuote(event)`, `writer.addZapSplit(pk)`, and kind-specific setters like `NoteWriter.setParent(parentEvent)` — not on a separate `Tags` plugin. See the `welshman-domain` skill.
`Router` is the one `ResolveRoute` implementation in the stack. It resolves each route against the app:
- **inbox / outbox** — `app.use(RelayLists).load(pubkey)` then `readUrls()` / `writeUrls()` (NIP-65, kind 10002).
- **messaging** — `app.use(MessagingRelayLists).load(pubkey)` (kind 10050).
- **eventInbox / eventOutbox** — a known `ref.pubkey` routes directly; otherwise `ref.id` is looked up in the repository to find the author. `ref.relays` are always included.
- **seen** — `app.tracker.getRelays(ref.id)`, or for a replaceable `ref` the tracker entry of the event at its address, plus `ref.relays`.
- **index / search** — `app.config.getIndexerRelays?.()` / `getSearchRelays?.()`.
When `app.user` is undefined, `user*` routes resolve to no relays rather than throwing.
`Router` also satisfies `@welshman/feeds`' `FeedRouter` interface, which is how `app.use(Feeds).makeFeedController(...)` routes a feed's filters.
### Relay quality
The resolver ranks relays by `app.use(RelayStats).getQuality(url)`, 0–1:
| Score | Condition |
|---|---|
| `0` | not a relay url, blocked by the user's kind-10006 list, or recently error-prone (any error in the last minute, >3 in an hour, >10 in a day) |
| `1` | already in the pool |
| `0.9` | connected at some point before |
| `0.8` | a normal `wss://` url with no history |
| `0.7` | an IP, local, onion, or plain-`ws://` url with no history |
A relay scoring `0` is dropped from the scenario's result entirely rather than deprioritized, so a scenario can come back empty even though its selections resolved to urls.
The DSL constructors, `RelayScenario` scoring and the fallback policies are documented in the `welshman-util` skill.
## Web of trust ## Web of trust
Built from the **public** `p` tags on follow (kind 3) and mute (kind 10000) lists as they land in the repository. Every read is a `Projection` (`.get()` / `.$`), and reads *about* a pubkey take a `WotScope`:
- `WotScope.Global` — counts every list in the repository.
- `WotScope.Follows` — counts only lists published by the user's own follows, i.e. the pubkey as this user sees it. With no signed-in user it falls back to global.
```typescript ```typescript
import {Wot, WotScope} from "@welshman/app"
const wot = app.use(Wot) const wot = app.use(Wot)
wot.follows(pubkey).get() wot.follows(pk).get() // string[] — who pk follows
wot.followers(pubkey).get() wot.mutes(pk).get() // string[] — who pk mutes
wot.network(pubkey).$ // follows-of-follows wot.followers(pk, WotScope.Follows).get() // string[]
wot.followsWhoFollow(pubkey, target).$ wot.muters(pk, WotScope.Follows).get() // string[]
wot.wotScore(pubkey, target).$ wot.score(pk, WotScope.Follows).get() // number — followers − muters, within scope
wot.network(pk).get() // follows-of-follows (minus direct follows)
wot.scores(WotScope.Follows).get() // Map<pubkey, score> — the whole picture at once
``` ```
## Feeds and sync Use `scores(scope)` when ranking a list (search results, a WoT range); it walks the graph once instead of once per pubkey.
## Feeds & search
```typescript ```typescript
app.use(Feeds).makeFeedController({feed, onEvent, ...}) import {makeIntersectionFeed, makeScopeFeed, makeKindFeed, Scope} from "@welshman/feeds"
app.use(Feeds).getPubkeysForScope(scope) import {get} from "svelte/store"
app.use(Feeds).forAuthor(pubkey).$
app.use(Sync).pull({relays, filters}) // negentropy: fetch what we're missing const controller = app.use(Feeds).makeFeedController({
app.use(Sync).push({relays, filters}) // publish what the relay is missing feed: makeIntersectionFeed(makeScopeFeed(Scope.Follows), makeKindFeed(1)),
onEvent: event => {/* render */},
})
await controller.load(50) // scopes (Self/Follows/Network/Followers) resolved via Wot
// Search lives on the collection that owns the data. There is no Searches plugin.
const search = get(app.use(Profiles).profileSearch)
const pubkeys = search.searchValues("alice") // also fires a NIP-50 network search; ranked by WoT
// also: app.use(Topics).topicSearch, app.use(Relays).relaySearch
// createSearch(options, {...}) builds a custom index over anything else
``` ```
## Using welshman stores outside Svelte ## Plugin architecture (for extending)
Projections and plugin stores implement the Svelte store contract — `subscribe(cb) → unsubscribe`, Base classes in `plugins/base.ts`:
firing synchronously with the current value — so they adapt to any reactive framework with a small
hook. Only the `svelte/store` *types* are needed, not the runtime. - **`DerivedPlugin<T>`** — collection derived from repository events (the repo is the single source of truth). Pass `{filters, eventToItem, getKey, loadOptions?}`; implement `fetch`. This is the dominant pattern. Gives you `index`/`all` (`Projection`s), `get(key)`, `one(key)`, `load`/`forceLoad`, and `project(key, read)`.
- **`RelayScopedDerivedPlugin<T>`** — the same, keyed per relay via the tracker (`getKey(item, url)`), so the same addressable coordinate on two relays stays two entries. `RelaySignedDerivedPlugin` (in `plugins/relays.ts`) narrows it further to events signed by the relay's own NIP-11 `self` key, which is what `RelayRoles`, `RelayMemberLists` and `RoomPinLists` use.
- **`LoadableMapPlugin<T>`** — owns its own `Map`, lazily fetches over HTTP (e.g. `Relays`, `Handles`, `Zappers`). Implement `fetch`.
- **`MapPlugin<T>`** — owns its own `Map`, no network (e.g. `RelayStats`, `Plaintext`).
Decode events with the app-configured `@welshman/domain` reader (`app.use(Domain).reader(Kind)`) as `eventToItem`, and mutate through `app.use(Domain).writer(Kind, reader?)` + `app.use(Domain).command(writer)`:
```typescript ```typescript
// React import {DerivedPlugin, Network, Domain, User, type IApp} from "@welshman/app"
const useStore = <T>(store: Readable<T>): T => { import {SOME_KIND} from "@welshman/util"
const [value, setValue] = useState<T>(() => get(store)) import {SomeKind, SomeKindReader, SomeKindWriter} from "@welshman/domain"
useEffect(() => store.subscribe(setValue), [store]) export class Somethings extends DerivedPlugin<SomeKindReader> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [SOME_KIND]}],
eventToItem: app.use(Domain).reader(SomeKind), // async: validates kind + parses
getKey: item => item.author(),
})
}
return value fetch = (pk: string, hints: string[] = []) =>
this.app.use(Network).loadUsingOutbox(pk, {kinds: [SOME_KIND]}, hints)
// Build a writer (optionally seeded from the current reader for edits), mutate it, then
// wrap it in a Command via Domain.command — the caller decides when/how to publish.
update = async (fn: (writer: SomeKindWriter) => void) => {
const user = User.require(this.app)
const writer = this.app.use(Domain).writer(SomeKind, await this.forceLoad(user.pubkey))
fn(writer)
return this.app.use(Domain).command(writer)
}
} }
const things = app.use(Somethings) // lazily constructed + memoized
``` ```
For a `Projection`, subscribe to `.$` and read `.get()` for a synchronous snapshot. Caching/backoff for `load` come from `makeLoadItem` (`@welshman/store`); default staleness window is 1 hour; `forceLoad` bypasses it.
## Policies & logging
Side effects live in `AppPolicy`s (`(app) => Unsubscriber`), run at construction, cleaned up by `cleanup()`.
- `defaultAppPolicies` = `[appPolicyIngest, appPolicyRelayStats, appPolicyWraps, appPolicyCacheDecrypt, appPolicyLogSignerMethods, appPolicyAuthUnlessBlocked]`.
- Auth builders: `makeAppPolicyAuth(shouldAuth)`, `appPolicyAuthAlways`, `appPolicyAuthNever`, `appPolicyAuthUnlessBlocked`.
- `appPolicyCacheDecrypt` and `appPolicyLogSignerMethods` both layer onto the user's signer via `User.wrapSigner` — the first caches decryptions into `app.use(Plaintext)`, the second records signer calls into `app.use(Logger)` (read them from `app.use(Logger).messages`).
```typescript
// Opt out of a default, or add your own:
import {App, defaultAppPolicies, appPolicyAuthNever, appPolicyIngest} from "@welshman/app"
const app = new App({user, policies: [appPolicyIngest, appPolicyAuthNever]})
```
## Gotchas & tips
- **`use()` is memoized per app.** `app.use(Profiles)` always returns the same instance for a given app. Cheap to call repeatedly.
- **`Projection` vs `Readable`.** Convenience accessors (`display`, `urls`, `score`, …) return a `Projection` — use `.$` for the store, `.get()` for a snapshot. `one(key)` returns a plain `Readable` (and triggers a load on subscribe).
- **`get(key)` does not load; `one(key)`/`load(key)` do.** Use `get` for a pure cache read.
- **Most loads use the outbox model**, which needs the author's relay list. `loadUsingOutbox` (and therefore most `fetch` methods) first loads NIP-65 relays for the author.
- **`createApp` vs `new App`.** `createApp` installs default policies; `new App` installs none. In tests prefer `new App` (no background subscriptions) unless you need ingestion.
- **Pass the `user` to `createApp`/`new App`, don't assign `app.user` afterwards.** Policies run once, at construction. `appPolicyCacheDecrypt` and `appPolicyLogSignerMethods` bail out immediately when there is no user, so a user attached later gets no decrypt caching and no signer log. To switch identities, build a new app and `cleanup()` the old one.
- **Call `cleanup()`** when discarding an app to close sockets and free the repository/tracker/wrap state.
## Old API → new API
| Old (global) | New (instance-based) |
|---|---|
| `addSession(...)` / `pubkey.get()` | `User.fromSession(...)` + `createApp({user})`; `app.user?.pubkey` |
| `deriveProfile(pk)` | `app.use(Profiles).one(pk)` |
| `deriveProfileDisplay(pk)` | `app.use(Profiles).display(pk).$` |
| `publishThunk({...})` | `app.use(Thunks).publish({...})` (resolve outbox relays via `await app.use(Router).resolver.relays([userOutbox()])`) |
| `follow(tag)` / `mute(tag)` | `app.use(FollowLists).follow(tag).then(publish)` / `app.use(MuteLists).mutePublicly(tag).then(publish)`, which return a [`Command`](#commands-deferred-publishing) |
| `load({...})` / `request({...})` | `app.use(Network).load({...})` / `request({...})` |
| `Router.get().FromUser()` / `router.Event(e)` | `app.use(Router).resolver` + the `RelaySelection` DSL (`resolver.relays([userOutbox()])`, `resolver.relay([seen(e)])`) |
| `app.use(Tags).tagEventForReply(e)` | domain writer tagging (`NoteWriter.setParent(e)`, `writer.tagPubkey/addQuote/addZapSplit`) |
| `relays` / `handles` / `zappers` stores | `app.use(Relays)` / `Handles` / `Zappers` |
| `app.use(Searches).profileSearch` | `app.use(Profiles).profileSearch` (likewise `Topics.topicSearch`, `Relays.relaySearch`) |
| `wot.graph` / `wot.wotScore(a, b)` | `app.use(Wot).scores(WotScope.Follows)` / `.score(pk, scope)` |
| `RelayLists.addRelay(url, mode)` | `RelayLists.addReadUrl(url)` / `addWriteUrl(url)` |
## Related skills ## Related skills
- `welshman-domain` — the readers/writers every plugin decodes events with - `welshman-store` — the `Repository` and Svelte-store primitives this layer builds on.
- `welshman-net` — sockets, adapters, request/publish lifecycle, auth - `welshman-domain` — the `Kind`/reader/writer model behind `app.use(Domain)` (event decoding + publishing).
- `welshman-store` — the repository and the derive helpers plugins are built on - `welshman-util` — the `RelaySelection` DSL, `Resolver` and `RelayScenario` that `app.use(Router)` dereferences.
- `welshman-signer` — signer implementations behind `User` - `welshman-net` — request/publish/sockets behind `app.use(Network)`.
- `welshman-util` — kinds, filters, tag specs, and the `RelaySelection` DSL - `welshman-signer` — signers and login methods used by `User`/sessions.
- `welshman-feeds` — feed construction used by `app.use(Feeds)`.

View file

@ -36,12 +36,14 @@ yarn add @welshman/content
| `Text` | `string` | Plain text | | `Text` | `string` | Plain text |
| `Newline` | `string` | One or more `\n` characters | | `Newline` | `string` | One or more `\n` characters |
| `Topic` | `string` | Hashtag text without the `#`; numeric-only tags are skipped | | `Topic` | `string` | Hashtag text without the `#`; numeric-only tags are skipped |
| `Command` | `{ command: string, pubkey?: string }` | A NIP-CD invocation (`/kick`, `/kick@npub1…`), only ever at the start of the content; `pubkey` is the executor the qualifier names |
| `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 | | `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 | | `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) | | `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 | | `Event` | `EventPointer` (`{ id, relays?, author?, kind? }`) | note / nevent references |
| `Address` | `AddressPointer` (`{ identifier, pubkey, kind, relays? }`) | naddr references | | `Address` | `AddressPointer` (`{ identifier, pubkey, kind, relays? }`) | naddr references |
| `Emoji` | `{ name: string, url?: string }` | `:shortcode:` — `url` resolved from `emoji` tags | | `Emoji` | `{ name: string, url?: string }` | `:shortcode:` — `url` resolved from `emoji` tags |
| `Room` | `ParsedRoomValue` (`{ url, room }`) | A NIP-29 room reference written as `relay.example.com'roomid` (an apostrophe or `’` between host and room id). The url is normalized to include a protocol and trailing slash; possessives like `example.com's` are skipped |
| `Code` | `string` | Backtick inline code or triple-backtick blocks | | `Code` | `string` | Backtick inline code or triple-backtick blocks |
| `Cashu` | `string` | cashu: token strings | | `Cashu` | `string` | cashu: token strings |
| `Invoice` | `string` | Bare lightning invoice string (without `lightning:` prefix); the `lightning:` prefix is in `raw` | | `Invoice` | `string` | Bare lightning invoice string (without `lightning:` prefix); the `lightning:` prefix is in `raw` |
@ -55,9 +57,10 @@ Every `Parsed` element also has a `raw: string` field holding the original match
All guards narrow the union type: All guards narrow the union type:
``` ```
isAddress isCashu isCode isEllipsis isEmail isAddress isCashu isCode isCommand isEllipsis
isEmoji isEvent isImage isInvoice isLink isEmail isEmoji isEvent isImage isInvoice
isLinkGrid isNewline isProfile isText isTopic isLink isLinkGrid isNewline isProfile isRoom
isText isTopic
``` ```
`isImage(parsed)` — special guard: true only for `ParsedLink` elements whose URL ends in `.jpg/.jpeg/.png/.gif/.webp`. `isImage(parsed)` — special guard: true only for `ParsedLink` elements whose URL ends in `.jpg/.jpeg/.png/.gif/.webp`.
@ -83,7 +86,11 @@ isLinkGrid isNewline isProfile isText isTopic
| `renderEntity(entity)` | `entity.slice(0, 16) + "…"` | Display text for entity links | | `renderEntity(entity)` | `entity.slice(0, 16) + "…"` | Display text for entity links |
| `createElement(tag)` | `document.createElement(tag)` | DOM element factory; override for SSR/non-browser | | `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`). Individual per-type render helpers are also exported (`renderText`, `renderLink`, `renderProfile`, `renderEvent`, `renderAddress`, `renderRoom`, `renderTopic`, `renderEmoji`, `renderCode`, `renderCommand`, `renderCashu`, `renderInvoice`, `renderEmail`, `renderNewline`, `renderEllipsis`, `renderOne`, `renderMany`), along with the default option bags `textRenderOptions` and `htmlRenderOptions`.
### Extending the parser
`parsers` is the ordered array of individual parsers (`parseCommand`, `parseNewline`, `parseLegacyMention`, `parseTopic`, `parseCodeBlock`, `parseCodeInline`, `parseAddress`, `parseProfile`, `parseEmoji`, `parseEvent`, `parseCashu`, `parseInvoice`, `parseEmail`, `parseRoom`, `parseLink`), and `parseNext(raw, context)` runs them in order at the current position. Each takes `(text, context: ParseContext)` and returns a `Parsed` or nothing. Order matters — `parseRoom` runs before `parseLink` so a `host'room` reference isn't swallowed as a bare-domain link.
## Common Patterns ## Common Patterns
@ -206,4 +213,7 @@ const emojiElements = parsed.filter(isEmoji)
- **`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). - **`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. - **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. - **Numeric hashtags are skipped**: `#42` will not produce a `Topic` element.
- **A `Command` is syntax, not a promise that anyone answers to it**: the parser has no idea which NIP-CD definitions exist, so `/nonsense` parses as a `Command` too. Match `value.command` (and `value.pubkey`, when the invocation names an executor) against the definitions you have, and render the element's `raw` as text when nothing matches.
- **Only the start of the content is an invocation**: a slash later in the text is punctuation or part of a path, so `look in /etc/passwd` produces no `Command`.
- **`ParsedRoom` is emitted by `renderOne` as plain text** (`r.addText(p.raw)`) rather than a link, because the renderer has no way to know a room's display name. Handle `isRoom` yourself when building a UI, the same as `isLinkGrid`.
- **Email matching** strips a leading `mailto:` — the resulting `ParsedEmail.value` is always the bare address string. - **Email matching** strips a leading `mailto:` — the resulting `ParsedEmail.value` is always the bare address string.

View file

@ -41,6 +41,7 @@ import "@welshman/editor/index.css"
| `BreakOrSubmit` | Keyboard handler: `Mod-Enter` always submits; `Enter` submits only when `aggressive: true` (chat-style); `Shift-Enter` inserts a hard break. | | `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. | | `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. | | `WordCount` | Extension that tracks `editor.storage.wordCount.words` and `editor.storage.wordCount.chars` on every document update. |
| `CommandExtension` | Inline atom node (`name: "command"`) holding a slash-command invocation — `{command, pubkey?}` attributes. `renderText` emits the canonical invocation via `renderCommandInvocation` from `@welshman/util`, so an executor that knows nothing about this client can read it back out of the note. |
### Node Views ### Node Views
@ -57,8 +58,9 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char`, `name`, `editor`, `search`, and `select`. Optional: `updateSignal`, `createSuggestion`. | | `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char`, `name`, `editor`, `search`, and `select`. |
| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`. Optional: `updateSignal`, `createSuggestion`. | | `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`. |
| `CommandSuggestion` | Pre-configured `TippySuggestion` for `/`-triggered slash commands. Requires `editor`, `search`, and `getAttributes(value) => CommandAttributes \| undefined`. Sets `showOnEmpty: true` (a bare `/` lists what's available) and `allow: ({range}) => range.from === 1` (an invocation is only valid at the very start of the content). |
| `DefaultSuggestionsWrapper` | Default dropdown renderer used by `TippySuggestion`. Implements `ISuggestionsWrapper`; replace to use a framework component. | | `DefaultSuggestionsWrapper` | Default dropdown renderer used by `TippySuggestion`. Implements `ISuggestionsWrapper`; replace to use a framework component. |
**`TippySuggestion` options:** **`TippySuggestion` options:**
@ -71,7 +73,11 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele
| `search` | yes | `(term: string) => string[]` — returns item values matching the query | | `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 | | `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) | | `updateSignal` | no | A Svelte `Readable` store; when it emits, the suggestion list re-renders (use for async/reactive search results) |
| `allowCreate` | no | Let the user commit the raw term as an item (default `false`) |
| `showOnEmpty` | no | Show the whole list before anything is typed (default `false`). Right for a small closed set the user browses; wrong for profiles, where a bare trigger matches everything |
| `allow` | no | `({state, range}) => boolean` — narrow where the suggestion can fire, on top of the schema check |
| `createSuggestion` | no | `(value: string) => Element` — renders a custom DOM element for each dropdown item | | `createSuggestion` | no | `(value: string) => Element` — renders a custom DOM element for each dropdown item |
| `createSuggestionsWrapper` | no | `(target, props) => ISuggestionsWrapper` — swap the dropdown for a framework component |
`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`. `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`.
@ -81,7 +87,6 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele
|--------|--------| |--------|--------|
| `Editor` | `@tiptap/core` — the editor instance class | | `Editor` | `@tiptap/core` — the editor instance class |
| `NodeViewProps` | `@tiptap/core` — prop type for node view factories (Tiptap's type) | | `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 | | `UploadTask` | `nostr-editor` — shape of an in-progress or completed file upload |
| `FileAttributes` | `nostr-editor` — `{ file: File, … }` passed to the `upload` callback | | `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 })` | | `editorProps` | `nostr-editor` — base ProseMirror `editorProps` used by nostr-editor; pass directly to `new Editor({ editorProps })` |
@ -138,7 +143,7 @@ import {get, writable} from "svelte/store"
import {Node, Extension, mergeAttributes} from "@tiptap/core" import {Node, Extension, mergeAttributes} from "@tiptap/core"
import {Plugin, PluginKey} from "@tiptap/pm/state" import {Plugin, PluginKey} from "@tiptap/pm/state"
import type {NodeViewRendererProps} from "@tiptap/core" import type {NodeViewRendererProps} from "@tiptap/core"
import {Profiles, Router, createSearch} from "@welshman/app" import {Profiles, Router} from "@welshman/app"
import {outbox} from "@welshman/util" import {outbox} from "@welshman/util"
import { import {
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps, Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
@ -187,11 +192,8 @@ export const makeEditor = ({
charCount?: ReturnType<typeof writable<number>> charCount?: ReturnType<typeof writable<number>>
submit: () => void submit: () => void
}) => { }) => {
const profileSearch = createSearch(get(profiles), { // The Profiles plugin maintains a ready-made fuzzy search over known profiles
onSearch: searchProfiles, const profileSearch = get(app.use(Profiles).profileSearch)
getValue: (p: any) => p.event.pubkey,
fuseOptions: {keys: ["nip05", "name", "display_name"], threshold: 0.3},
})
const editor = new Editor({ const editor = new Editor({
content, content,
@ -232,7 +234,7 @@ export const makeEditor = ({
addNodeView: () => ({node}: NodeViewRendererProps) => { addNodeView: () => ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span") const dom = document.createElement("span")
dom.classList.add("mention") dom.classList.add("mention")
const unsub = deriveProfileDisplay(node.attrs.pubkey) const unsub = app.use(Profiles).display(node.attrs.pubkey).$
.subscribe($d => { dom.textContent = "@" + $d }) .subscribe($d => { dom.textContent = "@" + $d })
return { return {
dom, destroy: unsub, dom, destroy: unsub,
@ -246,7 +248,8 @@ export const makeEditor = ({
MentionSuggestion({ MentionSuggestion({
editor: (this as any).editor, editor: (this as any).editor,
search: term => profileSearch.searchValues(term), search: term => profileSearch.searchValues(term),
getRelays: pubkey => Router.get().FromPubkeys([pubkey]).getUrls(), getRelays: async pubkey =>
(await app.use(Router).resolve([outbox(pubkey)])).getUrls(),
createSuggestion: pubkey => { createSuggestion: pubkey => {
const el = document.createElement("span") const el = document.createElement("span")
el.textContent = pubkey.slice(0, 12) + "…" el.textContent = pubkey.slice(0, 12) + "…"
@ -324,7 +327,7 @@ const onSubmit = (editor: Editor) => {
## Integration Notes ## Integration Notes
- **`@welshman/app`** — `app.use(Profiles).profileSearch` and `app.use(Profiles).display(pubkey)` are the typical sources for mention autocomplete data and display names. - **`@welshman/app`** — `app.use(Profiles).profileSearch` and `app.use(Profiles).display(pubkey)` are the typical sources for mention autocomplete data and display names.
- **`@welshman/app`** — relay hints for nprofile bech32 strings come from `app.use(Router).resolve([outbox(pubkey)])`. - **`@welshman/app`** — `app.use(Router).resolve([outbox(pubkey)])` provides the relay hints encoded into nprofile bech32 strings.
- **`@welshman/util`** — `fromNostrURI` is used internally by `EventNodeView` to strip the `nostr:` scheme before displaying. - **`@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. - **`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. - **`@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.

View file

@ -115,11 +115,12 @@ class FeedCompiler {
} }
type FeedCompilerOptions = { type FeedCompilerOptions = {
router: FeedRouter // REQUIRED — resolves relay selections
getPubkeysForScope: (scope: Scope) => string[] getPubkeysForScope: (scope: Scope) => string[]
getPubkeysForWOTRange: (min: number, max: number) => string[] getPubkeysForWOTRange: (min: number, max: number) => string[]
signer?: ISigner signer?: ISigner
signal?: AbortSignal signal?: AbortSignal
context?: AdapterContext context?: AdapterContext // net context: {pool, repository, getAdapter?}
} }
``` ```
@ -147,6 +148,32 @@ type FeedControllerOptions = FeedCompilerOptions & {
} }
``` ```
### Routing (`FeedRouter`)
`@welshman/feeds` has no way to turn a pubkey into relay urls, so it declares the capability as an interface and the caller supplies it.
```typescript
import type {RelaySelection, RelayScenario} from '@welshman/util'
export interface FeedRouter {
resolve(selections: RelaySelection[]): Promise<RelayScenario>
}
// Decide which relays serve which filters under the outbox model
getFilterSelections(filters: Filter[], router: FeedRouter): Promise<RelaysAndFilters[]>
// RelaysAndFilters = {relays: string[]; filters: Filter[]}
```
`getFilterSelections` applies one rule per outbox source: a `search` filter goes to `searchRelays(10)`; a gift-wrap filter with no `authors` goes to `userMessaging()`; a filter with `authors` is chunked and sent to each author's `outbox()`; and everything additionally gets a low-weight `userInbox(0.2)` pass. Each group resolves with `addMinimalFallbacks`.
`@welshman/app`'s `Router` plugin implements `FeedRouter`, and `app.use(Feeds).makeFeedController(...)` supplies it (along with `getPubkeysForScope`, `getPubkeysForWOTRange`, the signer, and the app's net context) so you only pass `feed` and your callbacks. The `RelaySelection` DSL itself is documented in the `welshman-util` skill.
### Display & validation helpers
`display*` functions render a feed definition as human-readable text — `displayFeed(feed)` dispatches on type, with `displayAuthorFeed`, `displayKindFeed`, `displayScopeFeed`, `displayTagFeed`, … underneath, plus `displayFeeds(feeds)` for a list.
`validate*` functions throw on a malformed feed tuple — `validateFeed(feed)` dispatches, with `validateAuthorFeed`, `validateDVMFeed`, `validateFeedArgs`, `validateTagFeedMapping`, … underneath. Run `validateFeed` on anything decoded from a kind-31890 event before compiling it.
## Common Patterns ## Common Patterns
### 1. Simple author + kind feed ### 1. Simple author + kind feed
@ -156,6 +183,7 @@ import { FeedController, makeIntersectionFeed, makeAuthorFeed, makeKindFeed } fr
import { Scope } from '@welshman/feeds' import { Scope } from '@welshman/feeds'
const controller = new FeedController({ const controller = new FeedController({
router, // a FeedRouter — e.g. app.use(Router)
feed: makeIntersectionFeed( feed: makeIntersectionFeed(
makeAuthorFeed("pubkey1", "pubkey2"), makeAuthorFeed("pubkey1", "pubkey2"),
makeKindFeed(1), makeKindFeed(1),
@ -178,6 +206,7 @@ import {
} from '@welshman/feeds' } from '@welshman/feeds'
const controller = new FeedController({ const controller = new FeedController({
router,
feed: makeIntersectionFeed( feed: makeIntersectionFeed(
makeScopeFeed(Scope.Follows), makeScopeFeed(Scope.Follows),
makeWOTFeed({ min: 0.1 }), makeWOTFeed({ min: 0.1 }),
@ -206,6 +235,7 @@ import {
// DVMItem.mappings controls how DVM result tags become sub-feeds // DVMItem.mappings controls how DVM result tags become sub-feeds
const controller = new FeedController({ const controller = new FeedController({
router,
feed: makeIntersectionFeed( feed: makeIntersectionFeed(
makeDVMFeed({ makeDVMFeed({
kind: 5300, kind: 5300,
@ -227,6 +257,7 @@ await controller.load(30)
import { FeedController, makeListFeed, makeKindFeed, makeUnionFeed, FeedType } from '@welshman/feeds' import { FeedController, makeListFeed, makeKindFeed, makeUnionFeed, FeedType } from '@welshman/feeds'
const controller = new FeedController({ const controller = new FeedController({
router,
feed: makeUnionFeed( feed: makeUnionFeed(
makeListFeed({ makeListFeed({
addresses: ["10003:pubkey:identifier"], addresses: ["10003:pubkey:identifier"],
@ -255,6 +286,7 @@ const filters = [
const feed = feedFromFilters(filters) const feed = feedFromFilters(filters)
const compiler = new FeedCompiler({ const compiler = new FeedCompiler({
router,
getPubkeysForScope: () => [], getPubkeysForScope: () => [],
getPubkeysForWOTRange: () => [], getPubkeysForWOTRange: () => [],
}) })
@ -285,11 +317,13 @@ console.log('Authors in feed:', [...authors])
- **`@welshman/util`** — `Filter`, `TrustedEvent`, and nostr primitives used throughout. `getIdFilters()` is used internally by the compiler for address feeds. - **`@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/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/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. - **`@welshman/app`** — `app.use(Feeds).makeFeedController({feed, onEvent, …})` supplies `router` (the `Router` plugin), `getPubkeysForScope`/`getPubkeysForWOTRange` (from `Wot`), the user's signer, and the app's `{pool, repository}` context. `Feeds` is also the kind-31890 saved-feed collection.
- **`Tracker`** — Optional deduplication helper (from `@welshman/net` or app layer). Pass a shared `Tracker` instance to avoid re-emitting events seen in other controllers. - **`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 ## Gotchas & Tips
- **`router` is required.** `FeedCompilerOptions.router` has no default; a `FeedController` or `FeedCompiler` constructed without one will fail when it tries to resolve relays. In an app, go through `app.use(Feeds).makeFeedController(...)`.
- **Always use factory functions** (`makeAuthorFeed`, etc.) rather than constructing raw tuples — the tuple structure is internal and type safety depends on using factories. - **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. - **`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. - **`FeedController.load()` is stateful** — each call continues from where the last left off (pagination). Create a new controller to reset.

View file

@ -23,7 +23,7 @@ pnpm add @welshman/lib
|--------|-------------| |--------|-------------|
| `Deferred<T, E>` | Type: a `Promise<T>` with `.resolve(T)` and `.reject(E)` methods attached | | `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()` | | `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 | | `makePromise<T, E>(executor)` | Creates a strongly-typed promise with typed error (`CustomPromise<T, E>`) |
`E` defaults to `T` when omitted. `defer<void>()` for a signal-style deferred. `thunk.complete` in `@welshman/app` is a `Deferred<void>`. `E` defaults to `T` when omitted. `defer<void>()` for a signal-style deferred. `thunk.complete` in `@welshman/app` is a `Deferred<void>`.
@ -54,8 +54,8 @@ bus.emit('login', { pubkey: '...' })
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `LRUCache<K, V>` | LRU cache; evicts least-recently-used entries when full | | `LRUCache<K, V>` | LRU cache; evicts least-recently-used entries when full |
| `simpleCache(getValue)` | Minimal memoization wrapper over an `LRUCache` with default settings |
| `cached(options)` | Memoizes a function with an LRU backing cache; exposes `.cache` and `.pop()` | | `cached(options)` | Memoizes a function with an LRU backing cache; exposes `.cache` and `.pop()` |
| `simpleCache(getValue)` | Minimal memoization wrapper with default settings |
```typescript ```typescript
import { LRUCache, cached } from '@welshman/lib' import { LRUCache, cached } from '@welshman/lib'
@ -125,6 +125,7 @@ displayDomain('relay.damus.io/path') // => 'relay.damus.io'
| `batch(t, fn)` | First call fires `fn([item])` immediately; subsequent calls within `t` ms are collected and `fn` is called with all accumulated items | | `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. | | `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 | | `race(threshold, promises)` | Resolves when `threshold` fraction of promises complete |
| `makeQueue()` | Returns `(f: () => Promise<unknown>) => Promise<unknown>` — chains every call onto one serial promise, logging and swallowing rejections so a failure doesn't stall the chain |
### Timestamp / Time Constants ### Timestamp / Time Constants
@ -160,6 +161,7 @@ displayDomain('relay.damus.io/path') // => 'relay.damus.io'
| `within([low, high], n)` | `n >= low && n <= high` (inclusive) | | `within([low, high], n)` | `n >= low && n <= high` (inclusive) |
| `clamp([min, max], n)` | Constrains `n` to the range | | `clamp([min, max], n)` | Constrains `n` to the range |
| `round(precision, x)` | Rounds to `precision` decimal places | | `round(precision, x)` | Rounds to `precision` decimal places |
| `toInt(x)` | `parseInt` that returns `undefined` instead of `NaN` — accepts `number \| string \| undefined` |
### Array / Sequence Utilities ### Array / Sequence Utilities
@ -250,6 +252,8 @@ type MaybeStr = Maybe<string> // string | undefined
| `ifLet(x, f)` | Calls `f(x)` only if `x` is defined | | `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 | | `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) | | `isDefined(x)` / `isUndefined(x)` / `assertDefined(x)` | `undefined` checks (not null) |
| `maybe(x?)` | Identity, typed `T \| undefined` — widens a value to `Maybe<T>` |
| `allPass(...preds)` / `somePass(...preds)` | Combine predicates into one `(x) => boolean` |
### Curried Collection Helpers ### Curried Collection Helpers
@ -388,7 +392,6 @@ const label = formatTimestampRelative(event.created_at) // "3 hours ago"
```typescript ```typescript
import { on } from '@welshman/lib' import { on } from '@welshman/lib'
// Each App owns its repository.
const unsub = on(app.repository, 'update', updates => { const unsub = on(app.repository, 'update', updates => {
console.log('added', updates.flatMap(u => u.added).length, 'events') console.log('added', updates.flatMap(u => u.added).length, 'events')
}) })

View file

@ -1,11 +1,13 @@
--- ---
name: welshman-net 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." description: "Use this skill when working with @welshman/net: relay connections, request/publish flows, auth, relay pool management, adapters, socket policies, the Repository/Tracker/WrapManager stores, or low-level nostr network I/O."
--- ---
# welshman/net — Relay Network Layer # 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 owns an `App` instance and its plugins) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities. `@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 owns instances of these primitives and wires them together) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities.
**There are no module-level singletons.** `Pool`, `Repository`, `Tracker` and `WrapManager` are plain classes you instantiate; the pool and repository a call should use are passed per call as a `context`. `Pool.get()`, `Repository.get()` and a mutable global `netContext` do not exist.
## Installation ## Installation
@ -18,18 +20,31 @@ yarn add @welshman/net
## Key Exports ## Key Exports
### Context
| Export | Description |
|--------|-------------|
| `NetContext` | `{pool?: Pool, repository?: Repository, getAdapter?: AdapterFactory}` — the instances a call should use |
| `AdapterContext` | `Partial<NetContext>` — what every `context` parameter accepts |
| `AdapterFactory` | `(url: string, context: NetContext) => AbstractAdapter \| undefined` |
Every entry point (`request`, `requestOne`, `publish`, `publishOne`, `makeLoader`, `diff`/`pull`/`push`) takes an optional `context`. There is no default: without `context.pool` a `wss://` url throws `"Unable to connect to relays without context.pool"`, and without `context.repository` `LOCAL_RELAY_URL` throws `"LOCAL_RELAY_URL cannot be used without context.repository"`. In an app, `app.netContext` is that object and `app.use(Network)` passes it for you.
### Pool & Sockets ### Pool & Sockets
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `Pool` | Singleton connection pool; creates and manages `Socket` instances per relay URL | | `Pool` | Connection pool; creates and manages `Socket` instances per relay url. Construct with `new Pool()` |
| `Pool.get()` | Returns the singleton `Pool` instance | | `pool.socketPolicies` | The policies applied to sockets this pool creates — copied from `defaultSocketPolicies` at construction |
| `pool.get(url)` | Gets or lazily creates a `Socket` for the given relay URL | | `pool.get(url)` | Gets or lazily creates a `Socket` for a (normalized) relay url |
| `pool.remove(url)` | Removes and cleans up a socket | | `pool.has(url)` | Whether a socket already exists for the url |
| `pool.subscribe(cb)` | Fires `cb(socket)` each time a new socket is created; returns unsubscriber | | `pool.remove(url)` | Cleans up the socket and forgets the url |
| `pool.clear()` | Removes every socket |
| `pool.subscribe(cb)` | Fires `cb(socket)` each time a new socket is created; returns an unsubscriber |
| `Socket` | WebSocket wrapper with status tracking, send queue, and auth state | | `Socket` | WebSocket wrapper with status tracking, send queue, and auth state |
| `SocketStatus` | Enum: `Open`, `Opening`, `Closing`, `Closed`, `Error` | | `SocketStatus` | Enum: `Open`, `Opening`, `Closing`, `Closed`, `Error` |
| `SocketEvent` | Enum: `Status`, `Send`, `Sending`, `Receive`, `Receiving`, `Error` | | `SocketEvent` | Enum: `Status`, `Send`, `Sending`, `Receive`, `Receiving`, `Error` |
| `socket.open()` / `attemptToOpen()` / `close()` / `cleanup()` / `send(message)` | Connection and send control |
| `socket.auth` | `AuthState` instance for NIP-42 on this connection | | `socket.auth` | `AuthState` instance for NIP-42 on this connection |
### Request ### Request
@ -39,20 +54,24 @@ yarn add @welshman/net
| `requestOne(options)` | Subscribe to a single relay; returns `Promise<TrustedEvent[]>` | | `requestOne(options)` | Subscribe to a single relay; returns `Promise<TrustedEvent[]>` |
| `request(options)` | Subscribe to multiple relays in parallel; 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 | | `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. | | `load(options)` | Pre-built loader with a 30 ms batch delay, 3 s timeout and 0.5 threshold. It auto-closes after EOSE, timeout, or disconnect, and resolves when half the relays' subscriptions have closed. It carries no context, so it only works where no pool or repository is needed. |
`request` / `requestOne` options (key fields): `request` / `requestOne` options (`BaseRequestOptions`):
- `relay` / `relays` — relay URL(s) - `relay` / `relays` — relay url(s)
- `filters` — array of nostr `Filter` objects - `filters` — array of nostr `Filter` objects
- `autoClose?: boolean` — close subscription after EOSE or on socket disconnect - `autoClose?: boolean` — close the subscription after EOSE or on socket disconnect
- `signal?: AbortSignal` — cancellation - `signal?: AbortSignal` — cancellation
- `tracker?: Tracker` — cross-relay deduplication (shared automatically by `request`) - `tracker?: Tracker` — cross-relay deduplication (shared automatically by `request`)
- `context?: AdapterContext`
- `resubscribeAttempts?: number` — how many times to retry a subscription the relay CLOSEs (default `0`; each retry backs off `2 ** attempt` seconds)
- `isEventValid?: (event, url) => boolean` — signature check override; defaults to `verifyEvent`
- Callbacks: `onEvent(event, url)`, `onEose(url)`, `onClose()`, `onDisconnect(url)`, `onFiltered`, `onDuplicate`, `onDeleted`, `onInvalid`, `onClosed(reason, url)` - Callbacks: `onEvent(event, url)`, `onEose(url)`, `onClose()`, `onDisconnect(url)`, `onFiltered`, `onDuplicate`, `onDeleted`, `onInvalid`, `onClosed(reason, url)`
`request`-only options: `request`-only: `threshold?: number` — fraction of relays that must close before the promise resolves (default `1`).
- `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. `makeLoader` options: `{delay, timeout?, threshold?, context?, isEventValid?}`. The returned `Loader` takes `{relays, filters, signal?, onEvent?, onDisconnect?, onEose?, onClose?}`.
Without `autoClose` or a `signal`, `requestOne` streams indefinitely. The returned promise only resolves if the relay sends CLOSED for all active subscription ids.
### Publish ### Publish
@ -61,7 +80,7 @@ Without `autoClose` or a `signal`, `requestOne` streams indefinitely — the ret
| `publish(options)` | Publishes to multiple relays; resolves to `PublishResultsByRelay` | | `publish(options)` | Publishes to multiple relays; resolves to `PublishResultsByRelay` |
| `publishOne(options)` | Publishes to a single relay; resolves to `PublishResult` | | `publishOne(options)` | Publishes to a single relay; resolves to `PublishResult` |
| `PublishStatus` | Enum: `Sending`, `Pending`, `Success`, `Failure`, `Timeout`, `Aborted` | | `PublishStatus` | Enum: `Sending`, `Pending`, `Success`, `Failure`, `Timeout`, `Aborted` |
| `PublishResult` | `{ relay: string, status: PublishStatus, detail: string }` | | `PublishResult` | `{status: PublishStatus, detail: string, relay: string}` |
| `PublishResultsByRelay` | `Record<string, PublishResult>` | | `PublishResultsByRelay` | `Record<string, PublishResult>` |
`publish` options: `event`, `relays`, `timeout?` (default 10 s), `signal?`, `context?`, plus callbacks `onSuccess`, `onFailure`, `onPending`, `onTimeout`, `onAborted`, `onComplete`. `publish` options: `event`, `relays`, `timeout?` (default 10 s), `signal?`, `context?`, plus callbacks `onSuccess`, `onFailure`, `onPending`, `onTimeout`, `onAborted`, `onComplete`.
@ -73,78 +92,74 @@ Without `autoClose` or a `signal`, `requestOne` streams indefinitely — the ret
| `AuthState` | Manages auth state for one socket; available as `socket.auth` | | `AuthState` | Manages auth state for one socket; available as `socket.auth` |
| `AuthStatus` | Enum: `None`, `Requested`, `PendingSignature`, `DeniedSignature`, `PendingResponse`, `Forbidden`, `Ok` | | `AuthStatus` | Enum: `None`, `Requested`, `PendingSignature`, `DeniedSignature`, `PendingResponse`, `Forbidden`, `Ok` |
| `AuthStateEvent.Status` | Emitted when auth status changes | | `AuthStateEvent.Status` | Emitted when auth status changes |
| `makeSocketPolicyAuth(options)` | Creates a socket policy that auto-handles auth challenges | | `makeSocketPolicyAuth(options)` | Creates a socket policy that auto-handles auth challenges. Options: `{sign, shouldAuth?}` |
| `defaultSocketPolicies` | Mutable array of policies applied to every new socket |
### Policies ### Policies
A `SocketPolicy` is `(socket: Socket) => Unsubscriber`, run once per socket at creation.
| Export | Description | | 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 them once it completes |
| `socketPolicyAuthBuffer` | Buffers outgoing messages during auth and replays after success |
| `socketPolicyConnectOnSend` | Auto-opens closed sockets when a message is queued | | `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 | | `socketPolicyLifecycle` | Owns the socket's lifetime: closes it after 30 s idle with nothing pending; while work *is* pending, probes with a throwaway REQ when nothing has been received for a while and closes if the probe goes unanswered; on an unexpected close, reopens after a flap delay and replays pending messages, rewriting each REQ's filters with `catchUpFilter` so nothing is missed |
| `defaultSocketPolicies` | Array of the four above; passed to every socket created by `Pool` | | `defaultSocketPolicies` | `[socketPolicyAuthBuffer, socketPolicyConnectOnSend, socketPolicyLifecycle]` |
A `SocketPolicy` is `(socket: Socket) => Unsubscriber`. `defaultSocketPolicies` is a template. `new Pool()` copies it into `pool.socketPolicies`, so mutating the array after a pool exists does nothing for that pool; assign to `pool.socketPolicies` instead.
### Repository ### Repository
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `Repository` | In-memory indexed event store with delete/expiry support | | `Repository` | In-memory indexed event store with delete/expiry support. Construct with `new Repository()` |
| `Repository.get()` | Returns the singleton instance | | `repository.publish(event, {shouldNotify?})` | Stores an event; returns `false` if duplicate/stale |
| `repository.publish(event)` | Stores an event; returns `false` if duplicate/stale | | `repository.query(filters, {shouldSort?})` | Returns matching `TrustedEvent[]`, sorted by `created_at` desc unless disabled |
| `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.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.hasEvent(event)` | Whether the event (or a newer replacement) is already stored |
| `repository.dump()` | Returns all stored events as `TrustedEvent[]` | | `repository.removeEvent(idOrAddress)` | Drops an event and unwinds its index entries |
| `repository.load(events)` | Bulk-replaces all stored events; emits a single `"update"` diff. Events with `event[verifiedSymbol] = true` skip signature re-verification. | | `repository.isDeleted(event)` | `true` if a kind-5 delete covers this event (`isDeletedById` / `isDeletedByAddress` check one path each) |
| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — conventional URL for the local repository | | `repository.isExpired(event)` | `true` past the event's NIP-40 `expiration` |
| `RepositoryUpdate` | `{ added: TrustedEvent[], removed: Set<string> }` — payload of `"update"` events | | `repository.dump()` | All stored events as `TrustedEvent[]` |
| `repository.load(events)` | Bulk-**replaces** all stored events; emits one `"update"` diff. Events with `event[verifiedSymbol] = true` skip signature re-verification |
| `repository.clear()` | Empties every index |
| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — conventional url for the local repository (also exported by `@welshman/util`) |
| `RepositoryUpdate` | `{added: TrustedEvent[], removed: Set<string>}` — payload of `"update"` events |
| `mergeRepositoryUpdates(updates)` | Merges an array of `RepositoryUpdate` objects into one | | `mergeRepositoryUpdates(updates)` | Merges an array of `RepositoryUpdate` objects into one |
Emits `"update"` with `RepositoryUpdate` (`{ added: TrustedEvent[], removed: Set<string> }`) on every change. Emits `"update"` with a `RepositoryUpdate` 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()`). > **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 `load()`, `request()` and `publish()` (with the repository in `context`). Local reads and writes then go through the same policy, deduplication and tracking pipeline as remote ones. Reserve the direct API for bulk startup (`repository.load()`) and low-level introspection (`getEvent`, `isDeleted`, `dump`).
### Tracker ### Tracker
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `Tracker` | Bidirectional map of `eventId ↔ Set<relayUrl>` | | `Tracker` | Bidirectional map of `eventId ↔ Set<relayUrl>` (`relaysById` / `idsByRelay`) |
| `tracker.track(eventId, relay)` | Records relay; returns `true` if the event was already seen | | `tracker.track(eventId, relay)` | Records the relay; returns `true` if the event was already seen |
| `tracker.getRelays(eventId)` | Set of relay URLs that have sent this event | | `tracker.addRelay(id, relay)` / `removeRelay(id, relay)` | Explicit edge management |
| `tracker.hasRelay(id, relay)` | Membership check |
| `tracker.getRelays(eventId)` | Set of relay urls that have sent this event |
| `tracker.getIds(relay)` | Set of event ids seen from a relay | | `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.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.load(relaysById)` | Bulk-replaces all mappings from a `Map<string, Set<string>>`; emits `"load"` |
| `tracker.clear()` | Removes all relay mappings; emits `"clear"` | | `tracker.clear()` | Removes all mappings; emits `"clear"` |
### Adapters ### Adapters
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `getAdapter(url, context?)` | Factory: returns `SocketAdapter`, `LocalAdapter`, or custom adapter | | `getAdapter(url, context?)` | Factory: `context.getAdapter` first, then `LocalAdapter` for `LOCAL_RELAY_URL`, then `SocketAdapter` for relay urls |
| `SocketAdapter` | WebSocket relay adapter | | `SocketAdapter` | WebSocket relay adapter |
| `LocalAdapter` | In-memory relay adapter | | `LocalAdapter` | In-memory adapter over a `Repository` |
| `MockAdapter` | Test adapter with manual send control | | `MockAdapter` | Test adapter with manual send control |
| `AbstractAdapter` | Base class for custom adapters | | `AbstractAdapter` | Base class for custom adapters |
| `AdapterEvent.Receive` | Emitted when a relay message arrives | | `AdapterEvent.Receive` | Emitted when a relay message arrives |
### Context
| Export | Description |
|--------|-------------|
| `NetContext` | `{ pool?, repository?, getAdapter? }` |
Pass `context` to each `request`/`publish`/`load` call. An `App` from `@welshman/app` owns one per
instance (`app.netContext`), and `app.use(Network)` supplies it for you.
### Negentropy / Diff (NIP-77) ### Negentropy / Diff (NIP-77)
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `diff(options)` | Compares local events against relays; returns `{ relay, have, need }[]` | | `diff(options)` | Compares local events against relays; returns `{relay, have, need}[]` |
| `pull(options)` | Fetches events relays have that you don't | | `pull(options)` | Fetches events relays have that you don't |
| `push(options)` | Publishes events you have that relays don't | | `push(options)` | Publishes events you have that relays don't |
| `Difference` | Low-level per-relay negentropy session | | `Difference` | Low-level per-relay negentropy session |
@ -153,27 +168,43 @@ instance (`app.netContext`), and `app.use(Network)` supplies it for you.
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `RelayMessageType` | Enum of relay→client message types | | `RelayMessageType` / `ClientMessageType` | Enums of relay→client and client→relay message types |
| `ClientMessageType` | Enum of client→relay message types | | `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, `isRelayClosed()`, … | Type guards for relay messages |
| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, etc. | Type guards for relay messages | | `isClientReq()`, `isClientEvent()`, `isClientClose()`, `isClientAuth()`, … | Type guards for client messages |
| `isClientReq()`, `isClientEvent()`, etc. | Type guards for client messages | | `matchReason(prefix, reason)` / `RelayReasonPrefix` | Match a relay's machine-readable `OK`/`CLOSED` reason prefix (`auth-required:`, `restricted:`, …) |
### WrapManager ### WrapManager
| Export | Description | | 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 | | `WrapManager` | Tracks NIP-59 gift wrap ↔ rumor relationships. `new WrapManager({tracker, repository})` |
| `wrapManager.add({wrap, rumor, recipient})` | Stores the rumor in the repository and copies the wrap's relay tracking onto it |
| `wrapManager.getRumor(wrapId)` / `getWraps(rumorId)` | Look up either direction |
| `wrapManager.remove(id)` / `removeByRumorId(id)` / `clear()` | Teardown |
| `wrapManager.dump()` / `load(wrapItems)` | Persist and restore (`WrapItem[]`) |
--- ---
## Common Patterns ## Common Patterns
### Set up a context
```typescript
import {Pool, Repository} from '@welshman/net'
import type {NetContext} from '@welshman/net'
const pool = new Pool()
const repository = new Repository()
const context: NetContext = {pool, repository}
```
Pass `context` to every net call below.
### Connect to a relay and stream events ### Connect to a relay and stream events
```typescript ```typescript
import {Pool, SocketEvent, SocketStatus} from '@welshman/net' import {SocketEvent, SocketStatus} from '@welshman/net'
const pool = Pool.get()
const socket = pool.get('wss://relay.example.com') const socket = pool.get('wss://relay.example.com')
socket.on(SocketEvent.Status, (status: SocketStatus) => { socket.on(SocketEvent.Status, (status: SocketStatus) => {
@ -187,10 +218,12 @@ socket.send(['REQ', 'my-sub', {kinds: [1], limit: 10}])
### Load events (one-shot, batched) ### Load events (one-shot, batched)
```typescript ```typescript
import {load} from '@welshman/net' import {makeLoader} from '@welshman/net'
// Bind a loader to the context once; concurrent calls within `delay` collapse
// into a single REQ per relay.
const load = makeLoader({delay: 30, timeout: 3000, threshold: 0.5, context})
// 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({ const events = await load({
relays: ['wss://relay.example.com', 'wss://relay2.example.com'], relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
filters: [{kinds: [0], authors: ['<pubkey>']}], filters: [{kinds: [0], authors: ['<pubkey>']}],
@ -203,14 +236,15 @@ const events = await load({
import {request} from '@welshman/net' import {request} from '@welshman/net'
import {now} from '@welshman/lib' import {now} from '@welshman/lib'
// Without autoClose this will stream forever. // Without autoClose this streams forever; the returned promise never settles
// The returned promise never settles unless all relays close the subscription. // unless all relays close the subscription.
const ctrl = new AbortController() const ctrl = new AbortController()
request({ request({
relays: ['wss://relay.example.com'], relays: ['wss://relay.example.com'],
filters: [{kinds: [1], since: now()}], filters: [{kinds: [1], since: now()}],
signal: ctrl.signal, signal: ctrl.signal,
context,
onEvent: (event, url) => console.log(event.id, 'from', url), onEvent: (event, url) => console.log(event.id, 'from', url),
}) })
@ -227,6 +261,7 @@ const results = await publish({
event: signedEvent, event: signedEvent,
relays: ['wss://relay.example.com', 'wss://relay2.example.com'], relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
timeout: 5000, timeout: 5000,
context,
onSuccess: r => console.log('accepted by', r.relay), onSuccess: r => console.log('accepted by', r.relay),
onFailure: r => console.warn('rejected by', r.relay, r.detail), onFailure: r => console.warn('rejected by', r.relay, r.detail),
}) })
@ -238,36 +273,40 @@ for (const [relay, result] of Object.entries(results)) {
} }
``` ```
### Enable NIP-42 auth globally ### Enable NIP-42 auth
Policies are per-pool. Set them before the pool creates any sockets, because a socket already in the pool keeps the policies it was built with.
```typescript ```typescript
import {defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net' import {Pool, defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net'
import type {StampedEvent} from '@welshman/util' import type {StampedEvent} from '@welshman/util'
// Call once at app startup, before any sockets are opened. const pool = new Pool()
defaultSocketPolicies.push(
pool.socketPolicies = [
...defaultSocketPolicies,
makeSocketPolicyAuth({ makeSocketPolicyAuth({
sign: (event: StampedEvent) => mySigner.sign(event), sign: (event: StampedEvent) => mySigner.sign(event),
shouldAuth: (socket) => true, // auth on every relay shouldAuth: socket => true, // auth on every relay
}), }),
) ]
``` ```
### Custom socket policies ### 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. A policy receives the socket when it is created, attaches listeners or patches socket methods, and returns a cleanup function.
```typescript ```typescript
import {writable} from 'svelte/store' import {writable} from 'svelte/store'
import {on} from '@welshman/lib' import {on} from '@welshman/lib'
import {defaultSocketPolicies, SocketEvent, isRelayEvent} from '@welshman/net' import {SocketEvent, isRelayEvent} from '@welshman/net'
import type {Socket, RelayMessage} from '@welshman/net' import type {Socket, RelayMessage} from '@welshman/net'
// Track how many events each relay has delivered this session // Track how many events each relay has delivered this session
export const eventCountByRelay = writable<Record<string, number>>({}) export const eventCountByRelay = writable<Record<string, number>>({})
const eventCountPolicy = (socket: Socket) => { const eventCountPolicy = (socket: Socket) =>
const unsub = on(socket, SocketEvent.Receive, (message: RelayMessage) => { on(socket, SocketEvent.Receive, (message: RelayMessage) => {
if (isRelayEvent(message)) { if (isRelayEvent(message)) {
eventCountByRelay.update(counts => ({ eventCountByRelay.update(counts => ({
...counts, ...counts,
@ -276,13 +315,10 @@ const eventCountPolicy = (socket: Socket) => {
} }
}) })
return unsub // called when the socket is destroyed pool.socketPolicies = [...pool.socketPolicies, eventCountPolicy]
}
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. The same structure covers more advanced patterns. Patch `socket.open` to block connections, or listen to `SocketEvent.Sending`/`SocketEvent.Receiving` to intercept messages before they are processed.
### Custom adapter (e.g. non-WebSocket backend) ### Custom adapter (e.g. non-WebSocket backend)
@ -300,7 +336,8 @@ class MyAdapter extends AbstractAdapter {
get sockets() { return [] } get sockets() { return [] }
send(message: ClientMessage) { send(message: ClientMessage) {
// forward message to your backend; call this.emit(AdapterEvent.Receive, replyMsg, this.url) when data arrives // forward to your backend; call
// this.emit(AdapterEvent.Receive, replyMsg, this.url) when data arrives
} }
} }
@ -309,17 +346,18 @@ request({
filters: [{kinds: [1]}], filters: [{kinds: [1]}],
autoClose: true, autoClose: true,
context: { context: {
getAdapter: (url) => url.startsWith('myscheme://') ? new MyAdapter(url) : undefined, ...context,
getAdapter: url => (url.startsWith('myscheme://') ? new MyAdapter(url) : undefined),
}, },
}) })
``` ```
A `getAdapter` that returns `undefined` falls through to the built-in resolution, so you can special-case one scheme and leave the rest alone.
### Use LOCAL_RELAY_URL to read/write the local repository ### 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 ```typescript
import {load, publish, request, LOCAL_RELAY_URL} from '@welshman/net' import {publish, request, LOCAL_RELAY_URL} from '@welshman/net'
import {now} from '@welshman/lib' import {now} from '@welshman/lib'
// Read from the local repository the same way you'd read from a remote relay // Read from the local repository the same way you'd read from a remote relay
@ -332,27 +370,24 @@ const events = await load({
await publish({ await publish({
event: signedEvent, event: signedEvent,
relays: [LOCAL_RELAY_URL, 'wss://relay.example.com'], relays: [LOCAL_RELAY_URL, 'wss://relay.example.com'],
context,
}) })
// Subscribe to new local events in real time // Subscribe to new local events in real time
request({ request({
relays: [LOCAL_RELAY_URL], relays: [LOCAL_RELAY_URL],
filters: [{kinds: [1], since: now()}], filters: [{kinds: [1], since: now()}],
onEvent: (event) => console.log('new local event', event.id), context,
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) ### Startup: bulk-load persisted events (skip re-verification)
```typescript ```typescript
import {Repository} from '@welshman/net'
import {verifiedSymbol} from '@welshman/util' import {verifiedSymbol} from '@welshman/util'
import type {TrustedEvent} from '@welshman/util' import type {TrustedEvent} from '@welshman/util'
const repo = Repository.get()
// Mark events as already-verified so welshman skips signature checks // Mark events as already-verified so welshman skips signature checks
const storedEvents: TrustedEvent[] = await loadFromStorage() const storedEvents: TrustedEvent[] = await loadFromStorage()
for (const event of storedEvents) { for (const event of storedEvents) {
@ -360,23 +395,21 @@ for (const event of storedEvents) {
} }
// Replaces all in-memory events in one pass; emits a single "update" // Replaces all in-memory events in one pass; emits a single "update"
repo.load(storedEvents) repository.load(storedEvents)
``` ```
### Startup: bulk-load Tracker state ### Startup: bulk-load Tracker state
```typescript ```typescript
// `app.tracker` — wired to that app's pool and repository // Build the map from your stored relay <-> event mappings
// Build the map from your stored relay<->event mappings
const relaysById = new Map<string, Set<string>>() const relaysById = new Map<string, Set<string>>()
for (const {id, relays} of storedTrackerItems) { for (const {id, relays} of storedTrackerItems) {
if (repo.getEvent(id)) { // skip orphaned entries if (repository.getEvent(id)) { // skip orphaned entries
relaysById.set(id, new Set(relays)) relaysById.set(id, new Set(relays))
} }
} }
// Takes Map<string, Set<string>> — same shape as tracker.relaysById // Takes Map<string, Set<string>> — the same shape as tracker.relaysById
tracker.load(relaysById) tracker.load(relaysById)
``` ```
@ -384,7 +417,6 @@ tracker.load(relaysById)
```typescript ```typescript
import {on, batch} from '@welshman/lib' import {on, batch} from '@welshman/lib'
// `app.repository`, or `new Repository()` when using @welshman/net standalone
import type {RepositoryUpdate} from '@welshman/net' import type {RepositoryUpdate} from '@welshman/net'
import type {TrustedEvent} from '@welshman/util' import type {TrustedEvent} from '@welshman/util'
@ -415,28 +447,30 @@ on(
## Integration Notes ## Integration Notes
- **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent` (already verified). - **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, `normalizeRelayUrl`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent`.
- **`@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/lib`** — utility helpers (`Emitter`, `batcher`, `defer`, `on`, …). `Emitter` is the base class for `Tracker`, `Repository` and `WrapManager`; `Socket`, `AuthState`, `AbstractAdapter` and `Difference` extend node's `EventEmitter` directly.
- **`@welshman/app`** — wraps `@welshman/net` in an `App` instance whose plugins own routing, collections, and publishing. Most app-level code should go through `app.use(Network)`; drop down to `@welshman/net` only for raw relay I/O or when building without an `App`. - **`@welshman/app`** — an `App` owns one `Pool`, `Repository`, `Tracker` and `WrapManager`, exposes them as `app.netContext`, and wraps the request/publish entry points as `app.use(Network)`. Most app-level code should go through that; drop to `@welshman/net` for raw relay I/O or non-Svelte clients.
- **`NetContext`** — passed explicitly per call. Each `App` owns its own pool and repository, which keeps one identity's data out of another's.
--- ---
## Gotchas & Tips ## 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. - **Build one `{pool, repository}` per identity and thread it through.** Two contexts never share sockets or events.
- **`request()` without `autoClose` or `signal` never resolves.** Pass one when you want a one-shot fetch; use a loader for the common case.
- **Relay url normalization happens inside `pool.get(url)`** via `normalizeRelayUrl`. Pass raw urls everywhere.
- **`pool.get(url)` creates a fresh socket after `pool.remove(url)`.** `remove` forgets the url and cleans up the socket, policies included; call it only when you want the pool to forget the relay, not merely to disconnect.
- **`socketPolicyLifecycle` reopens a closed socket only when work is pending.** Opening a socket because something was queued is `socketPolicyConnectOnSend`'s job.
- **Subscriptions the relay CLOSEs are not retried by default.** Set `resubscribeAttempts` when a caller should survive a transient refusal (e.g. auth in flight).
- **`Tracker` is shared across relays in `request()`**, so `onDuplicate` fires for events received from more than one relay. That is cross-relay deduplication, not an error.
- **`Repository.publish()` returns `false` for stale replaceable events.** If a newer version is already stored, the older one is silently dropped.
- **`makeSocketPolicyAuth` requires a `sign` function** returning `Promise<SignedEvent>`. If the user cancels, throw or reject: `doAuth` catches it and transitions to `AuthStatus.DeniedSignature`, preventing a retry loop.
- **Each filter in `filters` generates a separate REQ** inside `requestOne`. For large filter arrays merge them with `unionFilters` from `@welshman/util` first.
- **`repository.load()` replaces, it does not append.** It clears the indexes then re-inserts, emitting a single batched `"update"`. Use `repository.publish(event)` for incremental updates.
- **`RepositoryUpdate.removed` is a `Set<string>`.** Iterate with `for...of` or `Array.from`. `batch()` from `@welshman/lib` hands your callback a `RepositoryUpdate[]` — merge them yourself or use `mergeRepositoryUpdates`.
- **`tracker.load()` takes `Map<string, Set<string>>`.** Load it after `repository.load()` so you can drop orphaned event ids.
- **`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. ## Related skills
- **`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. - `welshman-app` — the instance-based layer that owns these primitives (`app.netContext`, `app.use(Network)`, `app.use(Sync)`).
- **`defaultSocketPolicies` is mutable.** Push policies before any sockets are created. Sockets created before a policy is pushed will not have it applied. - `welshman-store` — Svelte derivations over a `Repository` and `Tracker`.
- **`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. - `welshman-util` — the event, filter and relay-url types on the wire, and the routing DSL for choosing which urls to pass to these functions.
- **`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.

View file

@ -25,25 +25,34 @@ yarn add @welshman/signer
The common contract all signers implement. The common contract all signers implement.
```typescript ```typescript
import type { ISigner, SignOptions, SignWithOptions } from '@welshman/signer' import type {ISigner, SignOptions, EncryptionImplementation} from '@welshman/signer'
interface ISigner { interface ISigner {
sign: (event: StampedEvent, options?: SignOptions) => Promise<SignedEvent> sign: SignWithOptions
nip04: EncryptionImplementation
nip44: EncryptionImplementation
getPubkey: () => Promise<string> 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> cleanup?: () => Promise<void>
} }
type SignOptions = { signal?: AbortSignal } type SignOptions = {signal?: AbortSignal}
type Sign = (event: StampedEvent) => Promise<SignedEvent>
type SignWithOptions = (event: StampedEvent, options?: SignOptions) => Promise<SignedEvent>
type Encrypt = (pubkey: string, message: string) => Promise<string>
type Decrypt = (pubkey: string, message: string) => Promise<string>
type EncryptionImplementation = {encrypt: Encrypt; decrypt: Decrypt}
``` ```
### Helpers & wrappers
| Export | Description |
|---|---|
| `decrypt(signer, pubkey, message)` | Picks nip04 or nip44 by sniffing the ciphertext for `?iv=` |
| `nip04` / `nip44` | The raw primitives (`encrypt`/`decrypt` taking an explicit secret; `nip04.detect(m)`; `nip44.getSharedSecret`, LRU-cached) |
| `WrappedSigner` | An `ISigner` that routes every method through a `SignerMethodWrapper`, and an `Emitter`. `@welshman/app` layers decrypt-caching and signer logging onto the user's signer this way, via `User.wrapSigner(wrap)` |
| `SignerMethodWrapper` | `<T>(method: string, thunk: () => Promise<T>, args: unknown[]) => Promise<T>` — `method` is `"sign"`, `"getPubkey"`, `"nip44.decrypt"`, …; `args` lets a wrapper key a cache off the call's arguments |
| `signWithOptions(promise, options)` | Races a signing promise against a 30 s timeout and the caller's `AbortSignal` |
### Nip01Signer (local keypair) ### Nip01Signer (local keypair)
| Export | Description | | Export | Description |
@ -73,13 +82,22 @@ type SignOptions = { signal?: AbortSignal }
### Nip55Signer (native mobile) ### Nip55Signer (native mobile)
This package never imports `nostr-signer-capacitor-plugin` — it types the plugin structurally as `Nip55` and the app hands it over. Install it (`npm install nostr-signer-capacitor-plugin`) and register it once at startup:
```ts
import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin"
import {setNip55Plugin} from "@welshman/signer"
setNip55Plugin(NostrSignerPlugin)
```
| Export | Description | | Export | Description |
|---|---| |---|---|
| `getNip55()` | Returns `Promise<AppInfo[]>` — installed signing apps via Capacitor | | `setNip55Plugin(plugin)` | Registers the Capacitor plugin. Until called, `Nip55Signer` operations throw `"Nip55 is not enabled"` |
| `getNip55Plugin()` | Returns the registered plugin, or `undefined` |
| `getNip55()` | Returns `Promise<Nip55AppInfo[]>` — installed signing apps, or `[]` when no plugin is registered |
| `new Nip55Signer(packageName, pubkey?)` | Communicates with the specified native app; pass saved pubkey to resume a session | | `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) ### Nip59 (Gift Wrap)
| Export | Description | | Export | Description |
@ -211,13 +229,13 @@ const plaintext = await signer.nip44.decrypt(theirPubkey, ciphertext)
- **`@welshman/util`** supplies `makeEvent`, `makeSecret`, `StampedEvent`, `SignedEvent`, and nostr kind constants (`NOTE`, `DIRECT_MESSAGE`, etc.) used in all examples above. - **`@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/net`** and **`@welshman/app`** accept an `ISigner` wherever signing is needed (e.g. publishing events). Pass any concrete signer — they are interchangeable.
- **`@welshman/app`** wraps a signer in a `User` (`User.fromSigner(signer)` or `User.fromSession(session)`), passed to the app at construction: `createApp({user})`. Reach it again via `app.user?.signer`, or `User.require(app).signer` where a login is required. - **`@welshman/app`** has no `signer` store. An identity is a `User` (`{pubkey, signer}`) hanging off the app: `User.fromSigner(signer)` / `User.fromSession(session)`, then `createApp({user})`. Reach it as `app.user?.signer`, and require it with `User.require(app)`.
- `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. - `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 ## Gotchas & Tips
- **`Nip07Signer` is browser-only.** Do not instantiate it in SSR or Node environments; always guard with `getNip07()` first. - **`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. - **`Nip55Signer` requires Capacitor.** It will not work in a plain browser build. Only use it in a Capacitor-wrapped mobile app, after calling `setNip55Plugin(NostrSignerPlugin)` and confirming `getNip55()` returns apps. Without the plugin, `getNip55()` returns `[]` rather than throwing, so it doubles as the feature check.
- **`waitForNostrconnect` holds an open subscription.** Always pass an `AbortSignal` (e.g., from `new AbortController().signal`) so you can cancel if the user navigates away. - **`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. - **`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. - **`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.

View file

@ -30,6 +30,29 @@ yarn add @welshman/store
| `deriveEventsDesc(eventsByIdStore)` | Takes a `Readable<Map<string, TrustedEvent>>` and returns events sorted descending 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 | | `makeDeriveEvent(options)` | Factory returning `(idOrAddress: string) => Readable<TrustedEvent \| undefined>` for single-event lookups |
| `deriveIsDeleted(repository, event)` | `Readable<boolean>` — tracks deletion status of an event | | `deriveIsDeleted(repository, event)` | `Readable<boolean>` — tracks deletion status of an event |
| `deriveArray(itemsByIdStore)` | Turns any `Readable<Map<string, T>>` into `Readable<T[]>` |
| `getEventsById(options)` | The one-shot, non-reactive form of `deriveEventsById` |
### Relay-scoped event stores
These key events by the relays they were seen on, via a `Tracker`. Use them when the same event (or the same addressable coordinate) has to stay distinct per relay — NIP-29 rooms, relay membership, per-relay replaceables.
| Export | Description |
|---|---|
| `deriveEventsByIdByUrl(options)` | `Readable<Map<url, Map<id, TrustedEvent>>>` — every relay at once |
| `deriveEventsByIdForUrl(options)` | `Readable<Map<id, TrustedEvent>>` — one relay |
| `getEventsByIdByUrl` / `getEventsByIdForUrl` | Non-reactive equivalents |
| `deriveItemsByKeyByUrl<T>(options)` | The relay-scoped `deriveItemsByKey`: an event is keyed once per relay via `getKey(item, url)` |
Both relay-scoped option types extend `EventsByIdOptions` with `tracker: Tracker` (and `url: string` for the `ForUrl` variants). `ItemsByKeyByUrlOptions` adds two things over `ItemsByKeyOptions`:
```typescript
{
getKey: (item: T, url: string) => string | undefined // undefined excludes the item on that relay
revalidateOn?: Readable<unknown> // re-evaluate every key when this changes — for keys that
// depend on state settling later (e.g. a relay's NIP-11 self)
}
```
`deriveEventsById` / `deriveEvents` options (`EventsByIdOptions`): `deriveEventsById` / `deriveEvents` options (`EventsByIdOptions`):
```typescript ```typescript
@ -55,13 +78,6 @@ const deriveEvent = makeDeriveEvent({ repository })
const eventStore = deriveEvent(someIdOrAddress) // Readable<TrustedEvent | undefined> 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 ### Indexed collections
| Export | Description | | Export | Description |
@ -73,6 +89,17 @@ const notesDesc = deriveEventsDesc(noteEventsById)
| `makeLoadItem<T>(loadItem, getItem, options?)` | Cached async loader with staleness checks and exponential backoff | | `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 | | `makeForceLoadItem<T>(loadItem, getItem)` | Async loader that always fetches fresh data |
`makeLoadItem` options (`MakeLoadItemOptions`):
```typescript
{
timeout?: number // staleness window in SECONDS (default 3600)
maxSize?: number // LRU size for the fetched/attempt caches (default 10_000)
getFetched?: (key: string) => number // override where "last fetched" is stored
setFetched?: (key: string, ts: number) => void
getSource?: (...args: any[]) => string // how extra args key the backoff (default JSON.stringify)
}
```
`deriveItemsByKey` options: `deriveItemsByKey` options:
```typescript ```typescript
{ {
@ -88,7 +115,8 @@ const notesDesc = deriveEventsDesc(noteEventsById)
| Export | Description | | Export | Description |
|---|---| |---|---|
| `synced(config)` | Writable store that auto-persists to a `StorageProvider`; exposes a `.ready` promise | | `synced(config)` | Writable store that auto-persists to a `StorageProvider`; exposes a `.ready` promise (type `Synced<T>`) |
| `sync(config)` | The lower-level primitive: binds an existing store to a `StorageProvider` |
| `localStorageProvider` | Built-in `StorageProvider` backed by `localStorage` | | `localStorageProvider` | Built-in `StorageProvider` backed by `localStorage` |
`StorageProvider` interface: `StorageProvider` interface:
@ -110,7 +138,16 @@ interface StorageProvider {
| Export | Description | | Export | Description |
|---|---| |---|---|
| `getter<T>(store, options?)` | Returns `() => T`; auto-switches from `get()` to a subscription when call frequency exceeds `threshold` (default 10/s) | | `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 | | `withGetter<T>(store)` | Adds a `.get()` method to a `Readable` or `Writable` store (`WritableWithGetter` / `ReadableWithGetter`) |
### Derivation helpers
| Export | Description |
|---|---|
| `memoized(store)` | Suppresses notifications when the derived value is deep-equal to the last one |
| `deriveDeduplicated(stores, read)` | Derives `read()` from `stores`, skipping updates whose result is deep-equal to the previous. The backbone of `Projection`s in `@welshman/app` |
| `deriveDeduplicatedByValue(stores, read)` | Same, comparing by value identity rather than deep equality |
| `merged(stores)` | `derived(stores, identity)` — combine several stores into one array store |
## Common Patterns ## Common Patterns
@ -138,21 +175,22 @@ notes.subscribe($notes => {
### 2. Profiles indexed by pubkey ### 2. Profiles indexed by pubkey
```typescript ```typescript
import { parseJson } from "@welshman/lib"
import { Repository } from "@welshman/net" import { Repository } from "@welshman/net"
import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store" import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store"
import { PROFILE } from "@welshman/util" import { PROFILE, type TrustedEvent } from "@welshman/util"
import { Profile, type ProfileReader } from "@welshman/domain"
const repository = new Repository() const repository = new Repository()
// Decoding is @welshman/domain's job — configure the kind, then use its reader as eventToItem. type Profile = { event: TrustedEvent; name?: string }
const readProfile = Profile.configure({}).reader
const profilesByPubkey = deriveItemsByKey<ProfileReader>({ const profilesByPubkey = deriveItemsByKey<Profile>({
repository, repository,
filters: [{ kinds: [PROFILE] }], filters: [{ kinds: [PROFILE] }],
eventToItem: event => readProfile(event).parse(), // eventToItem decodes each event; here we parse the profile's JSON content.
getKey: profile => profile.author(), // In a full @welshman/app setup use `app.use(Domain).reader(Profile)` instead.
eventToItem: event => ({ event, ...parseJson(event.content) }),
getKey: profile => profile.event.pubkey,
}) })
// All profiles as array // All profiles as array
@ -163,7 +201,7 @@ const deriveProfile = makeDeriveItem(profilesByPubkey)
const aliceProfile = deriveProfile("alice-pubkey-hex") const aliceProfile = deriveProfile("alice-pubkey-hex")
aliceProfile.subscribe($profile => { aliceProfile.subscribe($profile => {
console.log($profile?.display()) console.log($profile?.name)
}) })
``` ```
@ -228,11 +266,10 @@ const getBookmark = (pubkey: string) => getBookmarksByPubkey().get(pubkey)
This `getBookmark` function is the right shape to pass as `getItem` to `makeLoadItem` This `getBookmark` function is the right shape to pass as `getItem` to `makeLoadItem`
(see Pattern 6). (see Pattern 6).
### 6. Full reactive item chain: deriveItemsByKey → deriveItems → getter → makeLoadItem → makeDeriveItem ### 6. Full reactive item chain
This is the canonical pattern for domain objects derived from repository events with This is the canonical pattern for domain objects derived from repository events with
on-demand network loading. `@welshman/app` packages it as `DerivedPlugin` — prefer subclassing on-demand network loading.
that over hand-rolling the chain unless you need something it doesn't cover.
```typescript ```typescript
import { import {
@ -243,8 +280,7 @@ import {
makeDeriveItem, makeDeriveItem,
} from "@welshman/store" } from "@welshman/store"
import { Network, Router } from "@welshman/app" import { Network, Router } from "@welshman/app"
import { outbox } from "@welshman/util" import { outbox, tagSpec, tagValue, tagValues } from "@welshman/util"
import { relayTags, tagSpec, tagValue, tagValues } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util" import type { TrustedEvent } from "@welshman/util"
const BOOKMARK_KIND = 30003 const BOOKMARK_KIND = 30003
@ -259,13 +295,13 @@ type Bookmark = {
const parseBookmark = (event: TrustedEvent): Bookmark => ({ const parseBookmark = (event: TrustedEvent): Bookmark => ({
pubkey: event.pubkey, pubkey: event.pubkey,
title: tagValue(tagSpec("title"), event.tags) ?? "Untitled", title: tagValue(tagSpec("title"), event.tags) ?? "Untitled",
urls: tagValues(relayTags("r"), event.tags), urls: tagValues(tagSpec("r"), event.tags),
event, event,
}) })
// Step 1: Reactive Map<pubkey, Bookmark> — live-updates from repository // Step 1: Reactive Map<pubkey, Bookmark> — live-updates from repository
const bookmarksByPubkey = deriveItemsByKey<Bookmark>({ const bookmarksByPubkey = deriveItemsByKey<Bookmark>({
repository: app.repository, repository,
filters: [{ kinds: [BOOKMARK_KIND] }], filters: [{ kinds: [BOOKMARK_KIND] }],
getKey: b => b.pubkey, getKey: b => b.pubkey,
eventToItem: parseBookmark, eventToItem: parseBookmark,
@ -304,18 +340,18 @@ aliceBookmark.subscribe($b => console.log($b?.title))
## Integration Notes ## 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/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`, and the tag specs (`tagValue`, `hexTags`, …) used when decoding events. - **`@welshman/util`** — provides `TrustedEvent`, `Filter`, and the tag/event helpers you use to decode events inside `eventToItem`. Higher-level typed decoders (profiles, lists, …) now live in `@welshman/domain` as async Reader classes (e.g. `Profile.configure(context).reader(event)`).
- **`@welshman/domain`** — the typed readers you normally pass as `eventToItem`, instead of writing a parser by hand.
- **`@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. - **`@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. - 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 ## 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`). - **`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 ? {event, pubkeys: tagValues(hexTags("p"), event.tags)} : 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. - **`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. - **`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. - **`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` timeout is in seconds**, because it is compared against `now()` from `@welshman/lib`. Write `{ 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. - **`makeLoadItem` propagates errors.** A rejection from `loadItem` rejects the returned promise; only the in-flight bookkeeping is cleaned up in a `finally`. Callers that treat a failed load as "no data yet" must catch it themselves, which is why app plugins wrap `load` in `.catch(noop)` on read paths.
- **`makeLoadItem` backs off exponentially per source**, so repeat attempts against a key that is not resolving are throttled rather than retried on every call. Use `makeForceLoadItem` when you need a fetch regardless of the cache.
- **`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`. - **`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. - **`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.

View file

@ -1,11 +1,11 @@
--- ---
name: welshman-util 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." description: "Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, keys, NIPs (13/42/86/98), relay urls, lightning, wallets, slash commands, or any core nostr data structure. Also covers relay selection — the RelaySelection routing DSL, Resolver, RelayScenario and fallback policies all live here (there is no @welshman/router package). (Profiles, lists, handlers, and rooms now live in @welshman/domain as Reader/Writer classes.)"
--- ---
# welshman/util — Core Nostr Utilities # 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. `@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, 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 ## Installation
@ -44,15 +44,41 @@ yarn add @welshman/util
| `getIdOrAddress(event)` | Returns address string for replaceable events, id otherwise | | `getIdOrAddress(event)` | Returns address string for replaceable events, id otherwise |
| `getIdAndAddress(event)` | Returns array with both id and address (if applicable) | | `getIdAndAddress(event)` | Returns array with both id and address (if applicable) |
| `deduplicateEvents(events)` | Deduplicate by id or address | | `deduplicateEvents(events)` | Deduplicate by id or address |
| `compareEventsAsc(a, b)` / `compareEventsDesc(a, b)` | Canonical event order: `created_at`, then `id` to break a same-second tie |
| `sortEventsAsc(events)` / `sortEventsDesc(events)` | Sort an iterable of events by that order |
| `isEphemeral(event)` | True for ephemeral kinds (20000–29999) | | `isEphemeral(event)` | True for ephemeral kinds (20000–29999) |
| `isReplaceable(event)` | True for plain or parameterized replaceable | | `isReplaceable(event)` | True for plain or parameterized replaceable |
| `isPlainReplaceable(event)` | True for kinds 10000–19999 and metadata/contacts | | `isPlainReplaceable(event)` | True for kinds 10000–19999 and metadata/contacts |
| `isParameterizedReplaceable(event)` | True for kinds 30000–39999 | | `isParameterizedReplaceable(event)` | True for kinds 30000–39999 |
| `sortEventsAsc(events)` / `sortEventsDesc(events)` | Sort by `created_at` |
| `asEventTemplate`, `asStampedEvent`, `asOwnedEvent`, `asHashedEvent`, `asSignedEvent` | Narrow an event down to the fields of a given level |
NIP-10 and NIP-22 threading lives on the `Note` and `Comment` classes in `@welshman/domain`, not here. The free helpers `getAncestors`, `getParentIdOrAddr`, `isChildOf`, `getReplyTags` and `getCommentTags` do not exist in this package.
### Type Guards ### Type Guards
`isEventTemplate`, `isStampedEvent`, `isOwnedEvent`, `isHashedEvent`, `isSignedEvent` `isEventTemplate`, `isStampedEvent`, `isOwnedEvent`, `isHashedEvent`, `isSignedEvent`
### Keys & event construction
| Export | Description |
|--------|-------------|
| `makeSecret()` | Cryptographically secure random hex private key |
| `getPubkey(secret)` | Derive the hex pubkey from a hex secret |
| `stamp(event, created_at?)` | `EventTemplate` → `StampedEvent` |
| `own(event, pubkey)` | `StampedEvent` → `OwnedEvent` |
| `hash(event)` / `getHash(event)` | `OwnedEvent` → `HashedEvent` / the id alone |
| `sign(event, secret)` / `getSig(event, secret)` | `HashedEvent` → `SignedEvent` / the sig alone |
| `prep(event, pubkey, created_at?)` | Stamp + own + hash in one step — an unsigned rumor |
### Proof of work (NIP-13)
| Export | Description |
|--------|-------------|
| `makePow(event, difficulty)` | Mine a `nonce` tag until the id has `difficulty` leading zero bits; returns a `ProofOfWork` |
| `getPow(event)` | Leading zero bits on an event's id |
| `estimateWork(difficulty)` / `benchmarkDifficulty` | Rough cost estimate for a difficulty target |
### Event Kinds (constants) ### Event Kinds (constants)
All constants are exported by name from `@welshman/util`. All constants are exported by name from `@welshman/util`.
@ -133,8 +159,22 @@ ROOM_ADD_MEMBER = 9000 ROOM_REMOVE_MEMBER = 9001
ROOM_ADD_PERM = 9003 ROOM_REMOVE_PERM = 9004 ROOM_ADD_PERM = 9003 ROOM_REMOVE_PERM = 9004
ROOM_DELETE_EVENT = 9005 ROOM_EDIT_STATUS = 9006 ROOM_DELETE_EVENT = 9005 ROOM_EDIT_STATUS = 9006
ROOM_CREATE_PERMISSION = 19004 ROOM_CREATE_PERMISSION = 19004
ROOM_UPDATE_PINS = 9010 ROOM_PINS = 39005
RELAY_MEMBERS = 13534 RELAY_ADD_MEMBER = 8000 RELAY_REMOVE_MEMBER = 8001 RELAY_MEMBERS = 13534 RELAY_ADD_MEMBER = 8000 RELAY_REMOVE_MEMBER = 8001
RELAY_JOIN = 28934 RELAY_INVITE = 28935 RELAY_LEAVE = 28936 RELAY_JOIN = 28934 RELAY_INVITE = 28935 RELAY_LEAVE = 28936
RELAY_ROLE = 33534
```
**Pinboards**
```
PINBOARD = 30067 PIN = 39067
```
**Slash commands**
```
COMMAND = 31992
``` ```
**Replaceable lists (kinds 10000–10099)** **Replaceable lists (kinds 10000–10099)**
@ -190,7 +230,7 @@ ALERT_ANDROID = 32833 ALERT_IOS = 32834
**Zaps / wallet / Lightning** **Zaps / wallet / Lightning**
``` ```
ZAP_GOAL = 9041 ZAP_REQUEST = 9734 ZAP_RESPONSE = 9735 ZAP_GOAL = 9041 ZAP_REQUEST = 9734 ZAP_RECEIPT = 9735
WALLET_INFO = 13194 WALLET_REQUEST = 23194 WALLET_RESPONSE = 23195 WALLET_INFO = 13194 WALLET_REQUEST = 23194 WALLET_RESPONSE = 23195
LIGHTNING_PUB_RPC = 21000 LIGHTNING_PUB_RPC = 21000
OTS = 1040 OTS = 1040
@ -270,16 +310,20 @@ isDVMKind(kind) // 5000–7000
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `tagSpec(keys, matchValue?, normalize?)` | Build a spec: which tag keys, plus optional validation/normalization | | `tagSpec(keys, matchValue?, normalizeValue?)` | Build a `TagSpec` — `keys` is a string or string[]; optional value filter/normalizer |
| `hexTags(keys)` | Spec for 32-byte hex values (`e`, `p`, …) | | `hexTags(keys)` | Spec matching 32-byte hex values (`isHex32`) — e/p tags |
| `addressTags(keys)` | Spec for `kind:pubkey:d` addresses (`a`, `A`) | | `addressTags(keys)` | Spec matching replaceable addresses (`Address.isAddress`) — a tags |
| `kindTags(keys)` | Spec for kind numbers (`k`) | | `relayTags(keys)` | Spec matching relay urls (`isRelayUrl`) — r/relay tags |
| `topicTags(keys)` | Spec for topics, normalized (`t`) | | `topicTags(keys)` | Spec that strips a leading `#` from values — t tags |
| `relayTags(keys)` | Spec for relay urls (`r`, `relay`) | | `kindTags(keys)` | Spec whose values parse to `number` — k tags |
| `tagValue(spec, tags)` | Value (index 1) of the first matching tag | | `matchTags(spec, tags)` | All tags matching the spec — spec first, then the tags array |
| `tagValues(spec, tags)` | Values of all matching tags | | `matchTag(spec, tags)` | First tag matching the spec, or `undefined` |
| `matchTag(spec, tags)` / `matchTags(spec, tags)` | The matching tag(s) themselves | | `tagValues(spec, tags)` | Values (index 1, normalized) of all matching tags; undefined dropped |
| `tagMatcher(spec)` | A `(tag) => boolean` predicate, for filtering in one pass | | `tagValue(spec, tags)` | Value of the first matching tag, or `undefined` |
| `tagMatcher(spec)` / `tagValueExtractor(spec)` | The raw `(tag)=>boolean` / `(tag)=>T` for a spec |
| `tagValueMatcher(spec, value)` | Predicate for one value, compared after normalization — matches however the tag spelled it |
The old `getTagValue`/`getPubkeyTagValues`/`getEventTags`/… accessors were removed — use the spec selectors above (e.g. `tagValues(hexTags("p"), tags)`, `tagValue(tagSpec("title"), tags)`).
### Filters ### Filters
@ -307,27 +351,166 @@ isDVMKind(kind) // 5000–7000
| `Address.from(s, relays?)` | Parse from `kind:pubkey:identifier` string | | `Address.from(s, relays?)` | Parse from `kind:pubkey:identifier` string |
| `Address.fromNaddr(naddr)` | Parse from NIP-19 naddr | | `Address.fromNaddr(naddr)` | Parse from NIP-19 naddr |
| `Address.fromEvent(event, relays?)` | Create from addressable event | | `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 | | `getAddress(event)` | Convenience: get address string from event |
### Relay ### Relay
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — the conventional url for the in-memory repository |
| `isRelayUrl(url)` | Validate relay URL | | `isRelayUrl(url)` | Validate relay URL |
| `isShareableRelayUrl(url)` | True if valid relay URL and not a local address | | `isShareableRelayUrl(url)` | True if valid relay URL and not a local address |
| `isOnionUrl(url)` | Tor address check | | `isOnionUrl(url)` | Tor address check |
| `isLocalUrl(url)` | Local address check | | `isLocalUrl(url)` | Local address check |
| `isIPAddress(url)` | IP address check | | `isIPAddress(url)` | IP address check |
| `normalizeRelayUrl(url)` | Normalize to standard wss:// format | | `normalizeRelayUrl(url)` | Normalize to standard wss:// format (passes `LOCAL_RELAY_URL` through unchanged) |
| `displayRelayUrl(url)` | Strip protocol and trailing slash | | `displayRelayUrl(url)` | Strip protocol and trailing slash |
### Zaps (NIP-57) `RelayMode`, `RelayProfile` and `displayRelayProfile` are gone. Read/write intent is expressed by the NIP-65 `RelayList` reader/writer in `@welshman/domain` (`readUrls()`/`writeUrls()`, `addReadUrl`/`addWriteUrl`), and NIP-11 relay info by the `Relay` type in `@welshman/domain` plus `app.use(Relays)`.
### Relay Selection (routing DSL)
`RelaySelection.ts` holds the relay-routing DSL. A *relay selection* names a source, such as
"the author's outbox" or "the relays this event was seen on", rather than a list of urls. Turning
one into urls needs relay lists, a tracker and a repository, so producing and resolving selections
are separate steps: this package defines and scores them, and `@welshman/app`'s `Router` plugin
supplies the one `ResolveRoute` implementation that dereferences them (see the `welshman-app`
skill). `@welshman/domain` readers/writers/queries emit selections, and `@welshman/feeds` asks for
the capability as its `FeedRouter` interface.
**Route + selection types**
| Type | Description |
|------|-------------|
| `EventRef` | `{ id?, pubkey?, kind?, identifier?, relays? }` — all optional and additive. A known `pubkey` routes directly without finding the event; `id` (or `kind`+`pubkey`+`identifier`) lets the resolver look it up; `relays` are hints for that lookup and a last-resort fallback |
| `RelayRoute` | Discriminated union: `userInbox` / `userOutbox` / `userMessaging`, `pubkeyInbox` / `pubkeyOutbox` / `pubkeyMessaging` (`{pubkey}`), `eventInbox` / `eventOutbox` / `seen` (`{ref}`), `relay` (`{url}`), `index`, `search` |
| `RelaySelection` | `{ route: RelayRoute; weight: number }` |
**DSL constructors** (each defaults `weight = 1`)
| Export | Returns | Description |
|--------|---------|-------------|
| `inbox(pubkey, weight?)` | `RelaySelection` | that pubkey's read relays |
| `outbox(pubkey, weight?)` | `RelaySelection` | that pubkey's write relays |
| `messaging(pubkey, weight?)` | `RelaySelection` | that pubkey's NIP-17 messaging relays |
| `userInbox(weight?)` / `userOutbox(weight?)` / `userMessaging(weight?)` | `RelaySelection` | the current user's relays |
| `eventInbox(ref, weight?)` / `eventOutbox(ref, weight?)` | `RelaySelection` | the referenced event's author's relays |
| `seen(ref, weight?)` | `RelaySelection` | relays the event was found on (tracker + ref hints) |
| `relay(url, weight?)` | `RelaySelection` | a literal relay url (formerly `relayHint`) |
| `relays(urls, weight?)` | `RelaySelection[]` | one selection per url (formerly `relayHints`) |
| `inboxes(pubkeys, weight?)` | `RelaySelection[]` | `uniq(pubkeys).map(inbox)` — inbox per referenced pubkey |
| `indexers(weight?)` | `RelaySelection` | profile/relay-list index relays |
| `searchRelays(weight?)` | `RelaySelection` | full-text search relays |
Note `relays` and `inboxes` return **arrays** — spread them with `...` into a route list; every
other constructor returns a single `RelaySelection`.
**Resolved selections + fallback policies**
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `getLnUrl(address)` | Convert lightning address or URL to LNURL; returns `undefined` if invalid | | `Selection` | `{ weight: number; relays: string[] }` — a concrete, resolved weighted relay set |
| `getInvoiceAmount(bolt11)` | Extract millisatoshi amount from BOLT11 invoice | | `makeSelection(relays, weight?)` | Build a `Selection`, filtering `isRelayUrl` and normalizing each url |
| `hrpToMillisat(hrpString)` | Convert human-readable BTC amount to millisats (`bigint`) | | `FallbackPolicy` | `(count: number, limit: number) => number` — how many defaults to add |
| `addNoFallbacks` | Never add fallback relays (**the default**) |
| `addMinimalFallbacks` | Add one fallback only if nothing else was found |
| `addMaximalFallbacks` | Top up to the limit with fallbacks |
**`RelayScenario`** — scores and picks concrete relays from weighted `Selection`s:
```typescript
new RelayScenario(selections: Selection[], options?: RelayScenarioOptions)
// options: { policy?, limit?, allowLocal?, allowOnion?, allowInsecure?,
// getRelayQuality?, getDefaultRelays? }
scenario.limit(n) // chainable (returns a cloned scenario)
scenario.policy(fn) // chainable
scenario.allowLocal(bool) / allowOnion(bool) / allowInsecure(bool) // chainable
scenario.getUrls() // string[]
scenario.getUrl() // first of getUrls()
```
`getUrls()` drops onion, local and plain-`ws://` urls unless explicitly allowed, sums each url's
weight across selections, scores as `quality * (1 + log(weight))` times a random factor, takes the
best `limit` (default 3), then adds shuffled `getDefaultRelays()` urls per the fallback policy. The
log keeps a relay that many selections name from dominating, and the random factor lets lower-ranked
relays get picked occasionally.
**`Resolver`** — bundles a single route-resolution function with the scenario options to apply to
everything it produces:
```typescript
type ResolveRoute = (route: RelayRoute) => MaybeAsync<string[]>
new Resolver(routeResolver: ResolveRoute, options?: RelayScenarioOptions)
await resolver.scenario(selections) // Promise<RelayScenario> — resolves each route, builds a scenario
await resolver.relays(selections) // Promise<string[]> — scenario(...).getUrls()
await resolver.relay(selections) // Promise<string | undefined> — scenario(...).getUrl()
```
In an app, `@welshman/app`'s `Router` owns the `Resolver` (`app.use(Router).resolver`), built with
`getRelayQuality`/`getDefaultRelays` from app config, and injects it into every domain kind's
context. Domain readers/writers then call `def.context.resolver.scenario(...)` / `.relay(...)`.
```typescript
import {outbox, inboxes, relay, Resolver} from '@welshman/util'
// Declarative selections: author's write relays (weight 1) + each mentioned
// pubkey's read relays (weight 0.5) + an explicit relay hint.
const selections = [
outbox(authorPubkey),
...inboxes(mentionedPubkeys, 0.5),
relay('wss://relay.example.com'),
]
// A Resolver dereferences routes -> urls given some route resolver.
const resolver = new Resolver(resolveRoute, {limit: 5, getRelayQuality})
const urls = await resolver.relays(selections) // string[]
```
**Routing gotchas**
- **Resolution is async**, because resolving an outbox may have to load a NIP-65 list first.
- **A scenario that resolves to nothing yields an empty array.** Add `.policy(addMinimalFallbacks)` where an empty result would break the caller.
- **Weights express preference, not selection.** Naming the same url in ten selections does not make it ten times more likely. To force a relay, use `forceRoutes` (domain writers) or `setRoutes` (domain queries).
- **Two calls with identical selections can return different urls.** In tests, assert on membership rather than exact url lists, or inject a deterministic `getRelayQuality`.
- **A relay whose `getRelayQuality` is 0 is dropped entirely**, because the scenario filters on the score and `-0` is falsy. A scenario can come back empty even though its selections resolved to urls.
### Slash commands
`Command.ts` models NIP-89-style slash commands: `CommandArg`/`CommandArgType`/`COMMAND_ARG_TYPES`
(`pubkey`, `event`, `address`, `relay`, `number`, `bool`, `enum`, `word`, `text`) with
`validateCommandArgs`; `CommandScope`/`CommandScopeTarget` with `parseCommandScope`,
`renderCommandScope`, `matchesCommandScopes`, `commandScopeRelays`, `commandScopesToFilter`; and
the invocation grammar — `parseCommandInvocation`, `renderCommandInvocation`, `bindCommandArgs`,
`parseCommandArgs`, `getActiveCommandArgIndex`. Kind `COMMAND` is 31992. `@welshman/editor`'s
`CommandExtension`/`CommandSuggestion` render and autocomplete these in the composer.
### Lightning (NIP-57 support)
| Export | Description |
|--------|-------------|
| `getLnUrl(address)` | Convert a lud16 address, HTTPS URL, or existing `lnurl1…` to an LNURL; `undefined` if invalid |
| `getInvoiceAmount(bolt11)` | Extract the millisatoshi amount from a BOLT11 invoice |
| `hrpToMillisat(hrpString)` | Convert a human-readable BTC amount to millisats (`bigint`) |
| `toMsats(sats)` / `fromMsats(msats)` | Unit conversion |
The `Zapper` and `Zap` types moved to `@welshman/domain` (`other/Zapper.ts`), alongside the `ZapRequest`/`ZapReceipt`/`ZapGoal` kinds. Receipt validation is `app.use(Zappers).validateZapReceipt(...)`.
### NIP-05 handles
| Export | Description |
|--------|-------------|
| `Handle` | `{ nip05, pubkey?, nip46?, relays? }` |
| `queryProfile(nip05)` | Resolve a NIP-05 identifier via `/.well-known/nostr.json`; `undefined` on failure |
| `displayNip05(nip05)` / `displayHandle(handle)` | Drop a leading `_@` for display |
### Pubkey
`Pubkey` wraps a hex pubkey plus relay hints. `Pubkey.from(entity, relays?)` accepts hex, `npub…` or `nprofile…`; instances expose `toString()`, `toNpub()`, `toNprofile()`.
### Wallet ### Wallet
@ -357,17 +540,11 @@ makeHttpAuthHeader(event: SignedEvent): string // Returns "Nostr <base64>"
```typescript ```typescript
sendManagementRequest(url: string, request: ManagementRequest, authEvent: SignedEvent): Promise<ManagementResponse> sendManagementRequest(url: string, request: ManagementRequest, authEvent: SignedEvent): Promise<ManagementResponse>
// ManagementResponse = { result?: any; error?: string } // ManagementResponse = { result?: any; error?: string }
// ManagementMethod enum covers: BanPubkey, AllowPubkey, BanEvent, AllowEvent, etc.
``` ```
### Handlers (NIP-89) Requests are built by `make*` factories rather than an enum: `makeBanPubkey`, `makeAllowPubkey`, `makeBanEvent`, `makeAllowEvent`, `makeCreateRole`/`makeEditRole`/`makeDeleteRole`, `makeAssignRole`/`makeUnassignRole`, `makeAssignMethod`/`makeUnassignMethod`, `makeCreateClaim`/`makeDeleteClaim`/`makeListClaims`, `makeChangeRelayName`/`Description`/`Icon`, `makeAllowKind`/`makeDisallowKind`, `makeBlockIp`/`makeUnblockIp`, `makeSignEvent`, `makeSupportedMethods`, and the matching `makeList*` readers.
```typescript `ManagementApi` is a client class that pairs a relay url with a `ManagementSign` function so you don't have to build the NIP-98 auth event per call. `app.use(RelayManagement).forUrl(url)` returns one bound to the app's user.
readHandlers(event: TrustedEvent): Handler[]
getHandlerKey(handler: Handler): string // "kind:address" format
getHandlerAddress(event: TrustedEvent): string | undefined
displayHandler(handler?: Handler, fallback?: string): string
```
### Links ### Links
@ -439,7 +616,7 @@ for (const event of storedEvents) {
event[verifiedSymbol] = true event[verifiedSymbol] = true
} }
repository.load(storedEvents) app.repository.load(storedEvents)
``` ```
Only do this for events you persisted yourself after they were validated. Never set Only do this for events you persisted yourself after they were validated. Never set
@ -448,14 +625,25 @@ Only do this for events you persisted yourself after they were validated. Never
### Working with tags ### Working with tags
```typescript ```typescript
import {tagValue, tagValues, hexTags, relayTags, tagSpec, topicTags} from '@welshman/util' import {
tagSpec,
hexTags,
topicTags,
relayTags,
tagValue,
tagValues,
} from '@welshman/util'
// Specs come first, then the tags array. The spec says which keys to match and how to // A selector takes a spec FIRST, then the tags array
// validate the value, so malformed tags are skipped rather than silently returned. const title = tagValue(tagSpec('title'), event.tags) // string | undefined
const title = tagValue(tagSpec('title'), event.tags) // string | undefined const urls = tagValues(tagSpec('r'), event.tags) // string[]
const urls = tagValues(relayTags('r'), event.tags) // string[], valid relay urls only
const ids = tagValues(hexTags(['e', 'a']), event.tags) // string[], 32-byte hex only // Multiple keys at once
const topics = tagValues(topicTags('t'), event.tags) // string[], normalized const ids = tagValues(tagSpec(['e', 'a']), event.tags) // string[]
const mentions = tagValues(hexTags('p'), event.tags) // string[]
const topics = tagValues(topicTags('t'), event.tags) // string[] ("#x" -> "x")
const relays = tagValues(relayTags(['r', 'relay']), event.tags)
``` ```
### Matching and building filters ### Matching and building filters
@ -564,9 +752,9 @@ await fetch('https://api.example.com/upload', {
- **`@welshman/net`** — uses `TrustedEvent`, `Filter`, `SignedEvent` from this package as the wire types for relay connections and subscriptions. - **`@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/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/domain and resolves this package's `RelaySelection` DSL through `app.use(Router)`. - **`@welshman/app`** — high-level application layer; composes net/store/domain and uses the lightning helpers from this package (profile/list/handler/room helpers now live in `@welshman/domain`).
- **`@welshman/domain`** — builds its typed readers on the tag specs (`tagValue`, `hexTags`, `addressTags`) defined here. - **`@welshman/app`'s `Router` plugin** — dereferences the `RelaySelection` DSL defined here, and injects its `Resolver` into every `@welshman/domain` kind.
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here, and supplies the encryption used when writing encrypted list kinds. - **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here; signers also provide the `nip44` encrypt/decrypt functions used by `@welshman/domain` list writers to encrypt private (NIP-44) tags.
--- ---
@ -576,16 +764,23 @@ await fetch('https://api.example.com/upload', {
- **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. - **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.
- **`app.use(Zappers).validateZapReceipt` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. For a reactive list of a parent's valid zaps use `validZapReceipts(receipts, parent)`, which re-validates as each recipient's zapper loads.
- **`validateZapReceipt` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. For a reactive list of a parent's valid zaps, use `app.use(Zappers).validZapReceipts(receipts, parent)`, which re-validates as each recipient's zapper loads.
- **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else. - **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else.
- **`normalizeTopic` is not exported.** `Topics.ts` isn't re-exported from the index; use `topicTags("t")` to get normalized topic values off an event's tags.
- **`normalizeRelayUrl` vs `displayRelayUrl`**: Use `normalizeRelayUrl` before storing or comparing relay URLs. Use `displayRelayUrl` only for human-readable display (strips protocol/trailing slash). - **`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. - **`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 `[]`. - **Tag selector argument order**: the spec comes **first**, the tags array **second** — `tagValue(tagSpec('title'), event.tags)`, `tagValues(hexTags('p'), event.tags)`. 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`. - **`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`.
---
## Related skills
- **`welshman-app`** (welshman-app skill) — the `Router` plugin that dereferences the routing DSL above, plus `RelayStats.getQuality` for the scoring input.
- **`@welshman/domain`** (welshman-domain skill) — Profiles, lists, handlers, rooms, and event routing moved out of `@welshman/util` and now live here. The old free functions (`readProfile`/`makeProfile`, `readList`/`makeList`, `PublishedProfile`/`PublishedList`, `Encryptable`, the handler/room helpers, …) were replaced by configurable `KindFactory` bundles: `Kind.configure(context).reader(event)` returns an async Reader that decodes the event, and `.writer(reader?)` builds/edits one. Private (NIP-44) list tags are handled inside the list Reader/Writer, so `Encryptable`/`DecryptedEvent` no longer exist.

View file

@ -11,14 +11,14 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http
| Package | Description | | Package | Description |
|---|---| |---|---|
| `@welshman/util` | Core nostr types, event helpers, filters, tag specs, NIPs, and the `RelaySelection` routing DSL | | `@welshman/util` | Core nostr types, event helpers, filters, NIP implementations, and the relay-selection routing DSL |
| `@welshman/lib` | General-purpose utilities: LRU cache, event emitter, deferred promises, task queue | | `@welshman/lib` | General-purpose utilities: LRU cache, event emitter, deferred promises, task queue |
| `@welshman/net` | Relay connections, request/publish lifecycle, and auth handling | | `@welshman/net` | Relay connections, request/publish lifecycle, auth, and the `Repository`/`Tracker`/`WrapManager` stores |
| `@welshman/domain` | A typed Reader/Writer pair per event kind, so you never hand-parse tags | | `@welshman/store` | Svelte store primitives over a `Repository` — live event and domain-object collections, cached loaders, persistence |
| `@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/signer` | Signing and login methods: NIP-01 (privkey), NIP-07 (extension), NIP-46 (bunker), NIP-55 (app), NIP-59 (gift wrap) |
| `@welshman/domain` | Typed Reader/Writer classes per event kind (profiles, notes, lists, rooms, relay management) that parse events, build templates, and emit relay routing |
| `@welshman/feeds` | Dynamic feed construction, filtering, and composition | | `@welshman/feeds` | Dynamic feed construction, filtering, and composition |
| `@welshman/app` | The `App` instance and its plugin registry, composing net, store, domain, signer, and feeds into a full application framework | | `@welshman/app` | Instance-based application framework: an `App` composes net, store, signer, feeds, and domain, exposing data modules via `app.use(...)` |
| `@welshman/content` | Parser and renderer for nostr note content (links, mentions, media, custom formatting) | | `@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 | | `@welshman/editor` | Batteries-included Svelte rich-text editor component with mention and embed support |
@ -27,43 +27,28 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http
Packages are layered so lower-level ones have no welshman dependencies: Packages are layered so lower-level ones have no welshman dependencies:
- **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util` - **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util`
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/store`, `@welshman/signer`, `@welshman/domain` - **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/store`, `@welshman/signer`
- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/app` - **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/domain`
- **Application** (composes everything above): `@welshman/app`
- **UI-focused** (largely independent, UI rendering concerns): `@welshman/content`, `@welshman/editor` - **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-domain`). For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-domain`, `welshman-signer`).
## The App instance Relay selection spans two packages. The `RelaySelection` DSL, `Resolver` and `RelayScenario` are in `@welshman/util` (`welshman-util` skill); the `Router` plugin that dereferences them is in `@welshman/app` (`welshman-app` skill). There is no `@welshman/router` package.
Everything in the framework hangs off one `App`. An app owns the primitives a single identity ## Getting started
needs — repository, socket pool, tracker, wrap manager — so data never bleeds across sessions.
```typescript Install only what you need:
import {createApp, Network, Profiles, User} from "@welshman/app"
// `createApp` = `new App` plus the default policies (ingest, relay stats, gift-wrap unwrapping) ```bash
const app = createApp({ # Full application framework (includes app, net, store, signer, feeds, domain)
user: await User.fromSigner(signer), // omit for a signed-out app npm i @welshman/app
config: {
getDefaultRelays: () => ["wss://relay.example.com"],
getIndexerRelays: () => ["wss://indexer.example.com"],
},
})
app.use(Profiles).load(pubkey) // plugins are per-app singletons, constructed on demand # Or assemble manually for more control
app.use(Network).load({relays, filters}) npm i @welshman/util @welshman/net @welshman/signer
``` ```
Three rules follow from this design: 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.
1. **`app.use(Plugin)` is memoized and cheap** — call it inline rather than caching the result.
2. **An app is scoped to one identity.** Logging in means building a *new* app and calling
`cleanup()` on the old one, not attaching a user to the existing one.
3. **Side effects live in policies**, not in the data classes. An `AppPolicy` is
`(app) => Unsubscriber`, applied once at construction and torn down by `cleanup()`.
Svelte apps typically wrap `app` in a store so plugin reads re-subscribe when login swaps the
instance. That binding layer is app-specific and deliberately not part of welshman.
## Key nostr concepts ## Key nostr concepts
@ -79,67 +64,72 @@ instance. That binding layer is app-specific and deliberately not part of welshm
| Goal | Package(s) to use | | Goal | Package(s) to use |
|---|---| |---|---|
| Fetch notes from relays | `app.use(Network)`, or `@welshman/net` directly for low-level control | | Fetch notes from relays | `@welshman/net` (low-level) or `@welshman/app` (high-level) |
| Select which relays to use | `RelaySelection` helpers in `@welshman/util` + `app.use(Router)` | | Compose typed events (notes, profiles, lists) | `@welshman/domain` |
| Read or write a specific kind | `@welshman/domain` via `app.use(Domain)` | | Select which relays to read from / publish to | `@welshman/util` (routing DSL) + `@welshman/app` (Router plugin) |
| Sign and publish events | `@welshman/signer` + `Command` from `@welshman/app` | | Sign and publish events | `@welshman/domain` + `@welshman/app`, or `@welshman/signer` + `@welshman/net` |
| Build a feed UI | `@welshman/feeds` + `app.use(Feeds)` | | Build a feed UI | `@welshman/feeds` + `@welshman/app` |
| Parse note text and media | `@welshman/content` | | Parse note text and media | `@welshman/content` |
| Embed a composer / editor | `@welshman/editor` | | Embed a composer / editor | `@welshman/editor` |
| Cache nostr events client-side | `@welshman/store` + `app.repository` | | Cache nostr events client-side | `@welshman/net` (`Repository`) + `@welshman/store` (reactive views over it) |
| Core event/filter/tag utilities | `@welshman/util` | | Core event/filter utilities | `@welshman/util` |
| Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` | | Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` |
## App example ### App Example
```typescript ```typescript
import {createApp, Domain, Profiles, User} from "@welshman/app" import { Nip07Signer } from "@welshman/signer"
import {Note} from "@welshman/domain" import { Note } from "@welshman/domain"
import {Nip07Signer} from "@welshman/signer" import { createApp, User, Domain, Profiles } from "@welshman/app"
// 1. Create an app instance. Each App owns its own repository, socket pool,
// tracker, and (optional) signing user, so data never leaks across identities.
// Pass the user at construction rather than assigning app.user afterwards.
const user = await User.fromSigner(new Nip07Signer())
// 1. Build an app for the signed-in user
const signer = new Nip07Signer()
const app = createApp({ const app = createApp({
user: await User.fromSigner(signer), user,
config: { config: {
getDefaultRelays: () => ["wss://relay.example.com"], getDefaultRelays: () => ["wss://relay.example.com", "wss://relay2.example.com"],
getIndexerRelays: () => ["wss://indexer.example.com"], getIndexerRelays: () => ["wss://indexer.example.com"],
}, },
}) })
// 2. Read the user's profile (loads from their write relays if not cached) // 2. Hydrate the repository from storage and flush changes back to it.
const profile = await app.use(Profiles).load(app.user!.pubkey) // See the welshman-net skill for repository.load() and the "update" listener.
console.log("Hello,", profile?.display()) // 3. Load the user's profile through the Profiles data module
// (triggers a network fetch via the outbox model if not cached)
const profile = await app.use(Profiles).forceLoad(user.pubkey)
if (profile) console.log("Hello,", profile.display())
// 3. Publish a note — build a writer, wrap it in a command, publish it // ...or subscribe reactively:
app.use(Profiles).one(user.pubkey).subscribe($profile => {
if ($profile) console.log("Profile:", $profile.display())
})
// 4. Compose and publish a note. Domain builds the event and resolves relays;
// the returned Command sends it through the publish pipeline.
const writer = app.use(Domain).writer(Note).setContent("Hello, Nostr!") const writer = app.use(Domain).writer(Note).setContent("Hello, Nostr!")
const command = await app.use(Domain).command(writer) const command = await app.use(Domain).command(writer)
await command.publish().waitForError() await command.publish()
``` ```
Publishing goes through a `Command`, which owns the rendered event and its resolved relays: ### Lower-level Example
`command.publish()`, `.publishToRelays(urls)`, or `.publishAsRelay(url)`. Plugin mutators
(`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`) already return a `Command`,
so `.then(publish)` is usually all you need.
## Lower-level example
The net layer takes an explicit context, so it can be used without an `App` at all.
```typescript ```typescript
import {AbstractAdapter, isClientEvent, publish, request} from "@welshman/net" import { AbstractAdapter, ClientMessage, isClientEvent, publish, request } from '@welshman/net'
import type {ClientMessage, NetContext} from "@welshman/net" import type { NetContext } from '@welshman/net'
import {call, sleep} from "@welshman/lib" import { call, sleep } from '@welshman/lib'
import {Nip01Signer} from "@welshman/signer" import { Nip01Signer } from '@welshman/signer'
import {makeEvent, NOTE} from "@welshman/util" import { makeEvent, NOTE } from '@welshman/util'
const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */) const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */) const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
const RELAY_URL = "bogus.relay" const RELAY_URL = "bogus.relay"
// An adapter for our relay url which just prints the content // Create an adapter for our relay url which just prints the content
export class PrintAdapter extends AbstractAdapter { export class PrintAdapter extends AbstractAdapter {
get sockets() { return [] } get sockets() { return [] }
get urls() { return [] } get urls() { return [] }
@ -151,29 +141,38 @@ export class PrintAdapter extends AbstractAdapter {
} }
} }
// Context is passed explicitly. An `App` supplies its own via `app.netContext`; here we build one. // A net context that routes our relay url to the custom adapter. Context is
// passed per call now — there is no module-level singleton.
const context: NetContext = { const context: NetContext = {
getAdapter: (url: string) => (url === RELAY_URL ? new PrintAdapter() : undefined), getAdapter: (url: string) => {
if (url === RELAY_URL) {
return new PrintAdapter()
}
},
} }
// Loop, sending off pings every so often
call(async () => { call(async () => {
while (true) { while (true) {
await sleep(1000) await sleep(1000)
const ping = await pingSigner.sign(makeEvent(NOTE, {content: "ping"})) const ping = await pingSigner.sign(
makeEvent(NOTE, {content: 'ping'})
)
await publish({event: ping, relays: [RELAY_URL], context}) await publish({event: ping, relays: [RELAY_URL], context})
} }
}) })
// Meanwhile, listen for pings and quote-note with a pong
call(async () => { call(async () => {
request({ request({
relays: [RELAY_URL], relays: [RELAY_URL],
filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}],
context, context,
filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}],
onEvent: async (ping, url) => { onEvent: async (ping, url) => {
const pong = await pongSigner.sign( const pong = await pongSigner.sign(
makeEvent(NOTE, {content: "pong", tags: [["q", ping.id, RELAY_URL, ping.pubkey]]}), makeEvent(NOTE, {content: 'pong', tags: [["q", ping.id, RELAY_URL, ping.pubkey]]})
) )
await publish({event: pong, relays: [RELAY_URL], context}) await publish({event: pong, relays: [RELAY_URL], context})

6
.env
View file

@ -3,14 +3,16 @@ VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
VITE_DEFAULT_SPACES=https://support.flotilla.social/ VITE_DEFAULT_SPACES=https://support.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_ABOUT=https://flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/logo.png VITE_PLATFORM_LOGO=static/logo.png
VITE_PLATFORM_RELAYS= VITE_PLATFORM_RELAYS=
VITE_PLATFORM_LOGEE=be523f2d2255fa96b281233ba9df60d63ef74f874bea96651edd4e1f79785703
VITE_PLATFORM_ACCENT="#7161FF" VITE_PLATFORM_ACCENT="#7161FF"
VITE_THEME="clay" VITE_THEME="clay"
VITE_PLATFORM_DESCRIPTION="The chat app built for self-hosted communities." VITE_PLATFORM_DESCRIPTION="Digital communities for free people."
VITE_PUSH_SERVER=https://nps.flotilla.social/ VITE_PUSH_SERVER=https://nps.flotilla.social/
VITE_PUSH_BRIDGE=wss://npb.coracle.social/ VITE_PUSH_BRIDGE=wss://npb.coracle.social/
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
@ -18,7 +20,7 @@ VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io 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=ephemeral.snowflare.cc,bucket.coracle.social
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
VITE_GLITCHTIP_API_KEY= VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN= GLITCHTIP_AUTH_TOKEN=

View file

@ -8,7 +8,6 @@ build
*.ttf *.ttf
gradlew* gradlew*
_app _app
release
ios/DerivedData/ ios/DerivedData/
ios/App/Pods/ ios/App/Pods/
android/capacitor-cordova-android-plugins android/capacitor-cordova-android-plugins

11
.fragua/config.yaml Normal file
View file

@ -0,0 +1,11 @@
# fragua project config — project-specific knobs only.
# Generic preferences live in ~/.fragua/config.yaml.
# Stable project identity. Committed so every clone shares it and runs
# stay attributable across machines — do not change it.
id: 01a03577-18d7-7f10-bcee-144a07a22c20
name: flotilla
tier: fork
# Uncomment if the project needs a per-worktree bootstrap command:
# bootstrap: "bun install --frozen-lockfile"

84
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,84 @@
name: CI
on:
push:
branches: [dev]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-check-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Install dependencies
run: corepack enable && pnpm i --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Check
run: pnpm run check
- name: Check desktop TypeScript
run: |
npm ci --prefix electron --ignore-scripts
npm --prefix electron run build
- name: Build
if: github.event_name != 'pull_request'
run: pnpm run build
# On a pull request the same build doubles as the preview build: same
# command, just pointed at the preview origin and with production
# analytics stripped out. The preview server downloads the artifact
# produced below; it never checks out or builds a pull request itself,
# so contributor code only ever executes here, in the runner.
#
# The hostname is spelled out rather than templated so that what gets
# deployed is readable from this file alone. It is one of two places to
# change if previews move (the other is PREVIEW_DOMAIN on the server).
- name: Build preview
if: github.event_name == 'pull_request'
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
VITE_PLATFORM_URL: https://pr-${{ github.event.number }}.preview.flowboat.lol
VITE_PLATFORM_NAME: "Flotilla PR #${{ github.event.number }}"
VITE_PLATFORM_DESCRIPTION: "Preview build of coracle/flotilla PR #${{ github.event.number }}. Not the real Flotilla."
run: |
export VITE_BUILD_HASH="${HEAD_SHA:0:8}"
pnpm run build
# src/app.html carries a plausible tag pointed at production analytics,
# whatever its data-domain says; a preview must not report into them.
perl -0777 -i -pe \
's|<script\s+defer\s+data-domain="[^"]*"\s+src="https://plausible\.coracle\.social/[^"]*"\s*></script>||gs' \
build/index.html
# v3, not v4, on purpose: actions/upload-artifact@v4 fails on this Gitea
# with "GHESNotSupportedError". @actions/artifact v2 decides an instance
# is GHES purely from GITHUB_SERVER_URL's hostname not being github.com,
# which is always true here, and the runner overwrites any step-level
# override of that variable. v3 works, and the preview server fetches
# the result through the run's artifact download route.
# (Verified on gitea.coracle.social, act_runner v0.6.1, 2026-09-01.)
- name: Upload preview
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v3
with:
name: preview
path: build
retention-days: 7
if-no-files-found: error

View file

@ -14,8 +14,30 @@ env:
IMAGE_NAME: coracle/flotilla IMAGE_NAME: coracle/flotilla
jobs: jobs:
lint-check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Install dependencies
run: corepack enable && pnpm i --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Check
run: pnpm run check
build-and-push-image: build-and-push-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: lint-check
permissions: permissions:
contents: read contents: read
packages: write packages: write

View file

@ -0,0 +1,34 @@
name: Mirror to GitHub
on:
push:
branches: [master]
tags: ["*"]
workflow_dispatch:
# Serialized rather than cancel-in-progress: aborting a push halfway leaves the
# mirror behind whatever triggered the run, and nothing would retry it.
concurrency:
group: ${{ github.workflow }}
jobs:
push-to-github:
runs-on: ubuntu-latest
if: github.repository == 'coracle/flotilla'
steps:
- name: Push master and tags to GitHub
env:
GH_MIRROR_TOKEN: ${{ secrets.GH_MIRROR_TOKEN }}
run: |
git clone --bare "${{ github.server_url }}/${{ github.repository }}.git" repo.git
cd repo.git
# Explicit refspecs rather than --mirror, which pushes everything
# under refs/ and would fail the whole push: gitea publishes 260-odd
# refs/pull/* refs, and github rejects that namespace outright
# ("deny updating a hidden ref"). Force so gitea always wins.
git push --force --prune \
"https://x-access-token:$GH_MIRROR_TOKEN@github.com/coracle-social/flotilla.git" \
'refs/heads/master:refs/heads/master' \
'refs/tags/*:refs/tags/*'

22
.gitignore vendored
View file

@ -12,6 +12,7 @@ vite.config.ts.timestamp-*
/playwright/.cache/ /playwright/.cache/
# Generated assets # Generated assets
static/desktop-logo.png
static/favicon.ico static/favicon.ico
static/pwa-64x64.png static/pwa-64x64.png
static/pwa-192x192.png static/pwa-192x192.png
@ -20,6 +21,11 @@ static/apple-touch-icon-180x180.png
static/maskable-icon-512x512.png static/maskable-icon-512x512.png
src/assets/icons/*.webp src/assets/icons/*.webp
manifest.webmanifest manifest.webmanifest
android/app/src/main/res/drawable*/splash.png
android/app/src/main/res/drawable-*/ic_stat_notify.png
android/app/src/main/res/mipmap-*/ic_launcher*
ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png
ios/App/App/Assets.xcassets/Splash.imageset/Default@*.png
# Capacitor # Capacitor
ios/App/public/ ios/App/public/
@ -28,6 +34,9 @@ ios/App/Podfile.lock
ios/DerivedData/ ios/DerivedData/
android/app/src/main/assets/public/ android/app/src/main/assets/public/
# F-Droid preparation output
.fdroid/
# Web/JavaScript # Web/JavaScript
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
@ -67,7 +76,6 @@ local.properties
proguard/ proguard/
google-services.json google-services.json
GoogleService-Info.plist GoogleService-Info.plist
ic_stat_notify.png
# IDEs and editors # IDEs and editors
.roo .roo
@ -81,3 +89,15 @@ CLAUDE.md
.DS_Store .DS_Store
Thumbs.db Thumbs.db
package-lock.json package-lock.json
!electron/package-lock.json
# fragua runtime — never commit these
.fragua/runs/
.fragua/worktrees/
.fragua/blobs/
.fragua/fragua.db*
.fragua/daemon/
# fragua — always commit these (negative patterns for clarity)
!.fragua/config.yaml
!.fragua/workflows/

View file

@ -1,7 +1,7 @@
pnpm run lint pnpm run lint
pnpm run check pnpm run check
if [[ ! -z $(cat package.json | grep 'link:') ]]; then if [[ ! -z $(grep 'link:' package.json pnpm-workspace.yaml) ]]; then
echo "Some packages are linked to local files!" echo "Some packages are linked to local files!"
exit 1 exit 1
fi fi

View file

@ -126,13 +126,16 @@ callbacks and hot paths.
**CRITICAL Code Style Guidelines:** **CRITICAL Code Style Guidelines:**
- **No `null`** - only use `undefined` - **No `null`** - only use `undefined`
- Never hard-code the app's name. The brand is a build-time `VITE_PLATFORM_*` variable, so user-facing copy interpolates `PLATFORM_NAME` from `@app/env`, and `PLATFORM_URL`, `PLATFORM_LOGO`, `PLATFORM_ABOUT` for the rest of it. Each is set by the deployment, so don't write a `"Flotilla"` fallback behind one either.
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components - Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
- TailwindCSS styling with css components customized by theme. See lib/components for examples. - TailwindCSS styling with css components customized by theme. See lib/components for examples.
- A component class goes in `@layer components`, in its own file under `lib/components`. An unlayered rule beats a layered one whatever the specificity, so a class outside the layer can never be overridden by a utility in markup. The third-party overrides in `base.css` stay unlayered. The library defaults they beat are unlayered too.
- Comments, naming, conditionals and single-use indirection are covered by the Cleanup Pass above. - Comments, naming, conditionals and single-use indirection are covered by the Cleanup Pass above.
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect. - Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax. - When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss - When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates - Do not define svelte event handlers inline, instead name them and put them in the script section of templates
- Size a component off its own container, not the screen: put `@container` on the element and use `@md:`/`@2xl:`, rather than `sm:`/`md:`. The navs are `hidden md:flex` and take 326px out of the page at `md`, so a `md:` breakpoint inside a page turns a wide layout on at the exact width where the page column gets narrower. Screen breakpoints are for chrome that appears or disappears with the viewport.
- Write a `{#if}`/`{:else if}` chain rather than hoisting display strings into a lookup `Record` in the script section. - Write a `{#if}`/`{:else if}` chain rather than hoisting display strings into a lookup `Record` in the script section.
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly. - Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
- To read a tag, prefer the domain reader's getter (`note.content()`, `roomMeta.name()`) over touching tags at all. Where there's no reader, use `tagValue(spec, tags)` / `tagValues(spec, tags)` from `@welshman/util` rather than reaching into the tag array yourself — that means no `tags.find(nthEq(0, name))?.[1]`. Build the spec with the narrowest helper that fits: `hexTags("p")`, `relayTags(["r", "relay"])`, `addressTags("a")`, `kindTags("k")`, `topicTags("t")`, or plain `tagSpec("h")` when the value needs no validation. Reserve `nthEq` for cases with no spec equivalent, such as `partition(nthEq(0, "imeta"), tags)`. - To read a tag, prefer the domain reader's getter (`note.content()`, `roomMeta.name()`) over touching tags at all. Where there's no reader, use `tagValue(spec, tags)` / `tagValues(spec, tags)` from `@welshman/util` rather than reaching into the tag array yourself — that means no `tags.find(nthEq(0, name))?.[1]`. Build the spec with the narrowest helper that fits: `hexTags("p")`, `relayTags(["r", "relay"])`, `addressTags("a")`, `kindTags("k")`, `topicTags("t")`, or plain `tagSpec("h")` when the value needs no validation. Reserve `nthEq` for cases with no spec equivalent, such as `partition(nthEq(0, "imeta"), tags)`.
@ -170,6 +173,9 @@ callbacks and hot paths.
2. Use `+page.svelte` for page component 2. Use `+page.svelte` for page component
3. Use `+layout.svelte` for shared layouts 3. Use `+layout.svelte` for shared layouts
4. Top-level sync logic goes in root `+layout.svelte` 4. Top-level sync logic goes in root `+layout.svelte`
5. Read params from the `params` prop, typed with `PageProps`/`LayoutProps` from the route's own
`./$types`. SvelteKit passes them down in the same update as the component swap, while the
`page` store is set a tick later. Only code outside `src/routes/` reads `$page.params`.
### Loading Data from Network ### Loading Data from Network
@ -181,7 +187,7 @@ callbacks and hot paths.
1. Build a writer: `app.use(Domain).writer(Kind, reader?)`, then chain its setters 1. Build a writer: `app.use(Domain).writer(Kind, reader?)`, then chain its setters
2. Wrap it: `const command = await app.use(Domain).command(writer)` 2. Wrap it: `const command = await app.use(Domain).command(writer)`
3. Publish it: `command.publish()`, `.publishToRelays(urls)`, or `.publishAsRelay(url)` 3. Publish it: `command.publish()` or `.publishToRelays(urls)`
4. Display thunk status to user (for cancel/error handling) 4. Display thunk status to user (for cancel/error handling)
Plugin mutators (`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`, …) already Plugin mutators (`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`, …) already
@ -192,6 +198,12 @@ return a `Command`, so `.then(publish)` is usually all you need.
- Import from `app/modal.ts` or `app/toast.ts` - Import from `app/modal.ts` or `app/toast.ts`
- Pass component objects with parameters - Pass component objects with parameters
- Use `$state.snapshot` if calling component might unmount - Use `$state.snapshot` if calling component might unmount
- Navigate with `navigate` from `app/modal.ts` rather than `goto` — an open modal owns a history
entry, and a navigation that drops the modal gives that entry back before it pushes its own.
A plain `<a>` inside a modal goes the same way, through `ModalContainer`'s `beforeNavigate`
- `navigate` is async and gives those entries back before it goes anywhere, so the page store
notifies again at the page being left — state cleared before the call has to survive that
- Pass `keepModal` to `navigate` to change the page under a modal and leave it open
## Development Workflow ## Development Workflow
@ -206,7 +218,7 @@ pnpm run check # Type check
**Welshman Development:** **Welshman Development:**
- Clone welshman to parent directory - Clone welshman to parent directory
- Use `./link_deps` script to link local welshman packages - Use `./scripts/link-deps.mjs` to link local welshman packages
- Avoid committing `pnpm.overrides` changes - Avoid committing `pnpm.overrides` changes
**Git Workflow:** **Git Workflow:**
@ -223,7 +235,7 @@ See `.env.template` for all options.
**Capacitor Integration:** **Capacitor Integration:**
- Android: Full support, APK builds via `pnpm run release:android` - Android: Full support, release builds via `pnpm release` (see README for the release flow)
- iOS: Full support (zaps disabled due to App Store policy) - iOS: Full support (zaps disabled due to App Store policy)
- PWA: Progressive Web App with service worker - PWA: Progressive Web App with service worker

View file

@ -1,5 +1,47 @@
# Changelog # Changelog
# 1.11.0
* Replace the home page with a dashboard of unread conversations, space activity and a network feed
* Redesign the calendar with month, week and agenda views, and add RSVPs
* Redesign classifieds as a browsable marketplace
* Rework threads as one continuous list with unread indicators on every board
* Add slash commands
* Add read out loud and voice dictation
* Add a desktop app with Linux, Windows and macOS packaging
* Add relay data import and export to the hosting panel
* Add a mute setting for rooms
* Show NIP-38 statuses and a pinned note on profiles
* Rework search dialogs
* Improve link previews, falling back to an inline link when one fails
* Persist direct messages so history survives relay retention
* Add an image by upload or url from one dialog, and play audio attachments inline
* Let space icons be dragged to reorder in the sidebar
* Open the space menu in a drawer instead of navigating away from the room
* Fix push notification delivery, taps and badge counts
* Update welshman library to 0.10.9
* Performance improvements to feeds, room chat and reactions
* Add an end-to-end test suite covering the user story catalog
# 1.10.0
* There is no 1.10 version 👻
# 1.9.1
* Re-work how claims work under the hood
* Fix storage and synchronization bugs
* Update welshman library to 0.9.0
* And long-form articles
* Fix quote loading, rendering, and comments
* Add "create thread" from chat message
* Fix voice chat bugs
* Fix notification bugs
* Improve video tiling and chrome
* Add ability to send logs to developer
* Add theme selector
* Add native sharing registration
# 1.9.0 # 1.9.0
* Fix nav safe are inset, qr code reactivity * Fix nav safe are inset, qr code reactivity

View file

@ -4,7 +4,7 @@
# docker run -p 3000:3000 flotilla # docker run -p 3000:3000 flotilla
# #
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build. # 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. # A .env in the build context is picked up by scripts/build.sh for branding config.
# https://pnpm.io/docker#example-3-build-on-cicd # https://pnpm.io/docker#example-3-build-on-cicd
FROM node:24-slim AS builder FROM node:24-slim AS builder

232
README.md
View file

@ -1,12 +1,34 @@
# Flotilla <p align="center">
<img src="static/banner.png" alt="Flotilla" width="640">
</p>
A discord-like nostr client based on the idea of "relays as groups". A discord-like nostr client based on the idea of "relays as groups". Supports NIP 29 groups, chat, DMs, threads, calendars, classifieds, zap goals, articles, microblogging, and cross-posting between different contexts.
If you would like to be interoperable with Flotilla, please check out this guide: https://habla.news/u/hodlbod@coracle.social/1741286140797 ## Install
- **Web** — [app.flotilla.social](https://app.flotilla.social), installable as a PWA
- **Android** — [Google Play](https://play.google.com/store/apps/details?id=social.flotilla)
- **Android APK** — [releases](https://gitea.coracle.social/coracle/flotilla/releases), see [Releasing](#releasing)
- **iOS** — [App Store](https://apps.apple.com/us/app/flotilla-chat/id6741344107)
- **Your own server** — see [Deployment](#deployment)
Hosted spaces are available at [flotilla.social](https://flotilla.social).
## Features
- Spaces and rooms, threads, direct and group messages
- Voice and video calls
- Calendar events, long-form articles, and polls
- Reactions, custom emoji, zaps, link previews, and media sharing
- Invite codes, member management, bans, roles, and reports
- Push notifications, unread indicators, and per-room mute
If you would like to be interoperable with Flotilla, please check out
[this guide](https://habla.news/u/hodlbod@coracle.social/1741286140797).
## Environment ## Environment
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples): Create an `.env.local` file to override any of the values in `.env`:
**Platform branding** **Platform branding**
- `VITE_PLATFORM_URL` - The url where the app will be hosted - `VITE_PLATFORM_URL` - The url where the app will be hosted
@ -14,8 +36,11 @@ You can also optionally create an `.env.local` file and populate it with the fol
- `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_ACCENT` - A hex color for the app's accent color (used only for generated manifest, for more control create a custom theme file) - `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color (used only for generated manifest, for more control create a custom theme file)
- `VITE_PLATFORM_DESCRIPTION` - A description of the app - `VITE_PLATFORM_DESCRIPTION` - A description of the app
- `VITE_PLATFORM_ABOUT` - URL to your marketing or about page
- `VITE_PLATFORM_TERMS` - URL to your terms of service page - `VITE_PLATFORM_TERMS` - URL to your terms of service page
- `VITE_PLATFORM_PRIVACY` - URL to your privacy policy page - `VITE_PLATFORM_PRIVACY` - URL to your privacy policy page
- `VITE_PLATFORM_LOGEE` - A hex pubkey which will receive logs users send from their privacy settings
- `VITE_THEME` - The visual preset components are styled with: `clay`, `flat`, or `navy`
**Platform mode** **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. - `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.
@ -25,6 +50,7 @@ You can also optionally create an `.env.local` file and populate it with the fol
- `VITE_DEFAULT_SPACES` - A comma-separated list of relay urls that new users will be automatically joined to on signup. Each one may optionally include an invite code, delimited by `|`, e.g. `my.space.com|CODE`. - `VITE_DEFAULT_SPACES` - A comma-separated list of relay urls that new users will be automatically joined to on signup. Each one may optionally include an invite code, delimited by `|`, e.g. `my.space.com|CODE`.
- `VITE_DEFAULT_RELAYS` - A comma-separated list of relay urls used as default outbox/inbox relays - `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_MESSAGING_RELAYS` - A comma-separated list of relay urls used for encrypted direct messages
- `VITE_DEFAULT_SEARCH_RELAYS` - A comma-separated list of relay urls used for search
- `VITE_DEFAULT_BLOSSOM_SERVERS` - A comma-separated list of blossom server urls used for file uploads - `VITE_DEFAULT_BLOSSOM_SERVERS` - A comma-separated list of blossom server urls used for file uploads
**Infrastructure** **Infrastructure**
@ -36,17 +62,205 @@ You can also optionally create an `.env.local` file and populate it with the fol
- `VITE_POMADE_SIGNERS` - A comma-separated list of Pomade signer server URLs (3+ required to enable email signup) - `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 - `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 `scripts/build.sh` directly or to the built container.
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer. If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
## Development ## Development
See [CONTRIBUTING.md](CONTRIBUTING.md). ```sh
pnpm install
pnpm run dev
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions and workflow.
### Desktop development (Linux)
Desktop secrets are encrypted with the OS keyring or keychain. If protected storage is unavailable
or unreadable, the app warns, keeps secrets in memory until it closes, and leaves the saved file
untouched. Unlocking or configuring the keyring and restarting restores persistence. The app never
uses Linux's insecure `basic_text` backend.
**Use only disposable accounts with unsigned development packages.**
The Electron subproject installs separately, so ordinary web and mobile installs don't download
Electron:
```sh
pnpm install --frozen-lockfile
npm ci --prefix electron
pnpm run dev:desktop
```
`dev:desktop` starts Vite on `127.0.0.1` and runs the Capawesome Electron platform against it, with
the Capacitor plugin bridge and frontend HMR. No previous frontend build is needed. It uses the
existing Vite port (1847 by default) and fails if that port is occupied. The platform allows one
instance at a time, so quit any other desktop instance before switching modes. Restart the command
after editing Electron TypeScript, and quit Electron or press Ctrl+C to stop.
To build and run local production assets instead:
```sh
pnpm run build:desktop
pnpm run start:desktop
```
`build:desktop` builds the frontend without PWA/service-worker registration, synchronizes the
Electron platform, and compiles its TypeScript entrypoint. It uses the same branding environment as
the web build, and does not synchronize Android or iOS. `start:desktop` opens the last build without
Vite, so rerun `build:desktop` after frontend changes.
Run `pnpm run test:desktop` after building to check the Linux desktop window. On a headless Linux
runner, use `xvfb-run -a pnpm run test:desktop`, and install `libgtk-3-0t64`, which Playwright's
Chromium dependencies leave out. The test drops Chromium's sandbox when it runs as root. It does not
start a web dev server, does not run in CI, and verifies nothing about Windows or macOS.
### Desktop packaging
```sh
pnpm run package:desktop:linux
pnpm run package:desktop:windows
pnpm run package:desktop:macos
```
Each command rebuilds production assets, copies and updates Capacitor, compiles Electron, vendors
its runtime and plugins, and runs electron-builder without publishing. Packages land in
`electron/dist/`: a Linux x64 AppImage, a Windows x64 NSIS installer, and macOS x64 and arm64 DMGs
and ZIPs. The version comes from the root `package.json`, the product name from
`VITE_PLATFORM_NAME`, and the Capacitor app ID stays fixed. Vite's `.env.local` overrides apply and
explicit `VITE_*` environment values take precedence, so use production branding when building for
others. `VITE_PLATFORM_LOGO` can be a local or HTTPS image, and packaging resizes it to 1024×1024.
macOS packages are signed and notarized when `CSC_NAME` names a Developer ID Application certificate
in the keychain and `APPLE_API_KEY`, `APPLE_API_KEY_ID` and `APPLE_API_ISSUER` are set. All other
packages are unsigned.
Window and Dock icons use the branding image, including in local runs. On Linux, a packaged app
registers a hidden desktop entry and an icon under `XDG_DATA_HOME` (normally `~/.local/share`) so
Wayland docks can identify it. It leaves existing user and system launchers alone, never writes an
entry from a development run, and updates the entry when an update renames the AppImage. Windows
uses the executable's icon resources.
Linux packaging requires Linux. Windows packaging from Linux uses the pinned official
`electronuserland/builder` Wine image through Docker, mounting only a temporary copy of the prepared
Electron project. Native addons need a target-OS ABI rebuild and cannot use this cross-build path.
Native Windows preparation needs Bash on PATH, for example Git Bash. DMG creation requires macOS. On
Linux, `pnpm run package:desktop:macos --dir` prepares unsigned bundles for inspection only, and
verifies nothing about the macOS runtime, Gatekeeper, or signing.
To smoke-test a package, run as a non-root user with the sandbox enabled:
```sh
FLOTILLA_DESKTOP_EXECUTABLE="/absolute/path/to/application" pnpm run test:desktop
```
Use the AppImage or installed executable rather than the installer. This checks packaged metadata,
local assets, navigation, workers, and CSP using a disposable profile. Installation, reboot, and
uninstall need testing on the target OS.
### Desktop updates
Packaged apps check for an update once at startup, download it, and install it on a normal quit.
There is no update notification or updater UI, and development runs never check. Update errors are
logged and leave the app usable. The feed is Gitea's latest release, set in
`electron/electron-builder.config.mjs`. [Releasing](#releasing) covers how a release fills it.
electron-builder writes `app-update.yml` and the `latest*.yml` manifests beside the installers, even
with `--publish never`. macOS only installs updates to a signed app. Windows and Linux packages
update unsigned.
For local updater QA, use disposable copies with temporary A/B versions and an isolated user-data
directory. In those copies only, point the builder's generic feed at a local HTTP server. Package
both versions and serve B's generated metadata and artifacts. Run AppImage A, wait for B to
download, quit normally, and relaunch the installed AppImage to verify its version and saved state.
Check that preferences, protected secrets, tray controls, and notification activation survive.
Also exercise missing files, interrupted downloads, invalid checksums and versions, and an
already-current version. A failed update must not install. Never bypass integrity checks.
On macOS, build both architectures together and verify the single generated manifest references
both ZIPs with matching hashes. Do not hand-create or merge updater manifests.
## Releasing
`pnpm release` takes a tagged commit and ships it everywhere: the web bundle and native projects,
the signed APK on gitea and zapstore, the AAB on Google Play, the iOS build on App Store Connect,
and the desktop packages. It checks the tag, the changelog section, every credential and every
tool up front, and refuses to start if any is missing. It finishes with a list of what's left to do
by hand, such as rolling out on Play and submitting for review.
```sh
pnpm bump minor # or patch, major, or an explicit x.y.z
# write the CHANGELOG.md section for the new version
git commit -am "Bump version"
git tag 1.12.0 && git push origin dev 1.12.0
pnpm release
```
`pnpm release --check` runs those checks and reports the plan without building anything. Naming
steps runs a subset, such as `pnpm release ios` or `pnpm release apk gitea`. A step that fails stops the
run and prints the command to pick up from there.
| step | what it does |
| --- | --- |
| `web` | `scripts/build.sh`: web bundle, `cap sync`, generated icons and splash screens |
| `apk` | `assembleRelease` signed with the distribution key, renamed to the path in `zapstore.yaml` |
| `fdroid` | reruns F-Droid's own preparation and build against the tag in a throwaway worktree |
| `play` | `bundleRelease` signed with the upload key, uploaded to a Play track as a draft |
| `ios` | `cap build ios` to an archive and IPA, uploaded with `altool` |
| `desktop` | `package:desktop:*` for this OS: Linux and Windows from Linux, signed and notarized macOS from a Mac |
| `gitea` | creates a draft release from the changelog, attaches the APK, desktop packages and update manifests, and publishes it once every platform is there |
| `zapstore` | `zsp publish zapstore.yaml` |
Linux builds the Linux and Windows packages and a Mac builds the macOS ones, so a release takes a
run on each, both ending in `gitea`. Gitea's latest release is the desktop update feed, so the release stays a
draft, hidden from updaters and Obtainium, until it has the APK and all three `latest*.yml`
manifests. Each manifest is uploaded after the files it lists. A mobile-only release can't be
published, so package the desktop apps for every release.
Release notes come from the `CHANGELOG.md` section matching `package.json`'s version, so every
store shows the same text. The APK and zapstore share one artifact, whose path lives in
`zapstore.yaml`.
F-Droid builds from the tag on its own servers, so the `fdroid` step uploads nothing. It runs
[their preparation and build](fdroid/README.md) against the tag in a throwaway git worktree and
fails the release before anything is published if that build breaks. Preparation patches source
with exact-match replacements, so it breaks quietly when the files it rewrites change. The step is
slow because it installs and builds from scratch.
### Credentials
These go in `.env.local`, which is gitignored. `pnpm release --check` lists whichever are missing
along with how to get them.
| variable | what it is |
| --- | --- |
| `GITEA_TOKEN` | gitea access token with `write:repository`, from Settings → Applications |
| `ANDROID_KEYSTORE_PATH`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEYSTORE_ALIAS` | the key APKs outside the app stores are signed with; it can never change without breaking updates |
| `PLAY_KEYSTORE_PATH`, `PLAY_KEYSTORE_PASSWORD`, `PLAY_KEYSTORE_ALIAS` | the Play upload key |
| `PLAY_SERVICE_ACCOUNT` | path to a service account json with the Release manager role, from Play Console → Setup → API access |
| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_PATH` | App Store Connect API key with the App Manager role, from Users and Access → Integrations; also notarizes the macOS app |
| `CSC_NAME` | the Developer ID Application certificate in the keychain that signs the macOS app, without its prefix |
| `SIGN_WITH` | nostr key for zapstore: an nsec, a `bunker://` url, or `browser` |
Add `_ALIAS_PASSWORD` to either keystore prefix when the alias has its own password. `PLAY_TRACK`
(default `production`) and `PLAY_STATUS` (default `draft`) choose where a Play upload lands.
Keystores and API keys belong outside the repository; only their paths go in `.env.local`.
Without the keystore variables, Android Studio still builds the project and produces an unsigned
release build.
### Obtainium
[Obtainium](https://obtainium.imranr.dev/) installs and updates Android apps from their release
pages. Gitea and Forgejo share a release API, so it works against this repository:
- App source URL: `https://gitea.coracle.social/coracle/flotilla`
- Override source: `Forgejo (Codeberg)`
Obtainium reports the git tag as the version.
## Deployment ## Deployment
To run your own Flotilla, it's as simple as: To run your own Flotilla:
```sh ```sh
pnpm install pnpm install
@ -66,3 +280,7 @@ Alternatively, you can copy the build files into a directory of your choice and
mkdir ./mount mkdir ./mount
docker run -v ./mount:/app/mount gitea.coracle.social/coracle/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'
``` ```
## License
[MIT](LICENSE)

View file

@ -1,6 +1,10 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
// Release credentials come from the environment so they stay out of the repo and out of the
// process list. Without them the release build is unsigned, which is what Android Studio gets.
def releaseKeystore = System.getenv("ANDROID_KEYSTORE_PATH")
android { android {
namespace = "social.flotilla" namespace = "social.flotilla"
compileSdk = rootProject.ext.compileSdkVersion compileSdk = rootProject.ext.compileSdkVersion
@ -8,8 +12,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 50 versionCode 52
versionName "1.9.0" versionName "1.11.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.
@ -17,8 +21,21 @@ android {
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
} }
} }
signingConfigs {
release {
if (releaseKeystore) {
storeFile file(releaseKeystore)
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias System.getenv("ANDROID_KEYSTORE_ALIAS")
keyPassword System.getenv("ANDROID_KEYSTORE_ALIAS_PASSWORD")
}
}
}
buildTypes { buildTypes {
release { release {
if (releaseKeystore) {
signingConfig signingConfigs.release
}
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }

View file

@ -27,6 +27,21 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.flotilla.social" /> <data android:scheme="https" android:host="app.flotilla.social" />
</intent-filter> </intent-filter>
<!-- Puts Flotilla in the system share sheet. These intents carry their payload in
extras rather than a data uri, so Capacitor never emits appUrlOpen for them;
ShareIntentPlugin picks them up instead. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity> </activity>
<provider <provider

View file

@ -8,11 +8,13 @@ import android.os.Bundle;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import social.flotilla.notifications.AndroidPushFallbackPlugin; import social.flotilla.notifications.AndroidPushFallbackPlugin;
import social.flotilla.share.ShareIntentPlugin;
public class MainActivity extends BridgeActivity { public class MainActivity extends BridgeActivity {
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
registerPlugin(AndroidPushFallbackPlugin.class); registerPlugin(AndroidPushFallbackPlugin.class);
registerPlugin(ShareIntentPlugin.class);
createPushNotificationChannel(); createPushNotificationChannel();
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
} }

View file

@ -1,7 +1,9 @@
package social.flotilla.notifications package social.flotilla.notifications
import android.Manifest
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
@ -11,16 +13,22 @@ 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
import com.getcapacitor.PermissionState
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import com.getcapacitor.annotation.Permission
import com.getcapacitor.annotation.PermissionCallback
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@CapacitorPlugin(name = "AndroidPushFallback") @CapacitorPlugin(
name = "AndroidPushFallback",
permissions = [Permission(alias = "notifications", strings = [Manifest.permission.POST_NOTIFICATIONS])],
)
class AndroidPushFallbackPlugin : Plugin() { class AndroidPushFallbackPlugin : Plugin() {
companion object { companion object {
const val PREFS_NAME = "CapacitorStorage" const val PREFS_NAME = "CapacitorStorage"
@ -33,6 +41,21 @@ class AndroidPushFallbackPlugin : Plugin() {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
} }
@PluginMethod
fun requestNotificationPermission(call: PluginCall) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || getPermissionState("notifications") == PermissionState.GRANTED) {
call.resolve(JSObject().put("receive", "granted"))
} else {
requestPermissionForAlias("notifications", call, "permissionCallback")
}
}
@PermissionCallback
private fun permissionCallback(call: PluginCall) {
val receive = if (getPermissionState("notifications") == PermissionState.GRANTED) "granted" else "denied"
call.resolve(JSObject().put("receive", receive))
}
@PluginMethod @PluginMethod
fun syncState(call: PluginCall) { fun syncState(call: PluginCall) {
val state: JSObject? = call.getObject("state") val state: JSObject? = call.getObject("state")

View file

@ -18,7 +18,6 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import fr.acinq.secp256k1.Secp256k1
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -47,7 +46,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
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
private val SECP = Secp256k1.get() private val SECP = fallbackSecp256k1
} }
private val prefs: SharedPreferences = private val prefs: SharedPreferences =

View file

@ -0,0 +1,5 @@
package social.flotilla.notifications
import fr.acinq.secp256k1.Secp256k1
val fallbackSecp256k1: Secp256k1 = Secp256k1.get()

View file

@ -0,0 +1,53 @@
package social.flotilla.share
import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.content.IntentCompat
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
@CapacitorPlugin(name = "ShareIntent")
class ShareIntentPlugin : Plugin() {
// Capacitor runs the launch intent through here as well as intents delivered while the app is
// already running, so this is the only hook we need. Retaining the event covers a cold start,
// where the web view isn't listening yet.
override fun handleOnNewIntent(intent: Intent) {
if (intent.action == Intent.ACTION_SEND) {
val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
// Android hands the same intent back when the activity is recreated, so drop the payload
// once we've taken it to avoid re-opening the share dialog.
if (uri != null) {
intent.removeExtra(Intent.EXTRA_STREAM)
// Copying a shared video can take a while, and this runs on the main thread
bridge.execute { notifyListeners("shareReceived", copyToCache(uri), true) }
} else if (text != null) {
intent.removeExtra(Intent.EXTRA_TEXT)
notifyListeners("shareReceived", JSObject().put("text", text), true)
}
}
}
// A content uri is readable by us but meaningless to the web view, so hand over a copy that
// capacitor's file server can serve.
private fun copyToCache(uri: Uri): JSObject {
val resolver = context.contentResolver
val type = resolver.getType(uri)
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(type)
val file = File.createTempFile("shared", extension?.let { ".$it" }, context.cacheDir)
resolver.openInputStream(uri)?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
return JSObject()
.put("path", file.absolutePath)
.put("name", file.name)
.put("type", type)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,0A12,12 0,1 1,12 24A12,12 0,1 1,12 0Z" />
<group
android:scaleX="0.6666667"
android:scaleY="0.6666667"
android:translateX="4"
android:translateY="4">
<path
android:fillColor="@android:color/transparent"
android:pathData="M2 12.0002C2 7.2862 2 4.92918 3.46447 3.46471C4.92893 2.00024 7.28595 2.00024 12 2.00024C16.714 2.00024 19.0711 2.00024 20.5355 3.46471C22 4.92918 22 7.2862 22 12.0002C22 16.7143 22 19.0713 20.5355 20.5358C19.0711 22.0002 16.714 22.0002 12 22.0002C7.28595 22.0002 4.92893 22.0002 3.46447 20.5358C2 19.0713 2 16.7143 2 12.0002Z"
android:strokeColor="#FF202124"
android:strokeWidth="1.5" />
<path
android:fillColor="@android:color/transparent"
android:pathData="M2 13.0002H5.16026C6.06543 13.0002 6.51802 13.0002 6.91584 13.1832C7.31367 13.3662 7.60821 13.7098 8.19729 14.3971L8.80271 15.1034C9.39179 15.7907 9.68633 16.1343 10.0842 16.3173C10.482 16.5002 10.9346 16.5002 11.8397 16.5002H12.1603C13.0654 16.5002 13.518 16.5002 13.9158 16.3173C14.3137 16.1343 14.6082 15.7907 15.1973 15.1034L15.8027 14.3971C16.3918 13.7098 16.6863 13.3662 17.0842 13.1832C17.482 13.0002 17.9346 13.0002 18.8397 13.0002H22"
android:strokeColor="#FF202124"
android:strokeWidth="1.5"
android:strokeLineCap="round" />
</group>
</vector>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,0A12,12 0,1 1,12 24A12,12 0,1 1,12 0Z" />
<group
android:scaleX="0.6666667"
android:scaleY="0.6666667"
android:translateX="4"
android:translateY="4">
<path
android:fillColor="@android:color/transparent"
android:pathData="M2 12.0002C2 8.22901 2 6.34339 3.17157 5.17182C4.34315 4.00024 6.22876 4.00024 10 4.00024H14C17.7712 4.00024 19.6569 4.00024 20.8284 5.17182C22 6.34339 22 8.22901 22 12.0002C22 15.7715 22 17.6571 20.8284 18.8287C19.6569 20.0002 17.7712 20.0002 14 20.0002H10C6.22876 20.0002 4.34315 20.0002 3.17157 18.8287C2 17.6571 2 15.7715 2 12.0002Z"
android:strokeColor="#FF202124"
android:strokeWidth="1.5" />
<path
android:fillColor="@android:color/transparent"
android:pathData="M6 8.00024L8.1589 9.79932C9.99553 11.3299 10.9139 12.0951 12 12.0951C13.0861 12.0951 14.0045 11.3299 15.8411 9.79932L18 8.00024"
android:strokeColor="#FF202124"
android:strokeWidth="1.5"
android:strokeLineCap="round" />
</group>
</vector>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,0A12,12 0,1 1,12 24A12,12 0,1 1,12 0Z" />
<group
android:scaleX="0.6666667"
android:scaleY="0.6666667"
android:translateX="4"
android:translateY="4">
<path
android:fillColor="@android:color/transparent"
android:pathData="M2,11.5005a9.5,9.5 0,1 1,19 0a9.5,9.5 0,1 1,-19 0"
android:strokeColor="#FF202124"
android:strokeWidth="1.5" />
<path
android:fillColor="@android:color/transparent"
android:pathData="M18.5 18.5005L22 22.0005"
android:strokeColor="#FF202124"
android:strokeWidth="1.5"
android:strokeLineCap="round" />
</group>
</vector>

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,0A12,12 0,1 1,12 24A12,12 0,1 1,12 0Z" />
<group
android:scaleX="0.6666667"
android:scaleY="0.6666667"
android:translateX="4"
android:translateY="4">
<path
android:fillColor="@android:color/transparent"
android:pathData="M2.5 6.50049C2.5 4.61487 2.5 3.67206 3.08579 3.08627C3.67157 2.50049 4.61438 2.50049 6.5 2.50049C8.38562 2.50049 9.32843 2.50049 9.91421 3.08627C10.5 3.67206 10.5 4.61487 10.5 6.50049V17.5005C10.5 19.3861 10.5 20.3289 9.91421 20.9147C9.32843 21.5005 8.38562 21.5005 6.5 21.5005C4.61438 21.5005 3.67157 21.5005 3.08579 20.9147C2.5 20.3289 2.5 19.3861 2.5 17.5005V6.50049Z"
android:strokeColor="#FF202124"
android:strokeWidth="1.5" />
<path
android:fillColor="@android:color/transparent"
android:pathData="M13.5 15.5005C13.5 13.6149 13.5 12.6721 14.0858 12.0863C14.6716 11.5005 15.6144 11.5005 17.5 11.5005C19.3856 11.5005 20.3284 11.5005 20.9142 12.0863C21.5 12.6721 21.5 13.6149 21.5 15.5005V17.5005C21.5 19.3861 21.5 20.3289 20.9142 20.9147C20.3284 21.5005 19.3856 21.5005 17.5 21.5005C15.6144 21.5005 14.6716 21.5005 14.0858 20.9147C13.5 20.3289 13.5 19.3861 13.5 17.5005V15.5005Z"
android:strokeColor="#FF202124"
android:strokeWidth="1.5" />
<path
android:fillColor="@android:color/transparent"
android:pathData="M13.5 5.50049C13.5 4.56861 13.5 4.10266 13.6522 3.73512C13.8552 3.24507 14.2446 2.85572 14.7346 2.65273C15.1022 2.50049 15.5681 2.50049 16.5 2.50049H18.5C19.4319 2.50049 19.8978 2.50049 20.2654 2.65273C20.7554 2.85572 21.1448 3.24507 21.3478 3.73512C21.5 4.10266 21.5 4.56861 21.5 5.50049C21.5 6.43237 21.5 6.89831 21.3478 7.26586C21.1448 7.75591 20.7554 8.14526 20.2654 8.34825C19.8978 8.50049 19.4319 8.50049 18.5 8.50049H16.5C15.5681 8.50049 15.1022 8.50049 14.7346 8.34825C14.2446 8.14526 13.8552 7.75591 13.6522 7.26586C13.5 6.89831 13.5 6.43237 13.5 5.50049Z"
android:strokeColor="#FF202124"
android:strokeWidth="1.5" />
</group>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 899 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -5,4 +5,8 @@
<string name="package_name">social.flotilla</string> <string name="package_name">social.flotilla</string>
<string name="custom_url_scheme">social.flotilla</string> <string name="custom_url_scheme">social.flotilla</string>
<string name="default_notification_channel_id">flotilla_push</string> <string name="default_notification_channel_id">flotilla_push</string>
<string name="shortcut_messages">Messages</string>
<string name="shortcut_search">Search</string>
<string name="shortcut_spaces">Spaces</string>
<string name="shortcut_inbox">Inbox</string>
</resources> </resources>

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="messages"
android:enabled="true"
android:icon="@drawable/shortcut_messages"
android:shortcutShortLabel="@string/shortcut_messages">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="social.flotilla"
android:targetClass="social.flotilla.MainActivity"
android:data="flotilla://shortcut/messages" />
</shortcut>
<shortcut
android:shortcutId="search"
android:enabled="true"
android:icon="@drawable/shortcut_search"
android:shortcutShortLabel="@string/shortcut_search">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="social.flotilla"
android:targetClass="social.flotilla.MainActivity"
android:data="flotilla://shortcut/search" />
</shortcut>
<shortcut
android:shortcutId="spaces"
android:enabled="true"
android:icon="@drawable/shortcut_spaces"
android:shortcutShortLabel="@string/shortcut_spaces">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="social.flotilla"
android:targetClass="social.flotilla.MainActivity"
android:data="flotilla://shortcut/spaces" />
</shortcut>
<shortcut
android:shortcutId="inbox"
android:enabled="true"
android:icon="@drawable/shortcut_inbox"
android:shortcutShortLabel="@string/shortcut_inbox">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="social.flotilla"
android:targetClass="social.flotilla.MainActivity"
android:data="flotilla://shortcut/inbox" />
</shortcut>
</shortcuts>

View file

@ -1,8 +1,9 @@
import type {CapacitorConfig} from "@capacitor/cli" import type {CapacitorConfig} from "@capacitor/cli"
import {loadEnv} from "vite"
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: "social.flotilla", appId: "social.flotilla",
appName: "Flotilla", appName: loadEnv(process.env.NODE_ENV || "production", process.cwd(), "VITE_").VITE_PLATFORM_NAME,
webDir: "build", webDir: "build",
ios: { ios: {
scheme: "Flotilla Chat", scheme: "Flotilla Chat",
@ -26,9 +27,7 @@ const config: CapacitorConfig = {
}, },
}, },
server: { server: {
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload url: process.env.FLOTILLA_DESKTOP_DEV_URL,
// url: "http://192.168.1.17:1847",
// cleartext: true,
}, },
} }

710
docs/feature_matrix.html Normal file
View file

@ -0,0 +1,710 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flotilla feature matrix</title>
<style>
:root {
color-scheme: light dark;
--fg: #1a1a1a;
--muted: #5b6470;
--bg: #ffffff;
--surface: #f6f7f9;
--border: #dfe3e8;
--accent: #6d28d9;
--ok: #166534;
--ok-bg: #dcfce7;
--partial: #92400e;
--partial-bg: #fef3c7;
--no: #991b1b;
--no-bg: #fee2e2;
--design: #1e40af;
--design-bg: #dbeafe;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e6e8eb;
--muted: #9aa4b2;
--bg: #15171a;
--surface: #1d2025;
--border: #30353c;
--accent: #c4b5fd;
--ok: #86efac;
--ok-bg: #14311f;
--partial: #fcd34d;
--partial-bg: #3a2c0a;
--no: #fca5a5;
--no-bg: #3a1414;
--design: #93c5fd;
--design-bg: #12233f;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 48px 24px 96px;
}
h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 1rem; }
h2 {
font-size: 1.4rem;
margin: 3rem 0 1rem;
padding-bottom: 0.4rem;
border-bottom: 2px solid var(--border);
}
h3 { font-size: 1.1rem; margin: 2rem 0 0.75rem; }
p { margin: 0.75rem 0; }
a { color: var(--accent); }
code {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.1em 0.35em;
font: 0.875em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
ul { padding-left: 1.4rem; }
li { margin: 0.35rem 0; }
.table-wrap { overflow-x: auto; margin: 1rem 0 1.5rem; }
table {
border-collapse: collapse;
width: 100%;
font-size: 0.95rem;
}
th, td {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
vertical-align: top;
}
th {
background: var(--surface);
font-weight: 600;
white-space: nowrap;
}
tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface) 55%, transparent); }
.status {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
}
.s-ok { color: var(--ok); background: var(--ok-bg); }
.s-partial { color: var(--partial); background: var(--partial-bg); }
.s-no { color: var(--no); background: var(--no-bg); }
.s-design { color: var(--design); background: var(--design-bg); }
hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
</style>
</head>
<body>
<main>
<h1>Flotilla feature matrix</h1>
<p>Flotilla&#39;s feature set, grouped by the areas of the pasted outline and expanded
with capabilities found in the codebase. Status values:</p>
<ul>
<li><strong>Implemented</strong> — reachable in the app today.</li>
<li><strong>Partial</strong> — present but limited, gated behind configuration, or dependent on
the relay/native platform.</li>
<li><strong>Not implemented</strong> — no code path exists.</li>
<li><strong>By design</strong> — supplied by the Nostr protocol or the deployment model rather
than by app code.</li>
</ul>
<p>Notes point at the route or module that carries the feature; <code>US-nnn</code> refers to
the story catalog in <code>e2e/USER_STORIES.md</code>.</p>
<h2>Chat</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Spaces (relay-based communities)</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces</code>, <code>app/rooms.ts</code>; NIP-29 relay-as-group. US-009–017</td>
</tr>
<tr>
<td>Rooms / channels</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces/[relay]/[h]</code>, <code>RoomChat.svelte</code>. US-018–028</td>
</tr>
<tr>
<td>Direct messages</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/chat</code>, <code>app/chats.ts</code>; NIP-17/44/59. US-029–036, US-108–109</td>
</tr>
<tr>
<td>Group DMs</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ChatStart.svelte</code>, <code>ChatMembers.svelte</code>. US-030</td>
</tr>
<tr>
<td>Voice &amp; video calls</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>app/call.ts</code>, <code>app/callEngine.ts</code>, LiveKit; relay must advertise the endpoint. Calls and video are out of e2e scope</td>
</tr>
<tr>
<td>Push notifications</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>app/push/</code>, <code>settings/alerts</code>; web, FCM/APNs and Android fallback. US-086, US-120</td>
</tr>
<tr>
<td>Threads</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces/[relay]/threads</code>, <code>ThreadBoard.svelte</code>. US-042–045</td>
</tr>
<tr>
<td>Media / file sharing</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>app/uploads.ts</code>, Blossom servers; upload, drag/paste, native clipboard. US-057, US-062</td>
</tr>
<tr>
<td>Room create / edit / delete</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>RoomCreate.svelte</code>, <code>RoomEdit.svelte</code>. US-020</td>
</tr>
<tr>
<td>Room join / leave and member lists</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>RoomMembers.svelte</code>; private-room access requests in <code>RoomAccess.svelte</code>. US-019, US-021, US-022</td>
</tr>
<tr>
<td>Replies, edits and deletes</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ComposeParent.svelte</code>; up-arrow edit, US-023, US-024, US-035</td>
</tr>
<tr>
<td>Reactions</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>EventReactButtons.svelte</code>, <code>app/reactions.ts</code>. US-025, US-040</td>
</tr>
<tr>
<td>Pinned messages</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>RoomPinnedMessages.svelte</code>, <code>app/roomPins.ts</code>. US-026</td>
</tr>
<tr>
<td>Forward / share to another room</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>Share.svelte</code>, <code>ShareEvent.svelte</code>. US-028, US-106</td>
</tr>
<tr>
<td>Drafts</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>app/drafts.ts</code>. US-058</td>
</tr>
<tr>
<td>Read out loud and dictation</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>SpeechBanner.svelte</code>, <code>app/dictation.ts</code>, <code>app/speech.ts</code>; OpenRouter key. US-119</td>
</tr>
<tr>
<td>Room and space search</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>RoomSearch.svelte</code>, <code>SpaceSearch.svelte</code>. US-017, US-027</td>
</tr>
<tr>
<td>Mentions, room references, hashtags, custom emoji</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>@welshman/editor</code> suggestions, <code>ContentMention.svelte</code>, <code>ContentTopic.svelte</code>, <code>ContentEmoji.svelte</code>. US-056, US-066</td>
</tr>
<tr>
<td>Link previews and rich embeds</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ContentLinkBlock.svelte</code>, <code>NoteContent*.svelte</code>; notes, articles, threads, images, video, invoices/Cashu. US-062–067</td>
</tr>
<tr>
<td>Send delay, per-relay delivery status, cancel and retry</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ThunkStatus.svelte</code>, <code>app/thunks.ts</code>. US-068–073</td>
</tr>
<tr>
<td>Zaps on messages</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>Zap.svelte</code>, <code>app/lightning.ts</code>; NIP-57. US-115</td>
</tr>
<tr>
<td>Unread indicators and mute</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>app/notifications.ts</code>. US-103, US-104, US-110–114</td>
</tr>
</tbody></table>
<h2>Events</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Calendar events</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces/[relay]/calendar</code>, <code>CalendarEvent*.svelte</code>; NIP-52. US-046–047</td>
</tr>
<tr>
<td>Monthly calendar view</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>CalendarMonth.svelte</code>, <code>CalendarWeek.svelte</code>, <code>CalendarAgenda.svelte</code>; month, week and agenda views, remembered per user</td>
</tr>
<tr>
<td>RSVPs</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>CalendarRsvp.svelte</code>, <code>app/calendar.ts</code>; NIP-52 going, maybe and can&#39;t go. No ticketing or paid attendance</td>
</tr>
<tr>
<td>Event detail with location and host</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>CalendarEventMeta.svelte</code>, <code>CalendarEventHeader.svelte</code>. US-047</td>
</tr>
<tr>
<td>Comments and reactions on events</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>EventComments.svelte</code>, <code>EventReactButtons.svelte</code>. US-052</td>
</tr>
</tbody></table>
<h2>Community content</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Long-form articles</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces/[relay]/articles</code>, <code>app/articles.ts</code>; NIP-23. US-037–039, US-041</td>
</tr>
<tr>
<td>Polls</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces/[relay]/polls</code>, <code>PollCreate.svelte</code>; NIP-88, single- and multiple-choice. US-048–049</td>
</tr>
<tr>
<td>Funding goals</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces/[relay]/goals</code>, <code>GoalCreate.svelte</code>; zap goal kind. US-050, US-052</td>
</tr>
<tr>
<td>Comments and reactions across content</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>CommentTree.svelte</code>, <code>EventActivity.svelte</code>. US-039, US-052</td>
</tr>
<tr>
<td>Create-from-room composer menu</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ComposeMenu.svelte</code>; publishes a kind-9 quote for interoperability. US-041, US-055</td>
</tr>
</tbody></table>
<h2>Moderation</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Ban / unban</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>SpaceMemberMenu.svelte</code>, <code>SpaceMembersBanned.svelte</code>; NIP-86 <code>banpubkey</code> / <code>unbanpubkey</code>. US-095</td>
</tr>
<tr>
<td>Member management</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces/[relay]/directory</code>, <code>SpaceMembersAdd.svelte</code>, <code>RoomMembersAdd.svelte</code>. US-022, US-095</td>
</tr>
<tr>
<td>Invite codes / access control</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>app/access.ts</code>, <code>SpaceInvite.svelte</code>, <code>RoomInvite.svelte</code>; relay claims and scoped codes. US-010–011, US-094</td>
</tr>
<tr>
<td>Role management and badges</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces/[relay]/directory</code>, <code>app/roles.ts</code>, <code>SpaceRoles.svelte</code>; NIP-29 <code>member</code> tags. US-093</td>
</tr>
<tr>
<td>Granular RBAC / role permissions</td>
<td><span class="status s-ok">Implemented</span></td>
<td>Roles are assigned in <code>/spaces/[relay]/directory</code>; a relay extension enforces the per-role permissions</td>
</tr>
<tr>
<td>Reports and content moderation</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>Report*.svelte</code>, <code>app/actionItems.ts</code>; author deletes, member reports, admin removes. US-096</td>
</tr>
<tr>
<td>Action-items queue</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>SpaceActionItems.svelte</code>, <code>app/actionItems.ts</code>. US-097</td>
</tr>
<tr>
<td>Relay policy warnings</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>RelaySummary.svelte</code>, <code>app/policies.ts</code>; auth-required, payment-required, proof-of-work. US-012, US-015</td>
</tr>
<tr>
<td>Unsigned-relay trust prompt</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>SpaceTrustRelay.svelte</code>. US-012</td>
</tr>
<tr>
<td>Mute rooms and spaces</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>app/settings.ts</code>, <code>RoomDetail.svelte</code>. US-104</td>
</tr>
<tr>
<td>Feature / pin content as admin</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>EditFeaturedContent.svelte</code>, <code>app/featured.ts</code>. US-092</td>
</tr>
</tbody></table>
<h2>Member tools</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Member profiles</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/people/[npub]</code>, <code>ProfilePage.svelte</code>. US-075, US-080, US-081</td>
</tr>
<tr>
<td>Edit own profile</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ProfileEditForm.svelte</code>, <code>settings/profile</code>. US-078</td>
</tr>
<tr>
<td>Searchable directory</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces/[relay]/directory</code>; search by name or role. US-093</td>
</tr>
<tr>
<td>People search</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>Search.svelte</code>, <code>app/social.ts</code>. US-074</td>
</tr>
<tr>
<td>Classifieds</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces/[relay]/classifieds</code>, <code>ClassifiedForm.svelte</code>; NIP-99, sold status. US-051</td>
</tr>
<tr>
<td>Content library / pinboards</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces/[relay]/library</code>, <code>Pinboard*.svelte</code>, <code>app/pinboards.ts</code>. US-053–054</td>
</tr>
<tr>
<td>Follow / unfollow</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ProfileMenu.svelte</code>, follow list. US-076</td>
</tr>
<tr>
<td>Web of trust</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ProfileTrust.svelte</code>, <code>app/social.ts</code>. US-077</td>
</tr>
<tr>
<td>Mute accounts</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>settings/content</code>. US-082</td>
</tr>
<tr>
<td>Profile notes feed</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ProfilePageNotes.svelte</code>. US-079</td>
</tr>
<tr>
<td>Profile sharing, QR and raw info</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ProfileQrCode.svelte</code>, <code>ProfileInfo.svelte</code>, <code>InfoNostr.svelte</code>. US-081</td>
</tr>
<tr>
<td>Statuses</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ProfileStatus.svelte</code>; NIP-38. US-075</td>
</tr>
</tbody></table>
<h2>White-labeling</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Branded web app</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>VITE_PLATFORM_*</code> env, <code>app/env.ts</code>; name, url, logo, accent, description, terms, privacy</td>
</tr>
<tr>
<td>Branded Android app</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>android/</code>, <code>@capacitor/assets</code>, <code>pnpm release</code></td>
</tr>
<tr>
<td>Branded iOS app</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ios/</code>, Capacitor build</td>
</tr>
<tr>
<td>Branded desktop app</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>electron/</code>, <code>scripts/package-desktop.mjs</code>; <code>pnpm run package:desktop:linux</code> / <code>:windows</code> / <code>:macos</code></td>
</tr>
<tr>
<td>PWA / installable web app</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>@vite-pwa/sveltekit</code>, <code>manifest.webmanifest</code>, <code>service-worker.js</code></td>
</tr>
<tr>
<td>Push to your own app</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>VITE_PUSH_SERVER</code> / <code>VITE_PUSH_BRIDGE</code>, <code>app/push/</code></td>
</tr>
<tr>
<td>Custom domain</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>CustomDomainModal.svelte</code>, <code>app/hosting.ts</code>; CNAME verification for hosted relays. US-101</td>
</tr>
<tr>
<td>Platform mode (own relays only)</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>VITE_PLATFORM_RELAYS</code>; disables browse/add/select and makes the platform relay home</td>
</tr>
<tr>
<td>Configurable defaults</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>VITE_DEFAULT_SPACES</code>, <code>VITE_DEFAULT_RELAYS</code>, <code>VITE_DEFAULT_MESSAGING_RELAYS</code>, <code>VITE_DEFAULT_BLOSSOM_SERVERS</code></td>
</tr>
<tr>
<td>Native share sheet and deep links</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>@capacitor/share</code>, Android share intent, <code>nostr:</code> / <code>https:</code> dispatch. US-106, US-107</td>
</tr>
</tbody></table>
<h2>Self-hosting</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Self-hostable</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>Dockerfile</code>, <code>server.js</code>; <code>docker run</code> or static build copied to any host</td>
</tr>
<tr>
<td>Open source</td>
<td><span class="status s-ok">Implemented</span></td>
<td>MIT license (<code>LICENSE</code>)</td>
</tr>
<tr>
<td>Static-host deployment</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>pnpm run build</code> output can be copied to any static host (<code>README.md</code>)</td>
</tr>
<tr>
<td>Managed hosting platform</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>app/hosting.ts</code>, <code>settings/hosting</code>, <code>/spaces/[relay]/admin</code>; create, plan, domain, pause and billing. US-098–102</td>
</tr>
<tr>
<td>Relay health checks</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>HomeHealthChecks.svelte</code>, <code>app/healthChecks.ts</code>. US-116</td>
</tr>
</tbody></table>
<h2>SSO</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Email / OTP sign-in</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>LogInEmail.svelte</code>, <code>LogInOTP.svelte</code>, <code>app/pomade.ts</code>; custodial Pomade flow, enabled when <code>VITE_POMADE_SIGNERS</code> is set. Out of e2e scope</td>
</tr>
<tr>
<td>Browser extension sign-in</td>
<td><span class="status s-ok">Implemented</span></td>
<td>NIP-07, <code>LogInSelect.svelte</code>. US-004</td>
</tr>
<tr>
<td>Remote signer / bunker</td>
<td><span class="status s-ok">Implemented</span></td>
<td>NIP-46, <code>LogInBunker.svelte</code>, <code>BunkerConnect.svelte</code>, QR flow. US-005</td>
</tr>
<tr>
<td>Native Android signer</td>
<td><span class="status s-ok">Implemented</span></td>
<td>NIP-55; out of e2e scope</td>
</tr>
<tr>
<td>Relay authentication</td>
<td><span class="status s-ok">Implemented</span></td>
<td>NIP-42, <code>app/access.ts</code>, <code>settings/privacy</code></td>
</tr>
<tr>
<td>Key backup on signup</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>KeyDownload.svelte</code>; plain nsec or password-encrypted ncryptsec. US-002</td>
</tr>
<tr>
<td>Data export</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>hosting/DataTransferModal.svelte</code>, <code>app/hosting.ts</code>; a space&#39;s events download as JSONL and re-import into another relay</td>
</tr>
<tr>
<td>Account deletion</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>ProfileDelete.svelte</code>, <code>settings/profile</code>; broadcasts account deletion. US-008</td>
</tr>
</tbody></table>
<h2>Portable identity &amp; history</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Customer holds keys</td>
<td><span class="status s-design">By design</span></td>
<td>Identity is a self-sovereign <code>nsec</code>/<code>npub</code>; no account lives with the operator</td>
</tr>
<tr>
<td>Customer holds source and data</td>
<td><span class="status s-design">By design</span></td>
<td>Self-hostable relay plus user-chosen relays; events are portable Nostr events</td>
</tr>
<tr>
<td>One-click export / import</td>
<td><span class="status s-no">Not implemented</span></td>
<td>No bundled export or re-import of a user&#39;s data</td>
</tr>
<tr>
<td>Multi-device sync</td>
<td><span class="status s-ok">Implemented</span></td>
<td>NIP-78 app-data sync, <code>app/sync.ts</code>; read state and settings follow the account</td>
</tr>
<tr>
<td>Local-first history</td>
<td><span class="status s-ok">Implemented</span></td>
<td>IndexedDB persistence, <code>app/storage.ts</code>, <code>lib/indexeddb.ts</code>; read conversations survive relay loss. US-109</td>
</tr>
<tr>
<td>Account portability across clients</td>
<td><span class="status s-design">By design</span></td>
<td>The same keys and relay events work in any NIP-29 client</td>
</tr>
<tr>
<td>Key recovery</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>KeyRecoveryRequest.svelte</code>, <code>KeyRecoveryConfirm.svelte</code> for custodial (Pomade) accounts</td>
</tr>
</tbody></table>
<h2>Home &amp; discovery</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Home dashboard / inbox</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/home</code>, <code>HomeInbox.svelte</code>, <code>app/inbox.ts</code>. US-105, US-116</td>
</tr>
<tr>
<td>Cross-space activity summary</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>HomeActivity.svelte</code>, <code>app/feeds.ts</code>. US-116</td>
</tr>
<tr>
<td>Network feed of follows</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>HomeNetwork.svelte</code>, <code>app/feeds.ts</code>. US-117</td>
</tr>
<tr>
<td>Space browse, search and reorder</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>/spaces</code>, <code>PrimaryNavSpaces.svelte</code>, drag reordering. US-009</td>
</tr>
<tr>
<td>Join by invite / direct link</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>SpaceJoin.svelte</code>, <code>/join</code>, invite parsing in <code>app/access.ts</code>. US-010, US-011</td>
</tr>
<tr>
<td>Link and bech32 resolution</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>[bech32]</code> route, <code>app/routes.ts</code>; npub/nevent/note. US-107</td>
</tr>
</tbody></table>
<h2>Lightning &amp; payments</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Zap messages and profiles</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>Zap.svelte</code>, <code>app/lightning.ts</code>; NIP-57</td>
</tr>
<tr>
<td>Wallet connection (NWC / WebLN)</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>settings/wallet</code>, <code>app/lightning.ts</code>; NIP-47. US-115</td>
</tr>
<tr>
<td>Lightning address and zap presets</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>settings/wallet</code>, <code>app/lightning.ts</code>. US-091</td>
</tr>
<tr>
<td>Hosting billing and invoices</td>
<td><span class="status s-ok">Implemented</span></td>
<td><code>PaymentDialog.svelte</code>, <code>PaymentSetup.svelte</code>, <code>app/hosting.ts</code>; Lightning and card. US-102</td>
</tr>
</tbody></table>
</main>
</body>
</html>

462
e2e/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,462 @@
# E2E architecture
Flotilla's end-to-end suite runs the real app against a relay network that is entirely under the
test's control. Nothing in this directory opens a connection to a host the test did not create.
## The relay
Every spec runs against a real [zooid](https://github.com/coracle-social/zooid) relay in Docker, the
implementation Flotilla is built for, so protocol drift between the client's assumptions and a real
relay shows up as a failing test.
zooid is multi-tenant: it binds a config to a `Host` header and serves any number of virtual relays
from one process. `harness/zooid/config.ts` names them, one toml apiece in `harness/zooid/docker/`,
and a scenario picks one by name. A second space costs a config file and no extra process, which is
what keeps outbox routing and cross-space isolation testable.
A relay's policy is its toml and nothing else. A scenario says what is _on_ a relay — its rooms, its
members, its messages — never what the relay _is_, so there is no policy negotiation anywhere in the
harness. `space` and `other` are the same permissive policy, for specs that need two relays; `closed` refuses a join that carries no invite claim, which is what raises "Request
Access"; `unsigned` serves events with their signatures stripped, which is what raises "Do you trust
this space?". Seeding a membership on `closed` is therefore not possible — `join()` and `member()`
publish a claimless join and the relay refuses it — so a scenario there seeds admin-created rooms
and lets the spec do the joining.
`indexer` and `outbox` are not spaces: groups are off, anyone may read and write, and nothing seeded
on them carries an `h` tag. They are what "The follow graph" below is made of.
### Why the relays are called `<name>.test`
The container listens on plaintext loopback, but a url that is local or insecure is dropped from
every relay selection unless the caller opts in — see `isLocalUrl` and `RelaySelection.getUrls` in
`@welshman/util` — and the app never opts in, since in production neither belongs in a routing
decision. Handed `ws://localhost:3334/`, the client would load a space by its explicit url and
resolve nothing else: no outbox loads for profiles or relay lists, no relay hints.
So the client is given `wss://space.test/` and never learns the container has an address. Everything
that speaks to it goes through `zooid/transport.ts`, which dials `127.0.0.1:3334` carrying the two
headers a TLS terminator adds in front of a real deployment:
- `Host: space.test` — zooid's dispatcher binds a config to a Host (`cmd/relay/main.go`), so this is
what selects which toml answers.
- `X-Forwarded-Proto: https` — khatru derives the url it checks NIP-42 and NIP-86 signatures against
from Host plus this (`getBaseURL` in `khatru/relay.go`), arriving at `wss://space.test/`, which is
exactly what the client signed into its `relay` tag. Seeding signs the same url, so a fixture is
written over the same relay the app talks to.
`.test` is reserved by RFC 2606 and resolves nowhere, so a url that ever escapes this process fails
to connect rather than reaching a host.
## Transport: one interception point
All relay traffic is intercepted in the **Node** process via Playwright's `routeWebSocket`, applied
to the `BrowserContext` so every page in it is covered:
```
browser context ──▶ context.routeWebSocket(everything but vite's hmr socket)
│
▼
zooid.relays.get(url)
│
┌─────────────────┴─────────────────┐
│ │
a socket this process opens unknown url: a relay that
to the container, as that holds no events, and the
relay's virtual host url is recorded as a leak
```
Every socket the browser opens is terminated in the node process, and the only one that leaves
is the loopback connection `zooid/transport.ts` makes to the container the test started.
`assertNoLeaks()` fails a test that touched a url the scenario never declared.
Because the branch is taken per socket rather than once, retention is expressible here too:
`forgetRelay(context, url)` sends that context down the empty-relay side from its next connection
on, without the url becoming a leak. A reload after it is a client coming back to a relay that has
dropped what it was holding, which is what separates history a client kept from history it is
reading back off the wire.
Silence is expressible the same way, and it is the fault a client cannot see: a relay that takes the
socket and then sends nothing at all leaves every request it was given indistinguishable from one
still in flight. `as(user, path, {silent: [url]})` is that from the first connection, which is where
a spec about a page failing to fill needs it; `silenceRelay(context, url)` applies it mid-test. Such
a url needs no tenant behind it, since nothing it says is ever served.
Interception is installed by `as()` and `visit()`, on a context each of them creates, so a page that
came from anywhere else has none of it. Playwright's own `context` fixture — and the `page` fixture
built on it — is therefore overridden to throw, so a spec written the ordinary way fails immediately
with a message saying so rather than quietly dialling the relays in `.env`. The built-in `request`
fixture goes the same way: an `APIRequestContext` is an http client in the node process that belongs to no browser
context, so the block-all below cannot see it and nothing records what it sent.
`playwright` is the one fixture left alone, because it is where the run's own browser comes
from, so every test would fail if it threw. A spec that goes around `as()` through
`playwright.request` or `playwright.chromium.launch()` reaches the network unwatched, and no fixture
can refuse that without refusing the suite.
Interception in Node rather than in the page enables multi-user testing. Three browser
contexts logged in as three different users all dispatch into the _same_ relay, so one user
genuinely observes another user's writes, over the wire, through the client's real socket stack.
### Why not an `AdapterFactory`
An `AdapterFactory` backed by a `Repository`-backed adapter cannot test authentication. NIP-42 lives on `Socket`: `AuthState` listens to `SocketEvent.Receiving`/
`Sending`, and `socketPolicyAuthBuffer` replays messages that were rejected with `auth-required:`.
An `AbstractAdapter` whose `sockets` getter returns `[]` never constructs any of that. Patching the
transport instead leaves `Pool → Socket → SocketAdapter` untouched, so auth, message buffering,
replay-after-auth and reconnect are all exercised as written.
## HTTP
Relays are not the only egress. `installHttpRoutes` routes every url that is not the dev server — a
predicate rather than a `"**/*"` pattern, so the hundreds of module requests a sveltekit page makes
in dev are never matched — and aborts what it catches. Two origins get past it: the dev server
on `localhost:1847`, which is left unrouted, and each relay's own origin, which is forwarded to the
container by the same transport carrying the same two headers, so the NIP-11 document and the NIP-86
management API the app reads are the real relay's answers, signed against the url the client used.
Every other request is aborted and recorded.
A relay's NIP-11 document decides whether a space is synced by reconciliation or a plain REQ
(NIP-77), whether a message the UI composes carries a protected `-` tag (NIP-70), what the space is
called, and which pubkey room state is trusted from. Its NIP-86 answers decide whether the user is an
admin, since a relay refuses management calls from anyone else and the method list that comes back
doubles as the client's permission set — the space, room, event and pin menus, the directory and the
library are all gated on it.
Services the app talks to are mocked per-scenario, so a blocked request is always a bug
rather than ambient noise: Dufflepud (`dufflepud.coracle.social`), Blossom uploads, the push server
(`nps.flotilla.social`), the hosting API, LiveKit token endpoints, and image/thumbnail fetches. The
analytics script hard-coded in `src/app.html` is mocked with an empty body to avoid making
`assertNoBlockedRequests()` a statement about the page shell rather than about the test.
Two of those answers are the scenario's own. NIP-11 fields a spec names are merged over the relay's
real document — a `redirect_to`, a `limitation`, a NIP the relay does not implement — rather than
replacing it, because `self`, `pubkey` and `supported_nips` are what room state is trusted from, and
the merge is installed with the page, since the document is read at startup and cached from then on.
The hosting API is a small stateful fake rather than fixed answers: a write mutates the record it
names and the next read sees it, so editing a space, deactivating it or paying an invoice is
observable. `getHosting(context)` changes the backend's mind between two of the user's clicks — a
custom domain that verifies, an invoice that gets paid.
## Containment
A browser has a fixed set of ways to put bytes on a wire.
| how the app can reach the network | what stops it |
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `WebSocket` — `@welshman/net`'s `Socket`, the only constructor call | `context.routeWebSocket(url => !isDevServerUrl(url))`. Terminated in node; an undeclared url gets a relay holding nothing and `assertNoLeaks()` fails the test |
| `fetch` / `XMLHttpRequest` — app code, welshman, `@pomade/core` | `context.route(url => !isDevServerUrl(url))`, which aborts unless a mock registered later answers first |
| `img`, `script`, `link`, fonts, media, and every other subresource | the same route. Images never reach it — `mockImages` answers anything with `resourceType() === "image"` with a 1×1 png |
| navigation, including the external links the ui offers | the same route: a document request is routed like any other, and a popup opens in the context that owns it |
| `EventSource` | the same route. Neither `src` nor welshman constructs one |
| `navigator.sendBeacon` | no call site. The one script that would use it is the plausible tag in `src/app.html`, served with an empty body |
| a service worker | `serviceWorkers: "block"` on the context. Playwright does not route a worker's requests, so the worker is refused instead |
| a web worker | no call site in `src`; `@pomade/core`'s is argon2, which is cpu and no socket |
| Capacitor's native http and push plugins | not reachable from a browser, and the zooid config leaves `[push]` disabled so nothing is asked to register |
Two connections leave this process, both to something the test started: the browser's to the vite dev
server on `localhost:1847`, and `zooid/transport.ts`'s to `127.0.0.1:3334`. Everything else the app
initiates dies in node.
The two ends are enforced differently. A websocket to a url no scenario declared is answered rather
than refused — by a relay that EOSEs every REQ and accepts every event into the void — and the url is
recorded, so the test fails on `assertNoLeaks()` naming it rather than on a timeout somewhere
downstream. An http request nothing mocked is aborted outright, but noticing it is opt-in:
`assertNoBlockedRequests()` is for a spec that has mocked what it exercises, because some of what a
page asks for is meant to be refused.
Configuration is the other half. `boot()` overrides every `VITE_` value that names a relay — default,
indexer, search, messaging, signer, platform, blocked, the default space list — with the scenario's
own urls, through the hook `src/app/env.ts` reads them with, and points `VITE_PUSH_BRIDGE` at `ws://localhost:1/`, which nothing serves, so a push
bridge connection is reported as a leak rather than blending into a relay's traffic. The `VITE_`
values it does not override still name real hosts — the blossom server, the pomade signers, the
thumbnail service, the push server, the hosting api — and none of them is contacted at boot. Blossom
is read only when an upload starts, the thumbnail url only on android, pomade only when a signup uses
it, and the hosting api and dufflepud are mocked. They are contained by the block-all rather than by
configuration.
Grepping the repo for hostnames accounts for all of them, and there are only three kinds. Most are an
`href` in help text — nostr.com, nostrapps.com, nsec.app, nosta.me, nostr.how, github.com,
fountain.fm, cal.com, figma.com, coracle.tools, gitea.coracle.social, nwc.getalby.com, and the
`coracle.social` entity links `src/app/env.ts` builds — reachable only by clicking, and routed if
clicked. Two are fetched: dufflepud, whose origin is the one service url hard-coded rather than read
from `VITE_`, and the plausible tag in `src/app.html`. Both are mocked. The rest are in comments.
Under `e2e` the only hostnames are the four service origins `net/http.ts` matches on, the virtual
relays' own `.test` names, and the loopback address `zooid/transport.ts` dials.
Three things this does not cover:
- **WebRTC.** `livekit-client` opens an `RTCPeerConnection`, and Playwright cannot see one. A voice
or video room reaches its sfu over ice and dtls with nothing in between. `mockLivekit` decides
where the client is pointed, which is why its `serverUrl` has to be something the test owns, and
why `[livekit]` is omitted from the zooid config entirely. A spec that joins a call escapes this
document's guarantee and needs its own answer.
- **Playwright's own fixtures.** `context`, `page` and `request` are overridden to throw, but
`playwright` cannot be — it is where the browser comes from. A spec that reaches the network
through `playwright.request` or `playwright.chromium.launch()` is unwatched.
- **The browser itself.** A browser's own traffic is not the app's and is not routed. Playwright
launches chromium with `--disable-background-networking`, `--disable-component-update` and
`--disable-breakpad`, which is the whole of the mitigation, and which is chromium's alone —
`E2E_BROWSER=firefox` or `webkit` runs the suite under an engine those flags say nothing about.
## Determinism without freezing the clock
Timestamps are never patched. Instead every fixture is generated fresh at the start of each test and
signed with timestamps relative to the moment the test began:
```ts
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.message(user.alice, "general", "morning all", at(2, HOUR))
space.message(user.bob, "general", "morning!", at(90, MINUTE))
})
```
`at(2, HOUR)` is `now() - int(2, HOUR)` evaluated once per test, so "2 hours ago" renders the same
way on every run without the app's `now()` being touched. Fixtures are published over a real socket,
authenticated as the identity that signed them, so the relay stores exactly what it would have stored
for a real client: there are no pre-signed JSON blobs to drift out of date, and a fixture the relay
would have refused fails the test instead of appearing in a query.
## Users and sessions
Test identities are deterministic secp256k1 keypairs derived from fixed secrets (`alice`, `bob`,
`carol`, `admin`), so a pubkey is stable across runs and can be asserted on directly.
`makeTestUser(name)` derives a fifth, sixth or hundredth one from its name and registers it, for the
directory and search specs that need more people than there are named ones.
A logged-in user is created by injecting a NIP-01 session before the app boots. `src/app/session.ts`
carries a DEV-only hook: `restoreSession()` prefers `window.__TEST_SESSION__` when it is present, and
`window.__TEST_EVENTS__` becomes the repository contents a returning user's client would have found
on disk. Both branches are stripped from production builds, and injecting through the hook avoids
writing Capacitor's `SecureStorage`/`Preferences` localStorage encoding by hand.
The injected events go in _after_ `storage.ready`. Storage loads what the last session left behind
with `Repository.load`, which clears the repository before inserting, so anything put there while
IndexedDB is still opening is wiped a few milliseconds later — before the layout has rendered
anything, and long before `authPolicy` reads the room list to decide whether to answer a relay's AUTH
challenge.
The cache exists for the harness rather than for the app. In production the client bootstraps itself:
asking a keyed collection for a pubkey loads it, so `authPolicy` reading the user's room and relay
lists to decide whether to answer a challenge is itself what fetches them, as is any outbox-routed
load of the user's own data. Kind-10002 comes back from the public indexers, and `syncRelayList`
cascades into the room list from there.
Here the indexers _are_ the scenario's relays, because `boot()` points every relay list at them, and
those are members-only — so that first load is refused with `auth-required:`, while `authPolicy`,
conservative by default, will not sign for a url no list has named yet. Each waits on the other. The
injected room list breaks the circle, and it is what a user who joined a space through the UI would
have on disk anyway.
The price is that no spec exercises the bootstrap: every one of them starts already knowing which
relays it belongs to, so a regression in the chain from relay list to room list, or in `authPolicy`'s
conservative gate, leaves the suite green. The public relays under "The follow graph" are the
standing-in indexer that covering it would need.
Each user is a separate `BrowserContext`, which also gives each one its own IndexedDB and
localStorage, so nothing bleeds between users.
An injected session is re-applied on every navigation, so through `as()` a reload, a logout and a
login are all unobservable. `visit(path, options)` is the same page without one — same context, same
interception, same env — and it is what a spec that watches someone arrive, sign in and come back
starts from. Both take the same options, over and above the scenario's own relays:
| option | what it does |
| ----------- | ------------------------------------------------------------------------------------ |
| `context` | merged over the project's context options: a viewport, a colour scheme, a permission |
| `env` | `VITE_` values applied over the ones derived from the scenario's relays |
| `nip07` | a `window.nostr` backed by that identity's own signer, for an extension login |
| `webln` | a `window.webln` that answers the connection handshake, for connecting a wallet |
| `relayInfo` | fields merged over a relay's own NIP-11 document, keyed by relay url |
| `hosting` | what the hosting backend already knows about this user |
Whatever `env` names still has to be something the scenario owns — a platform relay, the domain
hosted spaces are created under — or the app dials a host nothing serves and the test fails on a
leak. The NIP-07 provider is a real signer rather than a stub, because the session it produces has to
sign the NIP-42 challenges the members-only relays send.
## The follow graph
Everything `/home`'s network column is made of sits outside a space: a follow list, each followed
pubkey's relay list, and an indexer to resolve those from. `open("indexer")` and `open("outbox")`
are the two public relays that hold it, and a scenario says which of them a given fixture lands on:
```ts
const scenario = await seed(({relay, open, user, at}) => {
const space = relay("space")
const indexer = open("indexer")
const outbox = open("outbox")
space.room("general", {name: "General"})
space.join(user.alice, "general")
indexer.relayList(user.alice, {
read: [space.url, indexer.url],
write: [space.url, indexer.url, outbox.url],
})
indexer.follows(user.alice, [user.bob])
indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url]})
outbox.profile(user.bob, {name: "Bob Barker"})
outbox.note(user.bob, "the lighthouse has been dark since tuesday", at(30, MINUTE))
})
```
Bob is in none of alice's spaces, so the only thing that can put his note on her screen is his relay
list. Keeping the indexer and the outbox apart is what makes that assertable: a client that ignored
the list would ask the indexer, find nothing, and fail the spec, where one relay serving both roles
would pass either way. `VITE_INDEXER_RELAYS` points at the indexer as soon as a scenario opens one,
and at the scenario's spaces otherwise, which is what every spec written before there was one still
gets.
An open relay's url reads before seeding has run, unlike a space's, because a relay list has to name
the relay a note is seeded on.
### Why a reader has to name the relays it reads from
zooid answers no REQ without NIP-42, whatever `public_read` says, and `authPolicy` is conservative:
it identifies only to relays the user's own room list or relay list names. So a relay a spec expects
the client to read from has to appear in that user's list, and a relay list is seeded for the reader
as well as for the people they follow.
Read and write are separate there, and the difference is what a routing assertion hangs on. Above,
alice writes to bob's relay and reads from her space and the indexer: the write url is enough for
her client to identify to it, while a feed's context, which is built from her read urls, has no
reason to ask it for anything. A reply that only lives on bob's relay therefore counts only if the
feed asked the relay the note itself came from.
The write urls have a second job. A user's own follow list is loaded through their outbox, so the
relay a scenario seeds that list on has to be one of the relays their list says they write to — the
indexer, here. Seed it somewhere they only read from and the feed comes up empty with nothing to say
why.
Her own relay list reaches her client as cache alongside her room list, for the same reason the room
list does: it is the list that says which relays may be identified to, so it cannot be the thing
that has to be fetched first.
## Layout
```
e2e/
ARCHITECTURE.md this document
harness/
index.ts everything a spec imports: `test`, `expect`, helpers
keys.ts deterministic keypairs
ui.ts the locators specs share: dialogs, the composer, a room's messages
files.ts the bytes an upload spec picks, and the browser's own file chooser
zooid/
config.ts the virtual relays and their hosts — the one place they are named
relay.ts docker lifecycle, reset, seeding over its own authenticated socket
transport.ts the only thing that knows the container's address: ws and http to it
testRelay.ts the seeding affordances a scenario builds on
types.ts TestRelay, RoomOptions, RelayConnection
docker/
compose.yaml no data volume; tmpfs for /app/data and /app/media
config/ one toml per virtual relay: its host, and its whole policy
net/
websocket.ts routeWebSocket install, dispatch, transcript, leak detection
http.ts block-all + per-service mocks
app/
boot.ts env overrides, navigate, wait for mount and for the app to have read
the overrides
session.ts NIP-01 session injection
nip07.ts a window.nostr backed by a test identity's own signer
webln.ts a window.webln that enables and reports what it supports
seed/
scenario.ts the `seed()` builder and relative-time helpers
publish.ts the queue every seeding call goes through, and what it hands back
space.ts one space's fixtures: rooms, members, messages, replies, profiles,
direct messages, and anything a domain writer renders
openRelay.ts one public relay's fixtures: relay lists, follow lists, profiles, notes
specs/
*.spec.ts
```
A locator lives in the spec that uses it until a second spec needs the same one, at which point it
moves to `harness/ui.ts`. The class or the aria label it names is then one edit when the app renames
it, and the comment saying why it is shaped that way has one copy to keep true. What stays local is
what one spec means differently: `dms.spec.ts` names a message by its `data-event` id, because the
same words are sent more than once there.
One piece lives outside this directory: `src/lib/test/session.ts` holds the two window keys and the
getters `src/app/session.ts` reads them through. It is the only file the app ships on the harness's
behalf, and `harness/app/session.ts` duplicates the key names rather than importing them, since
importing anything under `src` would pull sveltekit into the node process.
## Resetting between tests
`Zooid.reset()` recreates the container rather than restarting it: with no volume mounted for
`/app/data`, storage lives in the container's writable layer and a tmpfs, so a fresh container is a
fresh database. What it mounts as its config is a copy of `docker/config`, staged outside the repo
and refreshed on the way up — zooid saves a relay's toml back when a NIP-86 call edits its name — so
every test starts against the same relays with nothing in them. The container is a worker fixture,
so the docker start-up cost is paid once per worker rather than once per test.
It is recreated in the teardown of the test that finishes, not the setup of the one that starts.
Creating a container tears down a veth pair and builds another, and the bridge behind it loses
carrier with them; chromium watches the host's interfaces and aborts everything it has in flight
when they move, which the app meets as a route chunk that failed to import and, with `ssr = false`,
a 500 page.
Recreating from teardown puts the next test's seeding — an authenticated socket per identity, and
every fixture written over it — between the churn and the first page that test opens. That is not
on its own enough: every netlink event lands before `compose up --wait` returns, but chromium
coalesces interface changes for up to two seconds before acting on one. So a recreate also stamps
the moment it settles, and a page waits out whatever is left of that window when it opens — which
for most tests is nothing, the teardown and the seeding having spent it already.
Seeding then writes over a real authenticated socket, one per identity per relay, held open for the
rest of the test. A fixture must therefore be signed by an identity `harness/keys.ts` holds, since
zooid authenticates every write.
Anything richer than a room message is built by the domain writers the app itself publishes with:
`space.kind(Article)` hands back the kind configured against this space, and `space.event(user, () =>
…renderTemplate())` defers the render until the space has a url to render hints against, which is
only true once the queue has drained. A NIP-17 conversation is `space.dm(from, to, content)`: it
gift-wraps one rumor per participant and publishes each wrap over the sender's connection, since a
wrap is signed by an ephemeral key nobody here can authenticate as. zooid stores it anyway, because
it authorizes a kind-1059 by the member named in its `p` tag — and refuses one addressed to a
stranger.
## Running
The suite is not run by agents (see CLAUDE.md).
```sh
pnpm exec playwright install # once
pnpm test # starts and stops the container itself
```
Every test skips when docker is unavailable, rather than failing.
The relay is one pinned zooid, named by digest in `harness/zooid/relay.ts`, and the harness fetches
that image the first time a machine needs it. So a spec asserting relay behaviour runs against the
relay the diff names, and needing a newer zooid is a bump in that file. `ZOOID_IMAGE` overrides the
pin, which is how a spec is tried against a zooid built from a checkout.
A test fails when the app broke while it ran, whatever it asserted: an uncaught exception on any
of its pages, or code of ours the browser refused under the content security policy. A refusal
reaches the console and nothing else, which is how a policy that had rotted past the script it
names stayed invisible to every spec for a week (#535). A failed request or a warning is neither,
and a dev server is full of both, so those are logged and left alone — as is the route chunk
sveltekit loses when the per-test container churns the network out from under it (#529).
Either way a failing test attaches `browser-console`, everything both sides said. `use.trace` and
playwright's own reporting only cover contexts playwright made itself, and the harness makes its
own, so without that attachment a failure carries the DOM snapshot and nothing the app said —
which is unreadable when what failed is the app rendering its error page.
It also attaches `relay-transcript`, what each user said to each relay and what came back. The
console says what the app did with an event, and the transcript says whether it ever had one.
One engine per run. These specs exercise sockets, auth and sync, so running them under three
engines adds little coverage. `E2E_BROWSER=webkit pnpm test` runs the whole
suite under another one. The container listens on a fixed port and cannot be sharded, so
`workers` is 1.
`src/app/env.ts` resolves every `VITE_` value through a DEV-only hook that prefers
`window.__TEST_ENV__`, which `boot()` injects per browser context, so any dev server will do and a
server already listening on `:1847` is reused. `boot()` still checks that the app read the injected
values, and fails naming the hook rather than letting a run drift onto the relays in `.env`.

1887
e2e/USER_STORIES.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
import {defineConfig} from "@playwright/test"
export default defineConfig({
testDir: ".",
forbidOnly: !!process.env.CI,
timeout: 60_000,
expect: {timeout: 15_000},
workers: 1,
reporter: "list",
})

211
e2e/desktop/smoke.spec.ts Normal file
View file

@ -0,0 +1,211 @@
import {mkdir, mkdtemp, readFile, rm, writeFile} from "node:fs/promises"
import {createRequire} from "node:module"
import {tmpdir} from "node:os"
import {join, resolve} from "node:path"
import {_electron, expect, test} from "@playwright/test"
declare global {
interface Window {
Capacitor: {
Plugins: {
DesktopSecureStorage: {
get(options: {key: string}): Promise<{value?: string}>
set(options: {key: string; value: string}): Promise<void>
}
}
}
}
}
test("the desktop app renders, navigates, and keeps external pages outside", async () => {
const profile = await mkdtemp(join(tmpdir(), "flotilla-desktop-"))
const env = {...process.env, XDG_DATA_HOME: join(profile, "data")}
try {
const packaged = process.env.FLOTILLA_DESKTOP_EXECUTABLE
const {appId} = JSON.parse(await readFile("electron/generated/capacitor.config.json", "utf8"))
const entryPath = join(env.XDG_DATA_HOME, "applications", `${appId}.desktop`)
const existingEntry =
"[Desktop Entry]\nExec=/installed/app.AppImage\nX-Electron-Generated=true\n"
if (!packaged && process.platform === "linux") {
await mkdir(join(env.XDG_DATA_HOME, "applications"), {recursive: true})
await writeFile(entryPath, existingEntry)
}
const executablePath: string =
packaged || createRequire(import.meta.url)(resolve("electron/node_modules/electron"))
let app = await _electron.launch({
executablePath,
env,
// Chromium refuses to start as root with its sandbox on, which is what a CI container is.
chromiumSandbox: packaged ? true : process.getuid?.() !== 0,
args: [...(packaged ? [] : [resolve("electron")]), `--user-data-dir=${profile}`],
})
try {
expect(await app.evaluate(({app}) => app.getPath("userData"))).toBe(profile)
if (packaged && process.platform === "linux") {
const entry = await readFile(entryPath, "utf8")
expect(entry).toContain(`Icon=${join(env.XDG_DATA_HOME, "icons", `${appId}.png`)}`)
expect(entry).not.toContain("/tmp/.mount_")
} else if (process.platform === "linux") {
expect(await readFile(entryPath, "utf8")).toBe(existingEntry)
}
const mainWindows = () =>
app.windows().filter(page => page.url().startsWith("capacitor-electron://"))
await expect.poll(() => mainWindows().length).toBe(1)
const [page] = mainWindows()
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await page.reload()
const heading = page.getByRole("heading")
await expect(heading).toBeVisible()
await expect(page).toHaveTitle(
(await heading.textContent())!.replace(/^Welcome to\s*|!$/g, ""),
)
expect(
await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme),
).toBe(await page.locator("body").getAttribute("data-theme"))
const origin = await page.evaluate(() => location.origin)
expect(origin).toMatch(/^capacitor-electron:\/\//)
expect(await page.locator('script[src*="@vite/client"]').count()).toBe(0)
if (packaged) {
const {version} = JSON.parse(await readFile("package.json", "utf8"))
expect(await app.evaluate(({app}) => app.isPackaged)).toBe(true)
expect(await app.evaluate(({app}) => app.getName())).toBe(await page.title())
expect(await app.evaluate(({app}) => app.getVersion())).toBe(version)
expect(await app.evaluate(({app}) => app.getAppPath())).toMatch(/app\.asar$/)
const updater = await app.evaluate(async ({app}) => {
const {createRequire} = process.getBuiltinModule("node:module")
const {readFile} = process.getBuiltinModule("node:fs/promises")
const {join} = process.getBuiltinModule("node:path")
const require = createRequire(join(app.getAppPath(), "package.json"))
return {
version: require("electron-updater").autoUpdater.currentVersion.version,
config: require("js-yaml").load(
await readFile(join(app.getAppPath(), "..", "app-update.yml"), "utf8"),
),
}
})
expect(updater.version).toBe(version)
expect(updater.config).toMatchObject({
provider: "generic",
url: "https://gitea.coracle.social/coracle/flotilla/releases/download/latest/",
})
expect(await app.evaluate(({app}) => app.commandLine.hasSwitch("no-sandbox"))).toBe(false)
const preferences = await app.browserWindow(page).then(window =>
window.evaluate(window => {
const {sandbox, contextIsolation, nodeIntegration} =
window.webContents.getLastWebPreferences()
return {sandbox, contextIsolation, nodeIntegration}
}),
)
expect(preferences).toEqual({sandbox: true, contextIsolation: true, nodeIntegration: false})
await page.screenshot({path: test.info().outputPath("packaged-onboarding.png")})
}
await page.getByRole("button", {name: "Log in", exact: true}).click()
await expect(page.getByTestId("login")).toBeVisible()
await page.goto(`${origin}/settings/profile`)
await expect(page.getByRole("heading")).toBeVisible()
await page.reload()
await expect(page.getByRole("heading")).toBeVisible()
expect(new URL(page.url()).pathname).toBe("/settings/profile")
expect(
await page.evaluate(async () => {
const url = URL.createObjectURL(
new Blob(["onmessage = event => postMessage(event.data)"], {type: "text/javascript"}),
)
const worker = new Worker(url)
try {
return await new Promise<string>((resolve, reject) => {
worker.onmessage = event => resolve(event.data)
worker.onerror = event => reject(new Error(event.message))
worker.postMessage("desktop worker ready")
})
} finally {
worker.terminate()
URL.revokeObjectURL(url)
}
}),
).toBe("desktop worker ready")
const externalUrls = await app.evaluateHandle(({shell}) => {
const urls: string[] = []
shell.openExternal = async (url: string) => {
urls.push(url)
}
return urls
})
const externalLink = page.locator('a[target="_blank"][href^="https:"]').first()
const externalUrl = await externalLink.evaluate((link: HTMLAnchorElement) => link.href)
await externalLink.click()
await expect.poll(() => externalUrls.jsonValue()).toContain(externalUrl)
expect(await page.evaluate(() => location.origin)).toBe(origin)
expect(app.windows()).toHaveLength(1)
expect(errors).toEqual([])
await page.evaluate(() => {
document.addEventListener(
"securitypolicyviolation",
({effectiveDirective}) => {
document.documentElement.dataset.cspViolation = effectiveDirective
},
{once: true},
)
const script = document.createElement("script")
script.textContent = "document.documentElement.dataset.inlineScriptExecuted = 'true'"
document.head.appendChild(script)
script.remove()
})
await expect(page.locator("html")).toHaveAttribute("data-csp-violation", "script-src-elem")
await expect(page.locator("html")).not.toHaveAttribute("data-inline-script-executed")
if (packaged) {
const contract = await app.evaluate(async ({safeStorage}) => ({
available: await safeStorage.isAsyncEncryptionAvailable(),
decrypted: await safeStorage.decryptStringAsync(
await safeStorage.encryptStringAsync("api-contract"),
),
}))
expect(contract.available).toBe(true)
expect(contract.decrypted.result).toBe("api-contract")
expect(typeof contract.decrypted.shouldReEncrypt).toBe("boolean")
const fixture = "packaged-desktop-secret"
await page.evaluate(async value => {
await window.Capacitor.Plugins.DesktopSecureStorage.set({key: "desktop-smoke", value})
}, fixture)
expect((await readFile(join(profile, "secure-storage.bin"))).includes(fixture)).toBe(false)
await app.close()
app = await _electron.launch({
executablePath,
env,
chromiumSandbox: true,
args: [`--user-data-dir=${profile}`],
})
await expect.poll(() => mainWindows().length).toBe(1)
const [relaunched] = mainWindows()
await relaunched.waitForFunction(() =>
Boolean(window.Capacitor?.Plugins?.DesktopSecureStorage),
)
expect(
await relaunched.evaluate(() =>
window.Capacitor.Plugins.DesktopSecureStorage.get({key: "desktop-smoke"}),
),
).toEqual({value: fixture})
}
} finally {
await app.close()
}
} finally {
await rm(profile, {recursive: true, force: true})
}
})

113
e2e/harness/app/boot.ts Normal file
View file

@ -0,0 +1,113 @@
import type {BrowserContext} from "@playwright/test"
import {ms} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import type {TestUser} from "../keys"
import {injectEvents, injectSession} from "./session"
// Must match TEST_ENV_KEY in src/lib/test/env.ts.
const TEST_ENV_KEY = "__TEST_ENV__"
// Set the first time the app reads a value out of the injected env, the only evidence this side
// has that the hook in src/app/env.ts ran.
const TEST_ENV_READ_KEY = "__TEST_ENV_READ__"
export type BootOptions = {
// Every relay list the app reads at startup is pointed here, so it can only dial relays the
// scenario created.
relays: string[]
// What a pubkey's own lists are resolved from, which is a relay of its own only when the
// scenario opened one. Defaults to `relays`.
indexers?: string[]
spaces?: string[]
user?: TestUser
// What this user's client already has in local storage, e.g. their room list.
events?: TrustedEvent[]
path?: string
// VITE_ values the scenario sets for itself, applied over the relay-derived ones below. Anything
// named here has to be something the test owns, or the test fails on a leak.
env?: Record<string, string>
}
export const boot = async (
context: BrowserContext,
{relays, indexers = relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions,
) => {
const urls = relays.join(",")
await context.addInitScript(
([key, readKey, env]) => {
Object.assign(window, {
[key]: new Proxy(env, {
get(target, prop) {
Object.assign(window, {[readKey]: true})
return Reflect.get(target, prop)
},
}),
})
},
[
TEST_ENV_KEY,
TEST_ENV_READ_KEY,
{
VITE_DEFAULT_RELAYS: urls,
VITE_INDEXER_RELAYS: indexers.join(","),
VITE_DEFAULT_SEARCH_RELAYS: urls,
VITE_DEFAULT_MESSAGING_RELAYS: urls,
VITE_SIGNER_RELAYS: urls,
VITE_DEFAULT_SPACES: spaces.join(","),
VITE_PLATFORM_RELAYS: "",
VITE_BLOCKED_RELAYS: "",
// Nothing serves this url, so a push bridge connection is reported as a leak instead of
// blending into the traffic of a relay the scenario did create.
VITE_PUSH_BRIDGE: "ws://localhost:1/",
...env,
},
] as const,
)
if (user) {
await injectSession(context, user)
await injectEvents(context, events)
}
const page = await context.newPage()
await page.goto(path)
// The root layout renders nothing until its async setup block resolves, so the shell appearing
// is the first point at which the app is running. With a session injected, wait for the signed-in
// nav instead. A session the app rejected renders the landing dialog, and failing on that here
// reads far better than the assertions it would break later.
const shell = page.locator(user ? ".primary-nav" : ".fl")
for (let attempt = 0; ; attempt++) {
try {
await shell.waitFor({state: "attached", timeout: ms(15)})
break
} catch (e) {
if (attempt === 3) {
throw e
}
await page.reload()
}
}
// src/app/env.ts reads every VITE_ value as it is imported, so by now the app has resolved them
// against either the env above or .env's real relays.
const usedTestEnv = await page.evaluate(
key => Boolean(Reflect.get(window, key)),
TEST_ENV_READ_KEY,
)
if (!usedTestEnv) {
throw new Error(
"The app read none of its VITE_ values from the harness, so it is pointed at the relays in " +
".env rather than at this scenario's. Check that src/app/env.ts still resolves them " +
"through maybeGetTestEnv, and that the dev server is running in dev mode.",
)
}
return page
}

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