Compare commits

...

298 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
610 changed files with 30767 additions and 9430 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
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
framework built around a single `App` object.
## Overview
`@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
```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
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({
user: await User.fromSigner(signer), // omit for a signed-out app
user, // optional User
config: {
dufflepudUrl: "https://dufflepud.example.com",
getDefaultRelays: () => ["wss://relay.example.com"],
getIndexerRelays: () => ["wss://indexer.example.com"],
getSearchRelays: () => ["wss://search.example.com"],
dufflepudUrl: "https://dufflepud.example", // optional: batches NIP-05/zapper lookups
getDefaultRelays: () => [...],
getIndexerRelays: () => [...], // discovery relays for profiles/relay lists
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 |
|---|---|
| `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 |
`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.
`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 —
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:
A `User` is `{pubkey, signer}`. A `Session` is a serializable `{method, data}` descriptor you persist; session handlers turn it back into a signer.
```typescript
app.use(Profiles).load(pubkey)
app.use(RelayLists).writeUrls(pubkey).get()
import {createApp, User, toSession, nip07} from "@welshman/app"
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 |
|---|---|
| `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) |
`nip55` additionally needs the Capacitor plugin passed to `@welshman/signer` once at startup, or building its signer throws `"Nip55 is not enabled"`:
Derived plugins expose:
```ts
import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin"
import {setNip55Plugin} from "@welshman/signer"
- `index` — `Projection<ItemsByKey<T>>`
- `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
setNip55Plugin(NostrSignerPlugin)
```
A **`Projection<T>` is `{get(): T, $: Readable<T>}`** — bind `.$` in markup, call `.get()` in
callbacks and hot paths. Build new ones with `projection(store)` or `projectFrom(source, read)`.
## Data plugins (reactive collections)
### 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`,
`BlockedRelayLists`, `SearchRelayLists`, `MessagingRelayLists`, `BlossomServerLists`
**People:** `Profiles`, `FollowLists`, `MuteLists`, `Handles`, `Zappers`, `Wot`, `Topics`
**Content:** `Reactions`, `Deletes`, `Pins`, `Pinboards`, `Feeds`, `FeedLists`, `Wraps`
**NIP-29 / membership:** `Rooms`, `RoomLists`, `RoomPinLists`, `RelayMemberLists`, `RelayRoles`
## Sessions and login
A `Session` is `{method, ...data}`, serializable so you can persist it. Handlers convert one into
a signer: `nip01`, `nip07`, `nip46`, `nip55`, `pomade`, plus `registerSessionHandler` for your own.
| Plugin | Data | Notable accessors |
|---|---|---|
| `Profiles` | kind-0 profiles | `display(pk)`, `update(fn)` → `Command`; `profileSearch` |
| `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` |
| `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` |
| `BlockedRelayLists` | kind-10006 | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` |
| `MessagingRelayLists` | kind-10050 (NIP-17 DM relays) | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` |
| `SearchRelayLists` | kind-10007 | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` |
| `BlossomServerLists` | kind-10063 media servers | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` → `Command` |
| `FeedLists` | kind-10014 saved-feed lists | list accessors + `update(fn)` → `Command` |
| `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
import {User, createApp, nip07, toSession} from "@welshman/app"
const session = toSession(nip07, {pubkey})
const user = await User.fromSession(session) // undefined if the handler can't build a signer
import {createApp, Profiles, RelayLists} from "@welshman/app"
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:
- `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:
## Publishing (optimistic thunks)
```typescript
import {Domain, publish} from "@welshman/app"
import {Note} from "@welshman/domain"
import {Thunks, Router} from "@welshman/app"
import {makeEvent, NOTE, userOutbox} from "@welshman/util"
const writer = app.use(Domain).writer(Note).setContent("hello")
const command = await app.use(Domain).command(writer)
command.publish() // to the resolved relays
command.publishToRelays(urls) // to specific relays
command.publishAsRelay(url) // signed by the relay itself (NIP-86)
```
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],
// There's no dedicated outbox helper on Thunks — resolve write relays yourself via the
// 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):
const thunk = app.use(Thunks).publish({
event: makeEvent(NOTE, {content: "hi"}),
relays: await app.use(Router).resolver.relays([userOutbox()]), // Promise<string[]>
delay: 3000, // abortable soft-undo window (ms)
})
const myPolicy: AppPolicy = app => {
const unsubscribe = on(app.repository, "update", handleUpdate)
// To specific relays:
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
that transitively imports your app module, construct the app lazily (on first access) so every
policy has registered by the time it's built.
`ThunkOptions`: `{event, relays?, recipient?, delay?, pow?, ...PublishOptions}` (`app` is injected). Incoming wraps addressed to the user are auto-unwrapped by the default `appPolicyWraps`.
## 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
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
import {Wot, WotScope} from "@welshman/app"
const wot = app.use(Wot)
wot.follows(pubkey).get()
wot.followers(pubkey).get()
wot.network(pubkey).$ // follows-of-follows
wot.followsWhoFollow(pubkey, target).$
wot.wotScore(pubkey, target).$
wot.follows(pk).get() // string[] — who pk follows
wot.mutes(pk).get() // string[] — who pk mutes
wot.followers(pk, WotScope.Follows).get() // string[]
wot.muters(pk, WotScope.Follows).get() // string[]
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
app.use(Feeds).makeFeedController({feed, onEvent, ...})
app.use(Feeds).getPubkeysForScope(scope)
app.use(Feeds).forAuthor(pubkey).$
import {makeIntersectionFeed, makeScopeFeed, makeKindFeed, Scope} from "@welshman/feeds"
import {get} from "svelte/store"
app.use(Sync).pull({relays, filters}) // negentropy: fetch what we're missing
app.use(Sync).push({relays, filters}) // publish what the relay is missing
const controller = app.use(Feeds).makeFeedController({
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`,
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.
Base classes in `plugins/base.ts`:
- **`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
// React
const useStore = <T>(store: Readable<T>): T => {
const [value, setValue] = useState<T>(() => get(store))
import {DerivedPlugin, Network, Domain, User, type IApp} from "@welshman/app"
import {SOME_KIND} from "@welshman/util"
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
- `welshman-domain` — the readers/writers every plugin decodes events with
- `welshman-net` — sockets, adapters, request/publish lifecycle, auth
- `welshman-store` — the repository and the derive helpers plugins are built on
- `welshman-signer` — signer implementations behind `User`
- `welshman-util` — kinds, filters, tag specs, and the `RelaySelection` DSL
- `welshman-store` — the `Repository` and Svelte-store primitives this layer builds on.
- `welshman-domain` — the `Kind`/reader/writer model behind `app.use(Domain)` (event decoding + publishing).
- `welshman-util` — the `RelaySelection` DSL, `Resolver` and `RelayScenario` that `app.use(Router)` dereferences.
- `welshman-net` — request/publish/sockets behind `app.use(Network)`.
- `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 |
| `Newline` | `string` | One or more `\n` characters |
| `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 |
| `LinkGrid` | `{ links: ParsedLinkValue[] }` | Produced by `reduceLinks`; a collection of adjacent block links |
| `Profile` | `ProfilePointer` (`{ pubkey, relays? }`) | nostr:npub / nostr:nprofile / @nostr:npub / @nostr:nprofile references (the `nostr:` prefix is required) |
| `Event` | `EventPointer` (`{ id, relays?, author?, kind? }`) | note / nevent references |
| `Address` | `AddressPointer` (`{ identifier, pubkey, kind, relays? }`) | naddr references |
| `Emoji` | `{ name: string, url?: string }` | `:shortcode:` — `url` resolved from `emoji` tags |
| `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 |
| `Cashu` | `string` | cashu: token strings |
| `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:
```
isAddress isCashu isCode isEllipsis isEmail
isEmoji isEvent isImage isInvoice isLink
isLinkGrid isNewline isProfile isText isTopic
isAddress isCashu isCode isCommand isEllipsis
isEmail isEmoji isEvent isImage isInvoice
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`.
@ -83,7 +86,11 @@ isLinkGrid isNewline isProfile isText isTopic
| `renderEntity(entity)` | `entity.slice(0, 16) + "…"` | Display text for entity links |
| `createElement(tag)` | `document.createElement(tag)` | DOM element factory; override for SSR/non-browser |
Individual per-type render helpers are also exported (`renderText`, `renderLink`, `renderProfile`, `renderEvent`, `renderAddress`, `renderTopic`, `renderEmoji`, `renderCode`, `renderCashu`, `renderInvoice`, `renderEmail`, `renderNewline`, `renderEllipsis`, `renderOne`, `renderMany`).
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
@ -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).
- **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.
- **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.

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. |
| `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. |
| `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
@ -57,8 +58,9 @@ These are drop-in Tiptap node-view factory functions that render inline pill ele
| Export | Description |
|--------|-------------|
| `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char`, `name`, `editor`, `search`, and `select`. Optional: `updateSignal`, `createSuggestion`. |
| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`. Optional: `updateSignal`, `createSuggestion`. |
| `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`. |
| `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. |
**`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 |
| `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) |
| `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 |
| `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`.
@ -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 |
| `NodeViewProps` | `@tiptap/core` — prop type for node view factories (Tiptap's type) |
| `NodeViewRendererProps` | `@tiptap/core` — alternate props type used in `Node.create({ addNodeView })` |
| `UploadTask` | `nostr-editor` — shape of an in-progress or completed file upload |
| `FileAttributes` | `nostr-editor` — `{ file: File, … }` passed to the `upload` callback |
| `editorProps` | `nostr-editor` — base ProseMirror `editorProps` used by nostr-editor; pass directly to `new Editor({ editorProps })` |
@ -138,7 +143,7 @@ import {get, writable} from "svelte/store"
import {Node, Extension, mergeAttributes} from "@tiptap/core"
import {Plugin, PluginKey} from "@tiptap/pm/state"
import type {NodeViewRendererProps} from "@tiptap/core"
import {Profiles, Router, createSearch} from "@welshman/app"
import {Profiles, Router} from "@welshman/app"
import {outbox} from "@welshman/util"
import {
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
@ -187,11 +192,8 @@ export const makeEditor = ({
charCount?: ReturnType<typeof writable<number>>
submit: () => void
}) => {
const profileSearch = createSearch(get(profiles), {
onSearch: searchProfiles,
getValue: (p: any) => p.event.pubkey,
fuseOptions: {keys: ["nip05", "name", "display_name"], threshold: 0.3},
})
// The Profiles plugin maintains a ready-made fuzzy search over known profiles
const profileSearch = get(app.use(Profiles).profileSearch)
const editor = new Editor({
content,
@ -232,7 +234,7 @@ export const makeEditor = ({
addNodeView: () => ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span")
dom.classList.add("mention")
const unsub = deriveProfileDisplay(node.attrs.pubkey)
const unsub = app.use(Profiles).display(node.attrs.pubkey).$
.subscribe($d => { dom.textContent = "@" + $d })
return {
dom, destroy: unsub,
@ -246,7 +248,8 @@ export const makeEditor = ({
MentionSuggestion({
editor: (this as any).editor,
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 => {
const el = document.createElement("span")
el.textContent = pubkey.slice(0, 12) + "…"
@ -324,7 +327,7 @@ const onSubmit = (editor: Editor) => {
## 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`** — 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.
- **`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.

View file

@ -115,11 +115,12 @@ class FeedCompiler {
}
type FeedCompilerOptions = {
router: FeedRouter // REQUIRED — resolves relay selections
getPubkeysForScope: (scope: Scope) => string[]
getPubkeysForWOTRange: (min: number, max: number) => string[]
signer?: ISigner
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
### 1. Simple author + kind feed
@ -156,6 +183,7 @@ import { FeedController, makeIntersectionFeed, makeAuthorFeed, makeKindFeed } fr
import { Scope } from '@welshman/feeds'
const controller = new FeedController({
router, // a FeedRouter — e.g. app.use(Router)
feed: makeIntersectionFeed(
makeAuthorFeed("pubkey1", "pubkey2"),
makeKindFeed(1),
@ -178,6 +206,7 @@ import {
} from '@welshman/feeds'
const controller = new FeedController({
router,
feed: makeIntersectionFeed(
makeScopeFeed(Scope.Follows),
makeWOTFeed({ min: 0.1 }),
@ -206,6 +235,7 @@ import {
// DVMItem.mappings controls how DVM result tags become sub-feeds
const controller = new FeedController({
router,
feed: makeIntersectionFeed(
makeDVMFeed({
kind: 5300,
@ -227,6 +257,7 @@ await controller.load(30)
import { FeedController, makeListFeed, makeKindFeed, makeUnionFeed, FeedType } from '@welshman/feeds'
const controller = new FeedController({
router,
feed: makeUnionFeed(
makeListFeed({
addresses: ["10003:pubkey:identifier"],
@ -255,6 +286,7 @@ const filters = [
const feed = feedFromFilters(filters)
const compiler = new FeedCompiler({
router,
getPubkeysForScope: () => [],
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/signer`** — `ISigner` interface, passed optionally through `FeedCompilerOptions` for DVM requests that require signing.
- **`@welshman/net`** — The `FeedController` delegates to `requestPage` for relay communication. The `FeedCompiler` delegates to `requestDVM` for DVM-based feeds. Neither accepts `request` or `requestDVM` as constructor options. `AdapterContext` from net is passed through `FeedCompilerOptions`.
- **`@welshman/app`** — Higher-level app packages typically wire up `getPubkeysForScope` and `getPubkeysForWOTRange` using their own follow/WOT stores, then construct `FeedController` instances from user-facing feed definitions.
- **`@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.
## 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.
- **`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.

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 |
| `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>`.
@ -54,8 +54,8 @@ bus.emit('login', { pubkey: '...' })
| Export | Description |
|--------|-------------|
| `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()` |
| `simpleCache(getValue)` | Minimal memoization wrapper with default settings |
```typescript
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 |
| `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 |
| `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
@ -160,6 +161,7 @@ displayDomain('relay.damus.io/path') // => 'relay.damus.io'
| `within([low, high], n)` | `n >= low && n <= high` (inclusive) |
| `clamp([min, max], n)` | Constrains `n` to the range |
| `round(precision, x)` | Rounds to `precision` decimal places |
| `toInt(x)` | `parseInt` that returns `undefined` instead of `NaN` — accepts `number \| string \| undefined` |
### Array / Sequence Utilities
@ -250,6 +252,8 @@ type MaybeStr = Maybe<string> // string | undefined
| `ifLet(x, f)` | Calls `f(x)` only if `x` is defined |
| `doLet(x, f)` | Calls `f(x)` and returns the result — scoped binding without a variable |
| `isDefined(x)` / `isUndefined(x)` / `assertDefined(x)` | `undefined` checks (not null) |
| `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
@ -388,7 +392,6 @@ const label = formatTimestampRelative(event.created_at) // "3 hours ago"
```typescript
import { on } from '@welshman/lib'
// Each App owns its repository.
const unsub = on(app.repository, 'update', updates => {
console.log('added', updates.flatMap(u => u.added).length, 'events')
})

View file

@ -1,11 +1,13 @@
---
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` 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
@ -18,18 +20,31 @@ yarn add @welshman/net
## 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
| Export | Description |
|--------|-------------|
| `Pool` | Singleton connection pool; creates and manages `Socket` instances per relay URL |
| `Pool.get()` | Returns the singleton `Pool` instance |
| `pool.get(url)` | Gets or lazily creates a `Socket` for the given relay URL |
| `pool.remove(url)` | Removes and cleans up a socket |
| `pool.subscribe(cb)` | Fires `cb(socket)` each time a new socket is created; returns unsubscriber |
| `Pool` | Connection pool; creates and manages `Socket` instances per relay url. Construct with `new Pool()` |
| `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 a (normalized) relay url |
| `pool.has(url)` | Whether a socket already exists for the url |
| `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 |
| `SocketStatus` | Enum: `Open`, `Opening`, `Closing`, `Closed`, `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 |
### Request
@ -39,20 +54,24 @@ yarn add @welshman/net
| `requestOne(options)` | Subscribe to a single relay; returns `Promise<TrustedEvent[]>` |
| `request(options)` | Subscribe to multiple relays in parallel; returns `Promise<TrustedEvent[]>` |
| `makeLoader(options)` | Creates a batching `load` function with configurable delay/timeout/threshold |
| `load(options)` | Pre-built loader: 200 ms batch delay, 3 s timeout, 0.5 threshold. Simpler than `request()` when you just want events — auto-closes after EOSE, timeout, or disconnect; resolves when half the relays' subscriptions have closed; returns a `Promise<TrustedEvent[]>`. When used with `@welshman/app`, received events auto-flow into the repository and tracker. |
| `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):
- `relay` / `relays` — relay URL(s)
`request` / `requestOne` options (`BaseRequestOptions`):
- `relay` / `relays` — relay url(s)
- `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
- `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)`
`request`-only options:
- `threshold?: number` — fraction of relays that must close before the promise resolves (default `1`)
`request`-only: `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
@ -61,7 +80,7 @@ Without `autoClose` or a `signal`, `requestOne` streams indefinitely — the ret
| `publish(options)` | Publishes to multiple relays; resolves to `PublishResultsByRelay` |
| `publishOne(options)` | Publishes to a single relay; resolves to `PublishResult` |
| `PublishStatus` | Enum: `Sending`, `Pending`, `Success`, `Failure`, `Timeout`, `Aborted` |
| `PublishResult` | `{ relay: string, status: PublishStatus, detail: string }` |
| `PublishResult` | `{status: PublishStatus, detail: string, relay: string}` |
| `PublishResultsByRelay` | `Record<string, PublishResult>` |
`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` |
| `AuthStatus` | Enum: `None`, `Requested`, `PendingSignature`, `DeniedSignature`, `PendingResponse`, `Forbidden`, `Ok` |
| `AuthStateEvent.Status` | Emitted when auth status changes |
| `makeSocketPolicyAuth(options)` | Creates a socket policy that auto-handles auth challenges |
| `defaultSocketPolicies` | Mutable array of policies applied to every new socket |
| `makeSocketPolicyAuth(options)` | Creates a socket policy that auto-handles auth challenges. Options: `{sign, shouldAuth?}` |
### Policies
A `SocketPolicy` is `(socket: Socket) => Unsubscriber`, run once per socket at creation.
| Export | Description |
|--------|-------------|
| `socketPolicyPing` | Sends a PING frame every 30 s when the socket is open and idle, to keep the connection alive |
| `socketPolicyAuthBuffer` | Buffers outgoing messages during auth and replays after success |
| `socketPolicyAuthBuffer` | Buffers outgoing messages during auth and replays them once it completes |
| `socketPolicyConnectOnSend` | Auto-opens closed sockets when a message is queued |
| `socketPolicyCloseInactive` | Closes idle sockets after 30 s (when no pending work remains); if the socket closes with pending work it delays and reopens, replaying queued messages |
| `defaultSocketPolicies` | Array of the four above; passed to every socket created by `Pool` |
| `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` | `[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
| Export | Description |
|--------|-------------|
| `Repository` | In-memory indexed event store with delete/expiry support |
| `Repository.get()` | Returns the singleton instance |
| `repository.publish(event)` | Stores an event; returns `false` if duplicate/stale |
| `repository.query(filters, opts?)` | Returns matching `TrustedEvent[]` sorted by `created_at` desc |
| `Repository` | In-memory indexed event store with delete/expiry support. Construct with `new Repository()` |
| `repository.publish(event, {shouldNotify?})` | Stores an event; returns `false` if duplicate/stale |
| `repository.query(filters, {shouldSort?})` | Returns matching `TrustedEvent[]`, sorted by `created_at` desc unless disabled |
| `repository.getEvent(idOrAddress)` | Look up by id or NIP-01 address (`kind:pubkey:d`) |
| `repository.isDeleted(event)` | `true` if a kind-5 delete covers this event |
| `repository.dump()` | Returns all stored events as `TrustedEvent[]` |
| `repository.load(events)` | Bulk-replaces all stored events; emits a single `"update"` diff. Events with `event[verifiedSymbol] = true` skip signature re-verification. |
| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — conventional URL for the local repository |
| `RepositoryUpdate` | `{ added: TrustedEvent[], removed: Set<string> }` — payload of `"update"` events |
| `repository.hasEvent(event)` | Whether the event (or a newer replacement) is already stored |
| `repository.removeEvent(idOrAddress)` | Drops an event and unwinds its index entries |
| `repository.isDeleted(event)` | `true` if a kind-5 delete covers this event (`isDeletedById` / `isDeletedByAddress` check one path each) |
| `repository.isExpired(event)` | `true` past the event's NIP-40 `expiration` |
| `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 |
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
| Export | Description |
|--------|-------------|
| `Tracker` | Bidirectional map of `eventId ↔ Set<relayUrl>` |
| `tracker.track(eventId, relay)` | Records relay; returns `true` if the event was already seen |
| `tracker.getRelays(eventId)` | Set of relay URLs that have sent this event |
| `Tracker` | Bidirectional map of `eventId ↔ Set<relayUrl>` (`relaysById` / `idsByRelay`) |
| `tracker.track(eventId, relay)` | Records the relay; returns `true` if the event was already seen |
| `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.copy(id1, id2)` | Copies relay associations from one id to another (used for gift wraps) |
| `tracker.load(relaysById)` | Bulk-replaces all relay mappings from a `Map<string, Set<string>>`; emits `"load"` |
| `tracker.clear()` | Removes all relay mappings; emits `"clear"` |
| `tracker.load(relaysById)` | Bulk-replaces all mappings from a `Map<string, Set<string>>`; emits `"load"` |
| `tracker.clear()` | Removes all mappings; emits `"clear"` |
### Adapters
| 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 |
| `LocalAdapter` | In-memory relay adapter |
| `LocalAdapter` | In-memory adapter over a `Repository` |
| `MockAdapter` | Test adapter with manual send control |
| `AbstractAdapter` | Base class for custom adapters |
| `AdapterEvent.Receive` | Emitted when a relay message arrives |
### Context
| Export | Description |
|--------|-------------|
| `NetContext` | `{ 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)
| 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 |
| `push(options)` | Publishes events you have that relays don't |
| `Difference` | Low-level per-relay negentropy session |
@ -153,27 +168,43 @@ instance (`app.netContext`), and `app.use(Network)` supplies it for you.
| Export | Description |
|--------|-------------|
| `RelayMessageType` | Enum of relay→client message types |
| `ClientMessageType` | Enum of client→relay message types |
| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, etc. | Type guards for relay messages |
| `isClientReq()`, `isClientEvent()`, etc. | Type guards for client messages |
| `RelayMessageType` / `ClientMessageType` | Enums of relay→client and client→relay message types |
| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, `isRelayClosed()`, … | Type guards for relay messages |
| `isClientReq()`, `isClientEvent()`, `isClientClose()`, `isClientAuth()`, … | Type guards for client messages |
| `matchReason(prefix, reason)` / `RelayReasonPrefix` | Match a relay's machine-readable `OK`/`CLOSED` reason prefix (`auth-required:`, `restricted:`, …) |
### WrapManager
| Export | Description |
|--------|-------------|
| `WrapManager` | Tracks NIP-59 gift wrap → rumor relationships; stores decrypted rumors in the repository and copies relay tracking from the wrap to the rumor |
| `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
### 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
```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')
socket.on(SocketEvent.Status, (status: SocketStatus) => {
@ -187,10 +218,12 @@ socket.send(['REQ', 'my-sub', {kinds: [1], limit: 10}])
### Load events (one-shot, batched)
```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({
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
filters: [{kinds: [0], authors: ['<pubkey>']}],
@ -203,14 +236,15 @@ const events = await load({
import {request} from '@welshman/net'
import {now} from '@welshman/lib'
// Without autoClose this will stream forever.
// The returned promise never settles unless all relays close the subscription.
// Without autoClose this streams forever; the returned promise never settles
// unless all relays close the subscription.
const ctrl = new AbortController()
request({
relays: ['wss://relay.example.com'],
filters: [{kinds: [1], since: now()}],
signal: ctrl.signal,
context,
onEvent: (event, url) => console.log(event.id, 'from', url),
})
@ -227,6 +261,7 @@ const results = await publish({
event: signedEvent,
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
timeout: 5000,
context,
onSuccess: r => console.log('accepted by', r.relay),
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
import {defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net'
import {Pool, defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net'
import type {StampedEvent} from '@welshman/util'
// Call once at app startup, before any sockets are opened.
defaultSocketPolicies.push(
const pool = new Pool()
pool.socketPolicies = [
...defaultSocketPolicies,
makeSocketPolicyAuth({
sign: (event: StampedEvent) => mySigner.sign(event),
shouldAuth: (socket) => true, // auth on every relay
shouldAuth: socket => true, // auth on every relay
}),
)
]
```
### Custom socket policies
A `SocketPolicy` is `(socket: Socket) => Unsubscriber`. It receives the socket when it is created, attaches listeners or patches socket methods, and returns a cleanup function. Push custom policies onto `defaultSocketPolicies` before any sockets are opened.
A policy receives the socket when it is created, attaches listeners or patches socket methods, and returns a cleanup function.
```typescript
import {writable} from 'svelte/store'
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'
// Track how many events each relay has delivered this session
export const eventCountByRelay = writable<Record<string, number>>({})
const eventCountPolicy = (socket: Socket) => {
const unsub = on(socket, SocketEvent.Receive, (message: RelayMessage) => {
const eventCountPolicy = (socket: Socket) =>
on(socket, SocketEvent.Receive, (message: RelayMessage) => {
if (isRelayEvent(message)) {
eventCountByRelay.update(counts => ({
...counts,
@ -276,13 +315,10 @@ const eventCountPolicy = (socket: Socket) => {
}
})
return unsub // called when the socket is destroyed
}
defaultSocketPolicies.push(eventCountPolicy)
pool.socketPolicies = [...pool.socketPolicies, 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)
@ -300,7 +336,8 @@ class MyAdapter extends AbstractAdapter {
get sockets() { return [] }
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]}],
autoClose: true,
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
Pass `LOCAL_RELAY_URL` as a relay to the standard net functions so local operations go through the same pipeline as remote ones (policies, deduplication, tracker):
```typescript
import {load, publish, request, LOCAL_RELAY_URL} from '@welshman/net'
import {publish, request, LOCAL_RELAY_URL} from '@welshman/net'
import {now} from '@welshman/lib'
// Read from the local repository the same way you'd read from a remote relay
@ -332,27 +370,24 @@ const events = await load({
await publish({
event: signedEvent,
relays: [LOCAL_RELAY_URL, 'wss://relay.example.com'],
context,
})
// Subscribe to new local events in real time
request({
relays: [LOCAL_RELAY_URL],
filters: [{kinds: [1], since: now()}],
onEvent: (event) => console.log('new local event', event.id),
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)
```typescript
import {Repository} from '@welshman/net'
import {verifiedSymbol} from '@welshman/util'
import type {TrustedEvent} from '@welshman/util'
const repo = Repository.get()
// Mark events as already-verified so welshman skips signature checks
const storedEvents: TrustedEvent[] = await loadFromStorage()
for (const event of storedEvents) {
@ -360,23 +395,21 @@ for (const event of storedEvents) {
}
// Replaces all in-memory events in one pass; emits a single "update"
repo.load(storedEvents)
repository.load(storedEvents)
```
### Startup: bulk-load Tracker state
```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>>()
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))
}
}
// Takes Map<string, Set<string>> — same shape as tracker.relaysById
// Takes Map<string, Set<string>> — the same shape as tracker.relaysById
tracker.load(relaysById)
```
@ -384,7 +417,6 @@ tracker.load(relaysById)
```typescript
import {on, batch} from '@welshman/lib'
// `app.repository`, or `new Repository()` when using @welshman/net standalone
import type {RepositoryUpdate} from '@welshman/net'
import type {TrustedEvent} from '@welshman/util'
@ -415,28 +447,30 @@ on(
## Integration Notes
- **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent` (already verified).
- **`@welshman/lib`** — utility helpers (`Emitter`, `batcher`, `defer`, `on`, etc.) used internally; `Emitter` (from `@welshman/lib`) is the base class for `Tracker`, `Repository`, and `WrapManager`. `Socket`, `AuthState`, `AbstractAdapter`, and `Difference` extend node's built-in `EventEmitter` directly.
- **`@welshman/app`** — wraps `@welshman/net` 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`.
- **`NetContext`** — passed explicitly per call. Each `App` owns its own pool and repository, which keeps one identity's data out of another's.
- **`@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`, …). `Emitter` is the base class for `Tracker`, `Repository` and `WrapManager`; `Socket`, `AuthState`, `AbstractAdapter` and `Difference` extend node's `EventEmitter` directly.
- **`@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.
---
## 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.
- **`load()` sets `autoClose: true` internally** and uses a 0.5 relay threshold; it resolves when half the relays' subscriptions have closed (typically after EOSE, timeout, or disconnect) — useful when some relays are slow or offline.
- **Relay URL normalization** happens inside `Pool.get(url)` via `normalizeRelayUrl`. Pass raw URLs everywhere; the pool handles canonicalization.
- **`defaultSocketPolicies` is mutable.** Push policies before any sockets are created. Sockets created before a policy is pushed will not have it applied.
- **`socketPolicyCloseInactive` only replays pending work on unexpected close.** It reopens and replays queued messages when a socket closes while work is pending — it does not proactively open sockets when new work is queued (that is `socketPolicyConnectOnSend`'s job). After `pool.remove(url)` the socket is cleaned up including its policy listeners, so `socketPolicyCloseInactive` can no longer reopen it.
- **`Pool.get(url)` lazily creates a new socket on every call after `pool.remove(url)`.** Calling `pool.remove(url)` forgets the URL and cleans up the socket — any subsequent `pool.get(url)` will construct a fresh socket. Call `pool.remove()` only when you want the pool to forget the URL entirely, not merely to disconnect temporarily.
- **`Tracker` is shared across relays in `request()`.** This means `onDuplicate` fires for events received from more than one relay — expected behavior for cross-relay deduplication.
- **`Repository.publish()` returns `false` for stale replaceable events.** If a newer version of a replaceable event is already stored, the older one is silently dropped.
- **`WrapManager` stores the decrypted rumor in the `Repository`** and copies relay tracking from the gift-wrap event id to the rumor id. Keep a reference to the `WrapManager` instance alongside your `Repository` and `Tracker` singletons.
- **`makeSocketPolicyAuth` requires a `sign` function** that returns a `Promise<SignedEvent>`. If the user cancels signing, have the `sign` function throw or reject; `doAuth` will catch the failure via `tryCatch` and automatically transition to `AuthStatus.DeniedSignature`, preventing infinite retry loops.
- **Each filter in `filters` array generates a separate REQ** inside `requestOne`. For large filter arrays consider merging them with `unionFilters` from `@welshman/util` before calling `request`.
- **`repository.load()` replaces all events, not appends.** It clears internal indexes first, then re-inserts every event. Emit a single batched `"update"` diff — do not call it repeatedly for incremental updates; use `repository.publish(event)` for that.
- **`RepositoryUpdate.removed` is `Set<string>`, not an array.** Iterate with `for...of` or `Array.from(removed)`. The `batch()` helper from `@welshman/lib` delivers updates as `RepositoryUpdate[]` to your flush callback — merge them yourself or use `mergeRepositoryUpdates`.
- **`tracker.load()` takes `Map<string, Set<string>>`** (the same type as `tracker.relaysById`). Load it after `repository.load()` so you can filter out orphaned event ids.
## Related skills
- `welshman-app` — the instance-based layer that owns these primitives (`app.netContext`, `app.use(Network)`, `app.use(Sync)`).
- `welshman-store` — Svelte derivations over a `Repository` and `Tracker`.
- `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.

View file

@ -25,25 +25,34 @@ yarn add @welshman/signer
The common contract all signers implement.
```typescript
import type { ISigner, SignOptions, SignWithOptions } from '@welshman/signer'
import type {ISigner, SignOptions, EncryptionImplementation} from '@welshman/signer'
interface ISigner {
sign: (event: StampedEvent, options?: SignOptions) => Promise<SignedEvent>
sign: SignWithOptions
nip04: EncryptionImplementation
nip44: EncryptionImplementation
getPubkey: () => Promise<string>
nip04: {
encrypt: (pubkey: string, message: string) => Promise<string>
decrypt: (pubkey: string, message: string) => Promise<string>
}
nip44: {
encrypt: (pubkey: string, message: string) => Promise<string>
decrypt: (pubkey: string, message: string) => Promise<string>
}
cleanup?: () => Promise<void>
}
type SignOptions = { signal?: AbortSignal }
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)
| Export | Description |
@ -73,13 +82,22 @@ type SignOptions = { signal?: AbortSignal }
### 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 |
|---|---|
| `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 |
Requires the peer dependency: `npm install nostr-signer-capacitor-plugin`
### Nip59 (Gift Wrap)
| 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/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.
## Gotchas & Tips
- **`Nip07Signer` is browser-only.** Do not instantiate it in SSR or Node environments; always guard with `getNip07()` first.
- **`Nip55Signer` requires Capacitor.** It will not work in a plain browser build. Only use it in a Capacitor-wrapped mobile app after confirming `getNip55()` returns apps.
- **`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.
- **`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.

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` |
| `makeDeriveEvent(options)` | Factory returning `(idOrAddress: string) => Readable<TrustedEvent \| undefined>` for single-event lookups |
| `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`):
```typescript
@ -55,13 +78,6 @@ const deriveEvent = makeDeriveEvent({ repository })
const eventStore = deriveEvent(someIdOrAddress) // Readable<TrustedEvent | undefined>
```
`deriveEventsAsc` / `deriveEventsDesc` take a map store, not an array store:
```typescript
// correct: pass the Readable<Map<string, TrustedEvent>> directly
const notesAsc = deriveEventsAsc(noteEventsById)
const notesDesc = deriveEventsDesc(noteEventsById)
```
### Indexed collections
| Export | Description |
@ -73,6 +89,17 @@ const notesDesc = deriveEventsDesc(noteEventsById)
| `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 |
`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:
```typescript
{
@ -88,7 +115,8 @@ const notesDesc = deriveEventsDesc(noteEventsById)
| 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` |
`StorageProvider` interface:
@ -110,7 +138,16 @@ interface StorageProvider {
| Export | Description |
|---|---|
| `getter<T>(store, options?)` | Returns `() => T`; auto-switches from `get()` to a subscription when call frequency exceeds `threshold` (default 10/s) |
| `withGetter<T>(store)` | Adds a `.get()` method to a `Readable` or `Writable` store |
| `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
@ -138,21 +175,22 @@ notes.subscribe($notes => {
### 2. Profiles indexed by pubkey
```typescript
import { parseJson } from "@welshman/lib"
import { Repository } from "@welshman/net"
import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store"
import { PROFILE } from "@welshman/util"
import { Profile, type ProfileReader } from "@welshman/domain"
import { PROFILE, type TrustedEvent } from "@welshman/util"
const repository = new Repository()
// Decoding is @welshman/domain's job — configure the kind, then use its reader as eventToItem.
const readProfile = Profile.configure({}).reader
type Profile = { event: TrustedEvent; name?: string }
const profilesByPubkey = deriveItemsByKey<ProfileReader>({
const profilesByPubkey = deriveItemsByKey<Profile>({
repository,
filters: [{ kinds: [PROFILE] }],
eventToItem: event => readProfile(event).parse(),
getKey: profile => profile.author(),
// eventToItem decodes each event; here we parse the profile's JSON content.
// 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
@ -163,7 +201,7 @@ const deriveProfile = makeDeriveItem(profilesByPubkey)
const aliceProfile = deriveProfile("alice-pubkey-hex")
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`
(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
on-demand network loading. `@welshman/app` packages it as `DerivedPlugin` — prefer subclassing
that over hand-rolling the chain unless you need something it doesn't cover.
on-demand network loading.
```typescript
import {
@ -243,8 +280,7 @@ import {
makeDeriveItem,
} from "@welshman/store"
import { Network, Router } from "@welshman/app"
import { outbox } from "@welshman/util"
import { relayTags, tagSpec, tagValue, tagValues } from "@welshman/util"
import { outbox, tagSpec, tagValue, tagValues } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util"
const BOOKMARK_KIND = 30003
@ -259,13 +295,13 @@ type Bookmark = {
const parseBookmark = (event: TrustedEvent): Bookmark => ({
pubkey: event.pubkey,
title: tagValue(tagSpec("title"), event.tags) ?? "Untitled",
urls: tagValues(relayTags("r"), event.tags),
urls: tagValues(tagSpec("r"), event.tags),
event,
})
// Step 1: Reactive Map<pubkey, Bookmark> — live-updates from repository
const bookmarksByPubkey = deriveItemsByKey<Bookmark>({
repository: app.repository,
repository,
filters: [{ kinds: [BOOKMARK_KIND] }],
getKey: b => b.pubkey,
eventToItem: parseBookmark,
@ -304,18 +340,18 @@ aliceBookmark.subscribe($b => console.log($b?.title))
## Integration Notes
- **`@welshman/net`** — provides `Repository` and `Tracker`. `Repository` is the event cache that feeds all store primitives in this package. Events flow from the network into the repository, which triggers store updates automatically.
- **`@welshman/util`** — provides `TrustedEvent`, `Filter`, and the tag specs (`tagValue`, `hexTags`, …) used when decoding events.
- **`@welshman/domain`** — the typed readers you normally pass as `eventToItem`, instead of writing a parser by hand.
- **`@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/app`** — the high-level app layer re-exports and composes store utilities with pre-configured repositories, loaders, and context. If you are using `@welshman/app`, many of these stores are already wired up for you.
- Stores in this package are **framework-agnostic** at runtime (plain Svelte stores), so they work in SvelteKit SSR as well as browser-only Svelte apps. The `synced` store's `localStorageProvider` is browser-only — guard it with `if (browser)` in SvelteKit.
## Gotchas & Tips
- **`eventToItem` can return `null`/`undefined`** — returning a falsy value from `eventToItem` in `deriveItemsByKey` causes that event to be skipped. Use this to filter out malformed events (e.g. `event.tags.length > 1 ? readList(event) : null`).
- **`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.
- **`throttled(0, store)` is a no-op** — it returns the original store unchanged, so it is safe to call with a user-configurable delay that may be zero.
- **`makeDeriveItem` is a factory** — call it once to create the lookup function, then call the returned function with a key to get a per-key `Readable`. Do not call `deriveItemsByKey` inside a Svelte `$:` block repeatedly; derive once at module level and pass the store down.
- **`makeLoadItem` timeout is in seconds** — the `timeout` option is compared against `now()` from `@welshman/lib`, which returns Unix time in seconds. The default is `3600` (one hour). Use `{ timeout: 30 }` for a 30-second staleness window, not `30_000`.
- **`makeLoadItem` uses exponential backoff** — repeated calls for the same key that already has a fresh result (item exists AND was fetched within the timeout window) are returned from cache without re-fetching. If the timeout has elapsed, it will re-fetch even if a previous value exists. Use `makeForceLoadItem` when you explicitly need fresh data.
- **`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` 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`.
- **`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
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` 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
@ -44,15 +44,41 @@ yarn add @welshman/util
| `getIdOrAddress(event)` | Returns address string for replaceable events, id otherwise |
| `getIdAndAddress(event)` | Returns array with both id and address (if applicable) |
| `deduplicateEvents(events)` | Deduplicate by id or address |
| `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) |
| `isReplaceable(event)` | True for plain or parameterized replaceable |
| `isPlainReplaceable(event)` | True for kinds 10000–19999 and metadata/contacts |
| `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
`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)
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_DELETE_EVENT = 9005 ROOM_EDIT_STATUS = 9006
ROOM_CREATE_PERMISSION = 19004
ROOM_UPDATE_PINS = 9010 ROOM_PINS = 39005
RELAY_MEMBERS = 13534 RELAY_ADD_MEMBER = 8000 RELAY_REMOVE_MEMBER = 8001
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)**
@ -190,7 +230,7 @@ ALERT_ANDROID = 32833 ALERT_IOS = 32834
**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
LIGHTNING_PUB_RPC = 21000
OTS = 1040
@ -270,16 +310,20 @@ isDVMKind(kind) // 5000–7000
| Export | Description |
|--------|-------------|
| `tagSpec(keys, matchValue?, normalize?)` | Build a spec: which tag keys, plus optional validation/normalization |
| `hexTags(keys)` | Spec for 32-byte hex values (`e`, `p`, …) |
| `addressTags(keys)` | Spec for `kind:pubkey:d` addresses (`a`, `A`) |
| `kindTags(keys)` | Spec for kind numbers (`k`) |
| `topicTags(keys)` | Spec for topics, normalized (`t`) |
| `relayTags(keys)` | Spec for relay urls (`r`, `relay`) |
| `tagValue(spec, tags)` | Value (index 1) of the first matching tag |
| `tagValues(spec, tags)` | Values of all matching tags |
| `matchTag(spec, tags)` / `matchTags(spec, tags)` | The matching tag(s) themselves |
| `tagMatcher(spec)` | A `(tag) => boolean` predicate, for filtering in one pass |
| `tagSpec(keys, matchValue?, normalizeValue?)` | Build a `TagSpec` — `keys` is a string or string[]; optional value filter/normalizer |
| `hexTags(keys)` | Spec matching 32-byte hex values (`isHex32`) — e/p tags |
| `addressTags(keys)` | Spec matching replaceable addresses (`Address.isAddress`) — a tags |
| `relayTags(keys)` | Spec matching relay urls (`isRelayUrl`) — r/relay tags |
| `topicTags(keys)` | Spec that strips a leading `#` from values — t tags |
| `kindTags(keys)` | Spec whose values parse to `number` — k tags |
| `matchTags(spec, tags)` | All tags matching the spec — spec first, then the tags array |
| `matchTag(spec, tags)` | First tag matching the spec, or `undefined` |
| `tagValues(spec, tags)` | Values (index 1, normalized) of all matching tags; undefined dropped |
| `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
@ -307,27 +351,166 @@ isDVMKind(kind) // 5000–7000
| `Address.from(s, relays?)` | Parse from `kind:pubkey:identifier` string |
| `Address.fromNaddr(naddr)` | Parse from NIP-19 naddr |
| `Address.fromEvent(event, relays?)` | Create from addressable event |
| `address.toString()` | Serialize to `kind:pubkey:identifier` |
| `address.toNaddr()` | Serialize to NIP-19 naddr |
| `getAddress(event)` | Convenience: get address string from event |
### Relay
| Export | Description |
|--------|-------------|
| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — the conventional url for the in-memory repository |
| `isRelayUrl(url)` | Validate relay URL |
| `isShareableRelayUrl(url)` | True if valid relay URL and not a local address |
| `isOnionUrl(url)` | Tor address check |
| `isLocalUrl(url)` | Local address check |
| `isIPAddress(url)` | IP address check |
| `normalizeRelayUrl(url)` | Normalize to standard wss:// format |
| `normalizeRelayUrl(url)` | Normalize to standard wss:// format (passes `LOCAL_RELAY_URL` through unchanged) |
| `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 |
|--------|-------------|
| `getLnUrl(address)` | Convert lightning address or URL to LNURL; returns `undefined` if invalid |
| `getInvoiceAmount(bolt11)` | Extract millisatoshi amount from BOLT11 invoice |
| `hrpToMillisat(hrpString)` | Convert human-readable BTC amount to millisats (`bigint`) |
| `Selection` | `{ weight: number; relays: string[] }` — a concrete, resolved weighted relay set |
| `makeSelection(relays, weight?)` | Build a `Selection`, filtering `isRelayUrl` and normalizing each url |
| `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
@ -357,17 +540,11 @@ makeHttpAuthHeader(event: SignedEvent): string // Returns "Nostr <base64>"
```typescript
sendManagementRequest(url: string, request: ManagementRequest, authEvent: SignedEvent): Promise<ManagementResponse>
// ManagementResponse = { result?: any; error?: string }
// ManagementMethod enum covers: BanPubkey, AllowPubkey, BanEvent, AllowEvent, etc.
```
### Handlers (NIP-89)
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
readHandlers(event: TrustedEvent): Handler[]
getHandlerKey(handler: Handler): string // "kind:address" format
getHandlerAddress(event: TrustedEvent): string | undefined
displayHandler(handler?: Handler, fallback?: string): string
```
`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.
### Links
@ -439,7 +616,7 @@ for (const event of storedEvents) {
event[verifiedSymbol] = true
}
repository.load(storedEvents)
app.repository.load(storedEvents)
```
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
```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
// validate the value, so malformed tags are skipped rather than silently returned.
// A selector takes a spec FIRST, then the tags array
const title = tagValue(tagSpec('title'), event.tags) // string | undefined
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
const topics = tagValues(topicTags('t'), event.tags) // string[], normalized
const urls = tagValues(tagSpec('r'), event.tags) // string[]
// Multiple keys at once
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
@ -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/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/domain`** — builds its typed readers on the tag specs (`tagValue`, `hexTags`, `addressTags`) defined here.
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here, and supplies the encryption used when writing encrypted list kinds.
- **`@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/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; 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.
- **`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.
- **`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.
- **`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).
- **`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`.
---
## 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 |
|---|---|
| `@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/net` | Relay connections, request/publish lifecycle, and auth handling |
| `@welshman/domain` | A typed Reader/Writer pair per event kind, so you never hand-parse tags |
| `@welshman/store` | Svelte stores and a Repository for indexing/querying nostr events client-side |
| `@welshman/net` | Relay connections, request/publish lifecycle, auth, and the `Repository`/`Tracker`/`WrapManager` stores |
| `@welshman/store` | Svelte store primitives over a `Repository` — live event and domain-object collections, cached loaders, persistence |
| `@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/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/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:
- **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util`
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/store`, `@welshman/signer`, `@welshman/domain`
- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/app`
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/store`, `@welshman/signer`
- **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`
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
needs — repository, socket pool, tracker, wrap manager — so data never bleeds across sessions.
## Getting started
```typescript
import {createApp, Network, Profiles, User} from "@welshman/app"
Install only what you need:
// `createApp` = `new App` plus the default policies (ingest, relay stats, gift-wrap unwrapping)
const app = createApp({
user: await User.fromSigner(signer), // omit for a signed-out app
config: {
getDefaultRelays: () => ["wss://relay.example.com"],
getIndexerRelays: () => ["wss://indexer.example.com"],
},
})
```bash
# Full application framework (includes app, net, store, signer, feeds, domain)
npm i @welshman/app
app.use(Profiles).load(pubkey) // plugins are per-app singletons, constructed on demand
app.use(Network).load({relays, filters})
# Or assemble manually for more control
npm i @welshman/util @welshman/net @welshman/signer
```
Three rules follow from this design:
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.
If you're building a conventional nostr web client, use `@welshman/app` for batteries-included functionality. For more advanced usage, use the lower-level modules without `app` for more control.
## Key nostr concepts
@ -79,67 +64,72 @@ instance. That binding layer is app-specific and deliberately not part of welshm
| Goal | Package(s) to use |
|---|---|
| Fetch notes from relays | `app.use(Network)`, or `@welshman/net` directly for low-level control |
| Select which relays to use | `RelaySelection` helpers in `@welshman/util` + `app.use(Router)` |
| Read or write a specific kind | `@welshman/domain` via `app.use(Domain)` |
| Sign and publish events | `@welshman/signer` + `Command` from `@welshman/app` |
| Build a feed UI | `@welshman/feeds` + `app.use(Feeds)` |
| Fetch notes from relays | `@welshman/net` (low-level) or `@welshman/app` (high-level) |
| Compose typed events (notes, profiles, lists) | `@welshman/domain` |
| Select which relays to read from / publish to | `@welshman/util` (routing DSL) + `@welshman/app` (Router plugin) |
| Sign and publish events | `@welshman/domain` + `@welshman/app`, or `@welshman/signer` + `@welshman/net` |
| Build a feed UI | `@welshman/feeds` + `@welshman/app` |
| Parse note text and media | `@welshman/content` |
| Embed a composer / editor | `@welshman/editor` |
| Cache nostr events client-side | `@welshman/store` + `app.repository` |
| Core event/filter/tag utilities | `@welshman/util` |
| Cache nostr events client-side | `@welshman/net` (`Repository`) + `@welshman/store` (reactive views over it) |
| Core event/filter utilities | `@welshman/util` |
| Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` |
## App example
### App Example
```typescript
import {createApp, Domain, Profiles, User} from "@welshman/app"
import {Note} from "@welshman/domain"
import {Nip07Signer} from "@welshman/signer"
import { Nip07Signer } from "@welshman/signer"
import { Note } from "@welshman/domain"
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({
user: await User.fromSigner(signer),
user,
config: {
getDefaultRelays: () => ["wss://relay.example.com"],
getDefaultRelays: () => ["wss://relay.example.com", "wss://relay2.example.com"],
getIndexerRelays: () => ["wss://indexer.example.com"],
},
})
// 2. Read the user's profile (loads from their write relays if not cached)
const profile = await app.use(Profiles).load(app.user!.pubkey)
// 2. Hydrate the repository from storage and flush changes back to it.
// 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 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:
`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.
### Lower-level Example
```typescript
import {AbstractAdapter, isClientEvent, publish, request} from "@welshman/net"
import type {ClientMessage, NetContext} from "@welshman/net"
import {call, sleep} from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer"
import {makeEvent, NOTE} from "@welshman/util"
import { AbstractAdapter, ClientMessage, isClientEvent, publish, request } from '@welshman/net'
import type { NetContext } from '@welshman/net'
import { call, sleep } from '@welshman/lib'
import { Nip01Signer } from '@welshman/signer'
import { makeEvent, NOTE } from '@welshman/util'
const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
const RELAY_URL = "bogus.relay"
// 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 {
get sockets() { 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 = {
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 () => {
while (true) {
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})
}
})
// Meanwhile, listen for pings and quote-note with a pong
call(async () => {
request({
relays: [RELAY_URL],
filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}],
context,
filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}],
onEvent: async (ping, url) => {
const pong = await pongSigner.sign(
makeEvent(NOTE, {content: "pong", tags: [["q", ping.id, RELAY_URL, ping.pubkey]]}),
makeEvent(NOTE, {content: 'pong', tags: [["q", ping.id, RELAY_URL, ping.pubkey]]})
)
await publish({event: pong, relays: [RELAY_URL], context})

4
.env
View file

@ -12,7 +12,7 @@ VITE_PLATFORM_RELAYS=
VITE_PLATFORM_LOGEE=be523f2d2255fa96b281233ba9df60d63ef74f874bea96651edd4e1f79785703
VITE_PLATFORM_ACCENT="#7161FF"
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_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
@ -20,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_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_SIGNER_RELAYS=ephemeral.snowflare.cc,bucket.coracle.social
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=

View file

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

View file

@ -31,5 +31,54 @@ jobs:
- 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

@ -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/*'

11
.gitignore vendored
View file

@ -12,6 +12,7 @@ vite.config.ts.timestamp-*
/playwright/.cache/
# Generated assets
static/desktop-logo.png
static/favicon.ico
static/pwa-64x64.png
static/pwa-192x192.png
@ -20,6 +21,11 @@ static/apple-touch-icon-180x180.png
static/maskable-icon-512x512.png
src/assets/icons/*.webp
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
ios/App/public/
@ -28,6 +34,9 @@ ios/App/Podfile.lock
ios/DerivedData/
android/app/src/main/assets/public/
# F-Droid preparation output
.fdroid/
# Web/JavaScript
node_modules/
.pnpm-store/
@ -67,7 +76,6 @@ local.properties
proguard/
google-services.json
GoogleService-Info.plist
ic_stat_notify.png
# IDEs and editors
.roo
@ -81,6 +89,7 @@ CLAUDE.md
.DS_Store
Thumbs.db
package-lock.json
!electron/package-lock.json
# fragua runtime — never commit these
.fragua/runs/

View file

@ -126,13 +126,16 @@ callbacks and hot paths.
**CRITICAL Code Style Guidelines:**
- **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
- 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.
- 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 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
- 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.
- 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)`.
@ -170,6 +173,9 @@ callbacks and hot paths.
2. Use `+page.svelte` for page component
3. Use `+layout.svelte` for shared layouts
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
@ -181,7 +187,7 @@ callbacks and hot paths.
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)`
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)
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`
- Pass component objects with parameters
- 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
@ -223,7 +235,7 @@ See `.env.template` for all options.
**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)
- PWA: Progressive Web App with service worker

View file

@ -1,5 +1,32 @@
# 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

229
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
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**
- `VITE_PLATFORM_URL` - The url where the app will be hosted
@ -14,9 +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_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_ABOUT` - URL to your marketing or about page
- `VITE_PLATFORM_TERMS` - URL to your terms of service 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**
- `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.
@ -26,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_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_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
**Infrastructure**
@ -43,11 +68,199 @@ If you're deploying a custom version of flotilla, be sure to remove the `plausib
## 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
To run your own Flotilla, it's as simple as:
To run your own Flotilla:
```sh
pnpm install
@ -67,3 +280,7 @@ Alternatively, you can copy the build files into a directory of your choice and
mkdir ./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: '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 {
namespace = "social.flotilla"
compileSdk = rootProject.ext.compileSdkVersion
@ -8,8 +12,8 @@ android {
applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 51
versionName "1.9.1"
versionCode 52
versionName "1.11.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// 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:!*~'
}
}
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 {
release {
if (releaseKeystore) {
signingConfig signingConfigs.release
}
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}

View file

@ -38,6 +38,10 @@
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<provider

View file

@ -1,7 +1,9 @@
package social.flotilla.notifications
import android.Manifest
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
@ -11,16 +13,22 @@ import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import com.getcapacitor.JSObject
import com.getcapacitor.PermissionState
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import com.getcapacitor.annotation.Permission
import com.getcapacitor.annotation.PermissionCallback
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.util.concurrent.TimeUnit
@CapacitorPlugin(name = "AndroidPushFallback")
@CapacitorPlugin(
name = "AndroidPushFallback",
permissions = [Permission(alias = "notifications", strings = [Manifest.permission.POST_NOTIFICATIONS])],
)
class AndroidPushFallbackPlugin : Plugin() {
companion object {
const val PREFS_NAME = "CapacitorStorage"
@ -33,6 +41,21 @@ class AndroidPushFallbackPlugin : Plugin() {
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
fun syncState(call: PluginCall) {
val state: JSObject? = call.getObject("state")

View file

@ -18,7 +18,6 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import fr.acinq.secp256k1.Secp256k1
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@ -47,7 +46,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
private const val REJECTED = "__REJECTED__"
private const val KIND_RELAY_AUTH = 22242
private const val KIND_NIP46_RPC = 24133
private val SECP = Secp256k1.get()
private val SECP = fallbackSecp256k1
}
private val prefs: SharedPreferences =

View file

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

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="custom_url_scheme">social.flotilla</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>

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

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>

View file

@ -22,6 +22,9 @@ this space?". Seeding a membership on `closed` is therefore not possible — `jo
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
@ -66,6 +69,18 @@ Every socket the browser opens is terminated in the node process, and the only o
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
@ -238,8 +253,8 @@ 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. Covering it needs a scenario with a public relay standing
in for an indexer.
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.
@ -254,6 +269,7 @@ starts from. Both take the same options, over and above the scenario's own relay
| `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 |
@ -262,6 +278,65 @@ hosted spaces are created under — or the app dials a host nothing serves and t
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
```
@ -270,6 +345,8 @@ e2e/
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
@ -287,14 +364,23 @@ e2e/
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
@ -309,6 +395,18 @@ and refreshed on the way up — zooid saves a relay's toml back when a NIP-86 ca
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.
@ -328,12 +426,31 @@ The suite is not run by agents (see CLAUDE.md).
```sh
pnpm exec playwright install # once
docker pull gitea.coracle.social/coracle/zooid:latest # once; the harness never pulls
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

View file

@ -4,14 +4,22 @@ The catalog e2e specs are written from. Each story is a slice of behavior a
person can observe in the running app. Specs reference stories by stable
id (`US-042`), so numbers are never reused or renumbered.
A story is behavior someone can describe without reading the css: what a button
does, what a feed contains, what a notification says. A layout threshold, a
padding, a color or a wording is not one, and a change that only moves one
belongs in the acceptance text of the story it sits under rather than in a story
of its own. Nothing in CI runs this suite, so every spec is a cost paid by hand
forever.
**Personas** come from `e2e/harness/keys.ts`, which defines four deterministic
identities:
- **alice**, **bob**, **carol** — ordinary members. Multi-user stories give each
their own browser context against the same relay, so one genuinely observes
another's writes over the wire.
- **admin** — the space admin, recognized by the relay's NIP-86 answers, which
is what unlocks the space, room, event and directory management surfaces.
- **admin** — the relay's owner, so every NIP-86 method comes back for them and
every space, room, event and directory management control is unlocked. A
member the relay answers with fewer methods gets fewer controls (US-127).
The test architecture is described in `e2e/ARCHITECTURE.md`: real zooid relays in
docker, with every socket and http request terminated in the test process.
@ -40,12 +48,19 @@ key in the app, so that I can start participating without an external tool.
Acceptance:
- Entering a display name advances to a key-backup step whose "Continue" button
stays disabled until a key file has been downloaded.
- Choosing the encrypted download requires a password of at least 12 characters
and produces a file containing an ncryptsec rather than a plain nsec.
- Entering a display name advances directly to the completion step without showing
the key-backup modal.
- Finishing the flow logs the new user in, dismisses the dialog, and lands on
the dashboard with the platform's default space visible.
- The dashboard shows a pending "Back Up Your Key" health check until the existing
backup flow completes successfully; leaving the modal keeps the check pending.
- Reloading preserves both the pending reminder and a completed backup.
- Applying all recommendations first lists the relay changes, and leaving that
review changes nothing. Confirming it opens the backup flow alongside the relay fixes.
- The "Back Up Your Key" check opens the backup flow directly, since it has nothing
to review.
- Choosing the encrypted download requires a password of at least 12 characters
and produces a file containing an ncryptsec rather than a plain nsec.
- The display name entered during signup appears on the new user's own profile.
### US-003 — Log in with an existing private key
@ -60,6 +75,7 @@ Acceptance:
- Pasting an ncryptsec reveals a password field: the correct password logs in, a
wrong one shows an error and stays logged out.
- Text that is not a valid key leaves the submit button disabled.
- Logging in with an existing private key does not enable the "Back Up Your Key" health check.
- bob logging in with his own key in a separate context sees his own identity,
not alice's.
@ -134,20 +150,20 @@ Acceptance:
## Spaces
### US-009 — Browse, search, and reorder your spaces
### US-009 — Browse and search spaces, and reorder your own
As alice, I want to see the spaces I've joined and find new ones, so that I can
get where I'm going and discover communities.
As alice, I want to find new spaces and keep the ones I'm in in the order I
want, so that I can discover communities and get where I'm going.
Acceptance:
- `/spaces` shows a "Your spaces" section listing every space alice has joined
and a "Browse Spaces" section of the rest.
- Typing a term filters both sections live, matching name, url, or description.
- Clicking a joined space opens it; clicking one she hasn't joined opens a join
prompt instead.
- Dragging a joined space above another reorders the list immediately, and the
order survives a reload.
- `/spaces` lists the spaces alice hasn't joined, under "Browse Spaces". The
ones she has are in the sidebar rail, all of them, and the rail scrolls.
- Typing a term filters the list live, matching name, url, or description.
- Clicking a space she hasn't joined opens a join prompt; a space in the rail
opens.
- Dragging a space above another in the rail reorders the list immediately, and
the order survives a reload.
### US-010 — Join a space from an invite link
@ -162,6 +178,8 @@ Acceptance:
- An unparseable link leaves the join button disabled and shows no preview.
- Navigating directly to the url of a space she hasn't joined opens the same
join prompt automatically, and going back leaves her un-joined.
- That prompt stays up once the space has finished opening, however slowly the
page it opens on arrives.
### US-011 — Request access when a space turns you away
@ -228,21 +246,7 @@ Acceptance:
appear when they apply.
- A members summary listing admins and newest members links through to the full
directory.
- Content admin has featured renders at the top for every visitor; with none, a
recent-activity summary appears instead.
### US-016 — Catch up on a space's recent activity
As bob, I want one feed of what's new across a space, so that I don't have to
open every room.
Acceptance:
- "Recent Activity" lists the latest message from each visible room alongside
recent posts and threads, newest first.
- A new message in a previously quiet room moves that room's entry to the top.
- Scrolling to the bottom loads older items, and a space with nothing in it
shows "No recent activity found".
- Content admin has featured renders at the top for every visitor.
### US-017 — Search across a space
@ -273,6 +277,16 @@ Acceptance:
- bob, already viewing the same room in his own session, sees the message appear
without reloading.
### US-118 — Messages sent in the same second are in one order for everyone
As alice, I want a room to read the same way for me as it does for bob, so that
we can refer to what was said without first agreeing on what order it was in.
Acceptance:
- Five messages sharing one timestamp are shown in ascending event id order,
whatever order they reached the client in.
### US-019 — Join and leave a room
As bob, I want to join a room's member list and leave it later, so that it shows
@ -302,6 +316,18 @@ Acceptance:
and removes it from the sidebar.
- bob sees neither "Edit Room" nor "Delete Room" in the room's detail menu.
### US-121 — Land somewhere after deleting the room you are in
As admin, I want deleting the room I am reading to put me on a page of the
space, so that I am not stranded on a screen with nothing on it.
Acceptance:
- Confirming the deletion leaves admin on a page below the space root, with the
space's remaining rooms listed beside it.
- Entering the space again from the rail lands on a page, rather than returning
to the room that was deleted or to the empty root.
### US-021 — Request access to a private room and get approved
As carol, I want to ask to join a closed room and be let in, so that I can read
@ -401,8 +427,20 @@ Acceptance:
- Clicking a result closes search and scrolls the timeline to the message,
highlighted in view.
- Opening a permalink url for a specific message lands on that message directly,
with a "scroll to bottom" control shown since the view is no longer at the
newest message.
and in a room short enough that the window opening with it reaches the present,
there is nothing left to jump back to and no control.
### US-027a — Follow a link to a recent message
As someone opening a push notification, I want the room to behave as though I
had scrolled to the bottom, so that I am not offered a way back to where I
already am.
Acceptance:
- A permalink to a message near the newest end lands with the room's last
message on screen and no "jump to newest" control, even when other events were
published after the one linked to.
### US-028 — Share a message somewhere else
@ -411,11 +449,42 @@ that I can pass it along without retyping it.
Acceptance:
- "Share" on a message inside a space opens a picker of that space's rooms.
- "Share Message" on a message inside a space opens a picker of that space's
rooms.
- The picker also offers a link to the message, which copies to the clipboard
and opens the room scrolled to it.
- Choosing a destination navigates there with the composer pre-filled with a
quote of the shared message.
- Sending posts the quoted message in the destination, visible to bob there.
### US-119 — Have a message read out loud
As alice, I want a message read to me, so that I can take in what was said
without looking at the screen.
Acceptance:
- "Read Out Loud" on a message with no OpenRouter key saved asks for one, the
same prompt dictation uses.
- Once a key is saved, the same menu item puts a player at the bottom of the
app naming whose message is being read.
- A quote, a mention or a url in the message is named rather than spelled out.
- The player starts on its own, pauses, scrubs, and closes, and closing it takes
it away.
### US-115 — Connect a wallet while sending a zap
As alice, I want to connect a wallet from the zap dialog and go on zapping, so
that reaching for one mid-zap is not a dead end.
Acceptance:
- Zapping a message from someone with a lightning address offers "Create
invoice" and a prompt to connect a wallet.
- Connecting one over WebLN reports success and closes only the wallet dialog.
- The zap dialog behind it drops the prompt, offers "Send Zap" instead, and
still holds the amount that was typed before the detour.
## Direct messages
### US-029 — Start a one-on-one chat
@ -430,6 +499,8 @@ Acceptance:
- The conversation appears at the top of her list, labeled with bob's name.
- Bob's profile page offers a "Message" button that opens the same conversation;
her own profile offers none.
- A profile modal has no such button, so its "..." menu offers "Send Message"
instead; the profile page's menu does not repeat it.
### US-030 — Start a group chat
@ -526,6 +597,48 @@ Acceptance:
a conversation with bob appears in her list without a reload.
- Opening it shows his message.
### US-108 — Read messages from a relay you only use for messages
As alice, I want the conversations on my messaging relay to load even when that relay is not one
of my spaces, so that direct messages work wherever I have pointed them.
Acceptance:
- With alice's messaging relays naming a relay she has not joined and neither reads from nor
publishes to, a conversation held there still appears in her chat list and opens with its
history.
- That relay hands her messages to nobody but her, so her messaging relay list is the only thing
that can vouch for her to it.
- A messaging relay list written by another client, naming the same relay without the trailing
slash, is honoured the same way.
### US-109 — Keep a conversation I have already read
As alice, I want a conversation I have already read to stay readable, so that my history does not
shrink to whatever my messaging relays happen to still be holding.
Acceptance:
- With a conversation open and read, closing the app and opening it again shows the same messages,
whether or not the relay they arrived from still serves them.
- A message deleted or edited out of that conversation stays gone across the same restart.
- A reaction I left on one of its messages is still there after the same restart.
### US-128 — Keep messages from strangers out of my conversations
As alice, I want messages from people I have no connection to held apart from the conversations I
care about, so that a stranger cannot bury them.
Acceptance:
- The chat list has a Conversations tab and a Requests tab, each with its own count.
- Someone I follow opens under Conversations and someone I share nothing with opens under Requests.
A member of a space I belong to, a sender enough of my follows follow, and a message carrying
enough proof of work each count as a connection too.
- Choosing Requests shows the chat held there, and it reads and opens like any other.
- A chat I have written in appears under Conversations whatever I know about the other person,
so answering a request moves it there.
## Articles & threads
### US-037 — Write and publish an article
@ -552,6 +665,8 @@ Acceptance:
- The Articles page lists each article with its author, published date, and a
preview; clicking an author or a topic filters the list, and combining both
narrows to articles matching both.
- An article with more topics than fit on one line wraps them inside its card
rather than pushing its reactions and action menu off the edge.
- Opening an article shows its title, cover image, summary, published date, and
full content, with the title matching its list card.
- Markdown in the body renders as real headings, bold text, and bullet lists,
@ -569,6 +684,8 @@ Acceptance:
top-level comment.
- Alice sees both and can add her own comment at the root, optionally attaching
an image to it.
- Every comment on an article posted in a room is tagged into that room, so the
relay handles it as part of the group.
### US-040 — React to a post with an emoji
@ -604,8 +721,10 @@ Acceptance:
- Creating a thread from a room's compose menu files it under that room's board
and posts a quote of it into the room's chat.
- Creating a thread from the top-level Threads page files it under a "General"
board.
- Each board on the Threads page has its own create button, and a thread started
from it is filed under that board.
- The "General" board is always present, so a thread belonging to no room can be
started from the Threads page.
- Each board row shows the topic title, author, reply count, and last-post time.
### US-043 — Reply to a thread and to a specific post
@ -616,24 +735,29 @@ so that responses connect to the right thing.
Acceptance:
- "Reply to thread" opens a composer with no quoted parent, and submitting
appends a post and increments the reply count in the header.
appends a post and increments the reply count on the opening post.
- "Reply" on a single post shows "Replying to" that post, which can be cleared
before submitting.
- The thread's opening post carries an "OP" badge on every page.
- A reply to a thread in a room is tagged into that room, so the relay handles
it as part of the group.
- The opening post stays above the replies, and the thread's author carries an
"OP" badge wherever their posts turn up.
### US-044 — Navigate a long thread
As bob, I want a long thread paginated and its posts individually linkable, so
that I can move around it and point people at one message.
As bob, I want a long thread to open on its newest replies with the earlier ones
within reach, and its posts individually linkable, so that I can catch up on it
and point people at one message.
Acceptance:
- A thread with more than 20 posts shows pagination controls, and the page
number, next/prev, and first/last controls each move to the matching slice
with the "Page X of Y" indicator updating.
- A thread with more than 20 replies opens on its newest 20, under the opening
post, with a "Show earlier replies" control naming how many are still hidden.
- That control reveals the next 20 without leaving the page, and its count drops
to match.
- "Permalink" on a post copies a link to that post.
- Opening that link as carol loads the thread, navigates to the page holding
that post, and scrolls it into view.
- Opening that link as carol loads the thread, reveals the post it names however
far back it is, and scrolls it into view.
### US-045 — Turn a chat message into a thread
@ -645,6 +769,7 @@ Acceptance:
- "Create a Thread" from a room message's menu opens the thread composer
pre-filled with a quote of that message.
- Publishing files the thread under that room's board.
- The message it was created from carries a link to the thread, which opens it.
- Opening the thread shows the quoted original message as part of the opening
post.
@ -660,7 +785,7 @@ Acceptance:
- The create form requires a title and a start/end time.
- Submitting closes the modal and lists the event under its date on the Calendar
page.
- The calendar opens scrolled to today or the next upcoming event.
- Switching to the Agenda view scrolls to today or the next upcoming event.
### US-047 — Manage your own calendar event
@ -710,11 +835,14 @@ members can see the momentum.
Acceptance:
- Creating a goal requires a title and details; the sats target defaults to 1000
and can be set by field or slider.
and can be set by field or slider, and a deadline and cover image are optional.
- The goals page is a board: space-wide totals, live/funded/ended filters, and a
sort by recent activity, age, progress or deadline.
- The goal's detail page shows the amount funded against its target on a
progress bar.
- A goal with contributions shows a contributor count and how long it has been
running.
progress meter, with what is left to raise and how long it has left or has
been running.
- A goal with contributions names its backers, ranked by what each gave, with
the comment that came with the zap.
### US-051 — Post, edit, and close out a classified listing
@ -751,11 +879,12 @@ Acceptance:
topics match.
- Selecting a shelf shows its pins as a gallery; an empty shelf shows a message
instead.
- As a non-admin, alice sees no "Create Shelf" or "Add a link" controls.
- Alice is offered "Create Shelf" and "Add a link", and "Add link" on someone
else's shelf, but not that shelf's "Edit shelf" or "Delete shelf".
### US-054 — Curate the library
As admin, I want to organize shelves and the links on them, so that members find
As bob, I want to organize shelves and the links on them, so that members find
good material first.
Acceptance:
@ -792,8 +921,8 @@ Acceptance:
- Typing "@" plus a few letters opens a dropdown of matching profiles, ranking
room and space members first, and narrowing as she types.
- Selecting bob inserts a mention that renders his name and avatar in the
composer and in the sent message.
- Selecting bob inserts a mention that renders his name in the composer and in
the sent message.
- Typing "~" opens a list of rooms; selecting one inserts a reference that
renders as a clickable link once sent.
@ -810,8 +939,8 @@ Acceptance:
behaves the same for its recipient.
- Dropping an image onto the composer, or pasting one from the clipboard,
attaches it the same way.
- A file type outside the supported list shows an error toast and attaches
nothing.
- A file the editor has no node for attaches as a link instead. What the server
refuses attaches nothing and shows its reason in an error toast.
### US-058 — Drafts survive navigating away
@ -839,6 +968,35 @@ Acceptance:
- The composer returns to composing a new message with its previous draft
intact.
### US-125 — Dictate a message
As alice, I want to speak a message instead of typing it, so that I can write
one without my hands.
Acceptance:
- The dictation button with no OpenRouter key saved asks for one, the same
prompt reading a message out loud uses.
- Stopping a recording asks whether to transcribe it or send it as a voice
note.
- Choosing to transcribe puts the transcript in the composer, ready to send.
- Leaving the room while a transcription is still out does not lose it: the
transcript lands in the composer that is there when it comes back.
### US-126 — Send a voice note
As alice, I want to send a recording as it is, so that the message carries my
voice rather than a transcript of it.
Acceptance:
- Choosing to send a voice note uploads the recording and attaches it to the
composer.
- Sending it publishes the message with the audio, which renders as a player in
the timeline.
- Discarding the recording instead leaves the composer empty and uploads
nothing.
## Rich content & media rendering
### US-060 — Reveal a flagged sensitive message
@ -886,8 +1044,7 @@ Acceptance:
- A standalone url whose preview resolves shows a card with title, description,
and image after a brief loading state.
- A standalone url with nothing usable shows a card explaining the preview
couldn't be loaded, naming the url.
- A standalone url with nothing usable falls back to the compact inline link.
- The same url embedded in a sentence renders as a compact inline link with no
card.
@ -913,8 +1070,9 @@ Acceptance:
quote strip.
- Clicking that strip takes alice to (or scrolls her to) her original message.
- A room message quoting a thread post renders as a bordered card naming the
author and content, briefly showing a loading state first.
author and content.
- Clicking that card opens the quoted post.
- A quote whose event has not arrived shows a loading placeholder in its place.
### US-066 — See distinctive inline tokens
@ -1003,8 +1161,8 @@ Acceptance:
confirms.
- On confirmation the status disappears silently and the normal reaction and
menu actions take its place.
- Cancelling removes the post entirely from the list or thread it was posted
into, and bob never sees it.
- Cancelling during the send delay removes the post entirely from the list or
thread it was posted into.
- A failed post shows "Failed to send!" in the same row, opening the same
per-relay detail popover.
@ -1043,9 +1201,9 @@ profile.
Acceptance:
- Typing a name on the people search filters results as she types, each showing
- Typing a name in the search dialog filters results as she types, each showing
avatar, display name, and about text.
- Scrolling to the bottom loads more matches.
- Only the ten best matches are listed.
- Clicking a result opens that person's profile.
### US-075 — View someone's profile
@ -1057,6 +1215,8 @@ Acceptance:
- His profile shows display name, avatar, banner, about text, and a shortened
npub whose copy button confirms the copy.
- A status he has published shows what he is up to, and links to the url it
names; an expired one shows nothing.
- A website he has set renders as a link.
- A Spaces panel lists the spaces he belongs to with a count, marks any alice
also belongs to as "Member", and navigates to a space when clicked; with none,
@ -1127,7 +1287,7 @@ check who someone is without losing my place.
Acceptance:
- Clicking bob's avatar or mention in a member list or message opens a popover
with his avatar, name, about text, and badges.
with his avatar, name, about text, status, and badges.
- The popover offers "View Full Profile", which navigates to his profile page.
- Closing the popover leaves alice where she was.
@ -1157,46 +1317,17 @@ Acceptance:
## Settings & preferences
### US-083 — Manage inbox and outbox relays
### US-084 — Block a relay you never want used
As alice, I want to choose where I read and publish, so that my messages reach
the right places.
As bob, I want to block a relay, so that nothing I do reaches out to it.
Acceptance:
- The relays page shows separate Inbox and Outbox cards, each with a current
count.
- Adding a relay by url to the Inbox list shows it there and increments the
count on the settings page.
- Removing a relay from the Outbox list drops it immediately, and a list with
only one relay shows a warning icon rather than a check.
### US-084 — Manage DM, search, and blocked relays
As bob, I want separate relay lists for messaging, search, and relays I never
want used, so that each feature uses relays suited to it.
Acceptance:
- A relay added to DM Relays appears there and not in Search Relays.
- The search-relay picker only offers relays advertising NIP-50 search support,
and removing one updates the count on the relays page.
- A relay added to Blocked Relays appears in that list and stops being offered
as a suggestion for the others.
### US-085 — Fix relay misconfiguration from the health check
As a user whose relay lists are wrong, I want the app to detect and fix it, so
that I don't have to know the specifics.
Acceptance:
- With only one outbox relay, the relays page shows a health check reporting
"Missing Outbox Relays".
- Applying that recommendation publishes a new relay list and the issue leaves
the pending list.
- "Apply All Recommendations" clears multiple issues at once, after which the
card shows an all-clear state.
- Settings › Privacy shows how many relays are blocked, and opening that list
from there offers a picker of the relays the client knows about.
- A relay added there appears in the blocked list and the count goes up.
- The picker stops offering a relay once it is blocked, while still offering the
others.
### US-086 — Configure alerts
@ -1252,6 +1383,17 @@ Acceptance:
- Turning off "Report usage" and saving persists across a reload.
- "Discard Changes" reverts unsaved toggles.
### US-129 — Raise the thresholds a stranger has to meet
As alice, I want to set how much proof of work and how many vouches a stranger
needs, so that I decide what reaches my conversations.
Acceptance:
- The proof-of-work and web-of-trust sliders start at 16 bits and 3 people, and
moving either updates the value beside its label live.
- Saving persists both across a reload.
### US-090 — Change the app's appearance
As alice, I want to set color scheme, visual theme, and font size, so that the
@ -1368,6 +1510,34 @@ Acceptance:
- "Remove Content" on a report deletes the reported message and clears the item;
dismissing clears the item and leaves the content alone.
### US-127 — Show a member only the controls their methods cover
As a space, we want each admin control gated on the management method behind it,
so that a member granted one method doesn't get an admin surface that only fails
when they use it.
Acceptance:
- On a space whose members hold `allowpubkey` alone, alice sees "Report Content"
on bob's message and no delete, no "Edit Space" in the space menu, and no
"More options" in the directory.
- She still sees "Action Items", which is the queue `allowpubkey` resolves.
- admin, who owns the relay and so holds every method, sees all three.
### US-130 — Share out admin permissions
As admin, I want to hand a member individual management permissions and see who
holds what, so that moderation is shared without handing anyone the whole relay.
Acceptance:
- "Admins" in the directory's menu lists the space's owner and everyone holding
assigned methods, each with a badge per permission they hold.
- Checking permissions under a member's "Edit permissions" puts them in that
list, and unchecking those permissions takes them back out.
- A member given "List banned members" finds "Banned Members" in the directory
menu they had no menu in before, and still no "Admins".
### US-098 — Browse and create hosted spaces
As a space owner, I want to see the spaces I host and spin up new ones, so that
@ -1420,8 +1590,9 @@ space carries my branding.
Acceptance:
- Saving a domain under "Manage" shows it on the relay card with a "Pending"
badge and the CNAME record to configure, whose copy button puts the target on
the clipboard.
badge and the DNS record to configure, whose copy button puts the target on
the clipboard. A subdomain gets a CNAME; a bare domain, which DNS won't let
take one, gets an ALIAS.
- "Verify DNS record" flips the badge to "Verified" once the backend reports it.
- The relay's displayed address then switches to the custom domain.
@ -1443,6 +1614,18 @@ Acceptance:
- Payment history lists past invoices with amount and billing period, most
recent first.
### US-122 — Export and import a hosted relay's data
As a space owner, I want to take a copy of everything my relay holds and put
events back, so that I can keep a backup and move between hosts.
Acceptance:
- "Import / export data" in the relay actions menu opens a Relay data dialog,
whose "Download events" downloads the relay's events as `<subdomain>.jsonl`.
- Importing a file reports how many events were stored and names the lines the
relay refused.
## Notifications & navigation
### US-103 — See and clear unread indicators
@ -1453,10 +1636,9 @@ check first.
Acceptance:
- After alice posts in a room bob hasn't opened, an unread dot appears on that
room and on its space in his sidebar, and on the space's row in `/spaces`,
without a reload.
- Opening the room clears its dot, and the dot stays cleared when he returns to
the space list.
room and on its space in his sidebar, without a reload.
- Opening the room clears its dot, and the dot stays cleared when he leaves the
space.
### US-104 — Mute a room or a whole space
@ -1473,6 +1655,18 @@ Acceptance:
room in it raises an unread dot.
- Turning either back on restores unread indicators for subsequent activity.
### US-120 — Read what a notification says
As alice, I want a notification to say what was written, so that I can tell
from it whether the message is worth opening.
Acceptance:
- With push notifications on and the tab in the background, a reply from bob in
a room alice is in raises one naming her as mentioned.
- Its body is the words bob wrote rather than the quote his reply is prepended
with, and a url in it is named by its host instead of spelled out.
### US-105 — Land on the home page
As a new user, I want the home page to route me somewhere useful, so that I'm
@ -1481,10 +1675,49 @@ never staring at a blank screen.
Acceptance:
- On a build with a configured platform space, `/home` opens that space.
- With none configured, it shows a welcome screen offering "Add a space" and
"Start a conversation".
- With none configured, it shows the dashboard, whose empty inbox offers "Add a
space" and "Start a conversation".
- Those options navigate to the spaces directory and the chat view respectively.
### US-116 — Read the home dashboard
As alice, I want the home page to tell me what happened while I was away, so
that I don't have to walk every space to find out.
Acceptance:
- The inbox lists each room and conversation with unread activity as a card,
naming the room and space and showing the latest message, newest first. It
holds messages only, and only while they are unread.
- Activity is a row of one card per space, counting what that space has waiting
that isn't a message - threads, events, classifieds and the rest. A card
disappears once its space is read.
- A conversation carries an unread dot, and "Mark all read" empties the inbox.
- Selecting a conversation opens it.
- Relay health checks are listed alongside the inbox, each naming what is wrong.
Applying one opens a review naming the relays it adds and removes, and
publishes nothing until it is confirmed.
- Hosting is offered whether or not she hosts a space: a shortcut to the hosting
panel when she has one, an invitation to start one when she doesn't.
### US-117 — See notes from the people I follow
As alice, I want the home page to show what the people I follow have posted, so
that home is worth opening when nothing is waiting for me.
Acceptance:
- The Network section lists notes from her follows, resolved through the relays
those people publish to. A follow who is in none of her spaces reads the same
way, replies included, since a note's replies are counted from the relay the
note itself came from.
- It is a list of notes: a reply is counted on the note it answers rather than
drawn underneath it, and never appears as an item of its own.
- Every note carries its reply count, including the ones with no replies.
- Scrolling to the end of the feed loads more rather than asking her to.
- The section fills from the relays that answer. One that takes the connection
and then says nothing does not hold it empty.
### US-106 — Share text into the app
As alice, I want to hand text to Flotilla and choose where it lands, so that I
@ -1511,18 +1744,94 @@ Acceptance:
- An unresolvable nostr link redirects to the app's home rather than showing a
broken page.
### US-110 — See another space's unread activity from a phone
As bob on a phone, I want the bottom bar's space-menu button to tell me another
space wants attention, so that I don't have to leave the room I'm reading to
find out.
Acceptance:
- While bob has a room open, a message in a space he isn't in raises an unread
dot on the space-menu button, matching the dot that space's row carries in
`/spaces`.
- A message in another room of the space he's already in does not; that space's
own room list carries it.
- Reading the other space takes the dot down.
### US-112 — See which threads are unread
As bob, I want the Threads dot to lead me to the thread that raised it, so that
the indicator is something I can act on rather than dismiss.
Acceptance:
- A thread alice posted raises an unread dot on the Threads nav item; one bob
posted himself does not.
- Opening the list keeps the dot on alice's row, so he can tell which thread is
new, and bob's row still carries none.
- Opening a thread takes its own row's dot down, and one he left alone keeps its
dot.
- The nav item stays clear once he has opened the list, whether or not a thread
under it is still unread.
### US-113 — See which threads are unread on a phone
As bob on a phone, I want the same dot on the thread that raised it, so that the
Threads indicator is as actionable on a phone as it is on a desktop.
Acceptance:
- The thread list in a board too narrow for the table is a list of links rather
than a table, and alice's thread carries a dot there; bob's own does not.
### US-114 — See which listings are unread
As bob, I want the same dot on a board whose items are cards rather than rows,
so that every content section answers "which one is new" the same way.
Acceptance:
- A listing alice posted raises an unread dot on the Classifieds nav item; one
bob posted himself does not.
- Opening the list keeps the dot on the corner of alice's card, and bob's own
card carries none.
### US-123 — Find a section whose newest item is old
As bob, I want a space to offer every kind of content it holds, so that a quiet
section is something I can reach rather than something I have to guess at.
Acceptance:
- A poll older than the window the space sync asks for still puts the Polls nav
item on the menu, and opening it lists the poll.
### US-124 — Reach a badge raised by content the space doesn't have
As bob, I want a badge on a space to lead me to whatever raised it, so that an
indicator I can see is one I can clear.
Acceptance:
- A comment on a poll the space doesn't hold raises a dot on the space, and the
Polls nav item carries the same dot once the space is open.
- Opening the list says there are no polls, and afterwards the space stops
offering the section.
## Out of scope
Features the e2e suite cannot exercise, and what stops it.
**Lightning payments and wallets.** Sending a zap on a message, article, thread
post, comment, or note; contributing to a funding goal; connecting a wallet over
WebLN or Nostr Wallet Connect; the wallet page's connection status and balance;
Nostr Wallet Connect; the wallet page's connection status and balance;
disconnecting a wallet; paying and receiving invoices. The harness mocks zapper
_discovery_ (Dufflepud's `/zapper/info`) but not the LNURL invoice callback or
the payment leg, and a connected wallet needs a real extension or an NWC
responder on its own relay. Existing zap receipts can be seeded, so a zap total
rendered on a message is testable; the send-and-settle flow is not.
_discovery_ (Dufflepud's `/zapper/info`) and stands up a WebLN provider that
answers the connection handshake, but not the LNURL invoice callback, the
payment leg, or an NWC responder on a relay of its own. Existing zap receipts
can be seeded, so a zap total rendered on a message is testable; the
send-and-settle flow is not.
**Voice and video rooms.** Creating or joining a Voice room, the mic-preview and
device-picker dialog, mute/camera/screen-share controls, speaking indicators,
@ -1565,6 +1874,14 @@ navigation off the app's origin, so nothing about the destination is observable.
logs into a DM to the platform's support contact. It targets a hardcoded pubkey
whose relays are not part of the sealed test network.
**A network read that fails.** Every relay a scenario declares answers, and a url the container
does not serve is answered by an empty relay rather than refused, so no spec can express a read
that fails. That leaves one invariant untested: a send whose reads fail before the message exists
must keep the text in the composer and say so, rather than clearing as though it went. It is the
shape of the bug that motivated US-108 — `@welshman/store`'s `load` rejects rather than resolving
empty, so a failure there aborts a publish before its thunk is made and nothing reaches the
timeline to carry a status. Testing it needs a seam for making a relay unusable.
**Internals with no user-visible surface.** The legacy session-storage format
migration, which has no observable difference and no supported way to seed the
old shape. `ProfileFeed` and `ProfileLatest` components that no route reaches.

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})
}
})

View file

@ -1,5 +1,5 @@
import type {BrowserContext} from "@playwright/test"
import {MINUTE, int, ms} from "@welshman/lib"
import {ms} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import type {TestUser} from "../keys"
import {injectEvents, injectSession} from "./session"
@ -7,27 +7,30 @@ 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, which is this side's only
// evidence that the hook in src/app/env.ts ran at all.
// 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 the urls it dials on its own
// initiative can only ever be relays the scenario created.
// 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 still has to be something the test owns, or the app will reach for it.
// 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, spaces = [], user, events = [], path = "/", env = {}}: BootOptions,
{relays, indexers = relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions,
) => {
const urls = relays.join(",")
@ -48,7 +51,7 @@ export const boot = async (
TEST_ENV_READ_KEY,
{
VITE_DEFAULT_RELAYS: urls,
VITE_INDEXER_RELAYS: urls,
VITE_INDEXER_RELAYS: indexers.join(","),
VITE_DEFAULT_SEARCH_RELAYS: urls,
VITE_DEFAULT_MESSAGING_RELAYS: urls,
VITE_SIGNER_RELAYS: urls,
@ -73,16 +76,26 @@ export const boot = async (
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 really running. With a session injected, wait for the
// signed-in nav instead — one the app rejected renders the landing dialog, and failing on that
// here is much easier to read than the assertions it would break later.
await page
.locator(user ? ".primary-nav" : ".fl")
.waitFor({state: "attached", timeout: ms(int(1, MINUTE))})
// 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")
// src/app/env.ts reads every VITE_ value as it is imported, so by now the app has either resolved
// them against the env above or against .env's real relays — which would otherwise surface as
// every assertion in the suite timing out.
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,

40
e2e/harness/app/cache.ts Normal file
View file

@ -0,0 +1,40 @@
import type {Page} from "@playwright/test"
import type {TrustedEvent} from "@welshman/util"
// Must match the database name and the `events` table in src/app/storage.ts, which are scoped to
// one identity.
const databaseName = (pubkey: string) => `flotilla-9gl-${pubkey}`
/**
* What this user's client has written to disk so far. Events reach indexeddb in three-second
* batches with nothing in the ui to say when one has landed, so a spec about what survives a
* restart waits on this before it reloads.
*/
export const readCachedEvents = (page: Page, pubkey: string): Promise<TrustedEvent[]> =>
page.evaluate(async name => {
const open = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(name)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
// An unversioned open creates the database when it is missing, so a client that has not written
// anything yet has no store to read.
if (!open.objectStoreNames.contains("events")) {
open.close()
return []
}
const items = await new Promise<{event: TrustedEvent}[]>((resolve, reject) => {
const request = open.transaction("events", "readonly").objectStore("events").getAll()
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
open.close()
return items.map(item => item.event)
}, databaseName(pubkey))

View file

@ -2,9 +2,8 @@ import type {BrowserContext} from "@playwright/test"
import type {StampedEvent} from "@welshman/util"
import type {TestUser} from "../keys"
// The function playwright installs on window for the shim below to call into. A binding is the
// only way across: the keys live in node, and a signer built in the page would be a different
// thing from the one seeding signs with.
// The function playwright installs on window for the shim below to call into. The keys live in
// node, so a signer built in the page would not be the one seeding signs with.
const TEST_NIP07_KEY = "__TEST_NIP07__"
type Nip07Call =
@ -14,10 +13,10 @@ type Nip07Call =
/**
* A NIP-07 provider backed by a test identity's real signer, so an extension login produces
* signatures the relays accept — including the NIP-42 auth events a members-only relay demands.
* signatures the relays accept, including the NIP-42 auth events a members-only relay demands.
*
* Both halves have to be installed before the page navigates: LogIn.svelte reads `window.nostr`
* while it renders to decide whether to offer the button at all.
* Both halves have to be installed before the page navigates. LogIn.svelte reads `window.nostr`
* while it renders, to decide whether to offer the button at all.
*/
export const injectNip07 = async (context: BrowserContext, user: TestUser) => {
await context.exposeBinding(TEST_NIP07_KEY, (source, call: Nip07Call) => {

View file

@ -8,9 +8,8 @@ const TEST_SESSION_KEY = "__TEST_SESSION__"
const TEST_EVENTS_KEY = "__TEST_EVENTS__"
// A nip01 session in the {method, data} shape @welshman/app's session handlers deserialize, so
// restoreSession can build a signer from it without any of the storage encoding a real login
// would have gone through. addInitScript runs before any page script, so this must be called
// before navigating, and it is installed on the context so every page in it boots as this user.
// restoreSession can build a signer from it without the storage encoding a real login goes through.
// addInitScript runs before any page script, so this has to be called before navigating.
export const injectSession = (context: BrowserContext, user: TestUser) =>
context.addInitScript(
([key, session]) => {
@ -19,8 +18,8 @@ export const injectSession = (context: BrowserContext, user: TestUser) =>
[TEST_SESSION_KEY, {method: "nip01", data: {secret: user.secret}}] as const,
)
// The repository contents the app loads once the injected session is restored — the local cache
// a user who had used the app before would boot with.
// The repository contents the app loads once the injected session is restored, the local cache a
// returning user would boot with.
export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) =>
context.addInitScript(
([key, value]) => {

32
e2e/harness/app/webln.ts Normal file
View file

@ -0,0 +1,32 @@
import type {BrowserContext} from "@playwright/test"
// What `getInfo` answers with. `supports` is what src/app/components/WalletConnect.svelte gates the
// connection on, and the node's alias is what the wallet page names the connection by.
export type WebLnInfo = {
supports?: string[]
node?: {alias?: string; pubkey?: string}
version?: string
}
/**
* A WebLN provider on `window`, in the shape a browser extension installs. Connecting is a
* capability handshake and nothing more, so the whole provider answers from a literal in the page —
* paying an invoice and issuing one are past the boundary this harness stops at, and calling either
* here throws rather than pretending.
*
* Install it before the page navigates. WalletConnect reads `window.webln` while it renders, to
* decide whether to offer the button at all.
*/
export const injectWebLn = (context: BrowserContext, info: WebLnInfo = {}) =>
context.addInitScript(
$info => {
Object.assign(window, {
webln: {
enable: () => Promise.resolve(),
getInfo: () => Promise.resolve($info),
getBalance: () => Promise.resolve({balance: 0}),
},
})
},
{supports: ["lightning"], ...info},
)

84
e2e/harness/faults.ts Normal file
View file

@ -0,0 +1,84 @@
import {inspect} from "node:util"
import type {BrowserContext, ConsoleMessage} from "@playwright/test"
// A CSP refusal reaches the console and nothing else, so a policy that has rotted past the script
// it names is invisible to every spec: app.html's requestIdleCallback shim was refused on every
// platform for a week with the suite green (#535).
const isRefusal = (text: string) => text.includes("Content Security Policy")
// Every test recreates the zooid container, chromium aborts what the page had in flight when the
// interfaces churn, and sveltekit reports a route chunk lost that way as an uncaught TypeError. It
// reaches nearly every spec — 256 of the 258 faults a full survey run raised — so it stays in the
// log and out of the fault set until #529 stops the churn.
const isChunkLoss = (text: string) => text.includes("Failed to fetch dynamically imported module")
// Chrome reports a failed request as "Failed to load resource: the server responded with a status
// of 404 (Not Found)" and carries the url nowhere but the message's location, so a line built from
// the text alone cannot say which resource went missing.
const locate = (message: ConsoleMessage) => {
const text = message.text()
const {url} = message.location()
return url && !text.includes(url) ? `${text} ${url}` : text
}
// Playwright builds this from the page's exception details, and a page that throws something other
// than an Error leaves it with neither message nor stack — which is how a fault used to reach the
// console log as a bare "uncaught:".
const describe = (error: Error) => error.stack || error.message || inspect(error)
export type FaultWatch = {
// Everything either side said, for the account attached to a failing test.
log: string[]
// The subset of it that means the app broke rather than the box being noisy.
found: string[]
observe(context: BrowserContext, who: string): void
// Throws when the app itself broke while the test ran: an uncaught exception, or code of ours the
// browser refused to run. A failed request or a noisy warning is neither — a dev server is full
// of both — so those stay in the log.
assertNone(): void
}
export const watchFaults = (): FaultWatch => {
const log: string[] = []
const found: string[] = []
const record = (line: string, fault: boolean) => {
log.push(line)
if (fault) {
found.push(line)
}
}
return {
log,
found,
observe(context, who) {
context.on("console", message => {
const type = message.type()
const text = locate(message)
if (["error", "warning"].includes(type)) {
record(`[${who}] ${type}: ${text}`, type === "error" && isRefusal(text))
}
})
context.on("weberror", error => {
const text = describe(error.error())
record(`[${who}] uncaught: ${text}`, !isChunkLoss(text))
})
},
assertNone() {
if (found.length > 0) {
throw new Error(
[
"The app broke while the test ran, so nothing it asserted means anything:",
...found.map(fault => ` ${fault}`),
].join("\n"),
)
}
},
}
}

30
e2e/harness/files.ts Normal file
View file

@ -0,0 +1,30 @@
import type {Locator, Page} from "@playwright/test"
// A file as the browser's own chooser takes one.
export type TestFile = {
name: string
mimeType: string
buffer: Buffer
}
// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched
// instead of re-encoding it through a canvas, so the bytes the server hashes are the bytes chosen
// here and the url an upload resolves to is predictable from node. The base64 is what a spec hands
// to the page, since a Buffer does not survive the trip into evaluate().
export const GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
export const GIF = Buffer.from(GIF_BASE64, "base64")
// A 1x1 webp, which the compressor passes through for the same reason.
export const WEBP = Buffer.from("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", "base64")
export const gifFile = (name: string): TestFile => ({name, mimeType: "image/gif", buffer: GIF})
// Every picker in the app opens the browser's own chooser, which is the only place a spec can hand
// it a file: the input behind it is never on screen.
export const chooseFile = async (page: Page, button: Locator, file: TestFile) => {
const chooser = page.waitForEvent("filechooser")
await button.click()
await (await chooser).setFiles(file)
}

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