14 KiB
Project Overview
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
Tech Stack:
- SvelteKit 5.48+ with TypeScript 5.9+
- Capacitor for cross-platform (Web/PWA, Android, iOS)
- TailwindCSS for styling
- Welshman library suite for Nostr protocol
- IndexedDB for local storage
- Vite for building
Key Concepts:
- Spaces - Relays used as community groups (like Discord servers)
- Rooms - NIP-29 groups within spaces (like Discord channels), identified by
h - Chats - Direct message conversations (NIP-04/NIP-44 encrypted)
Architecture & Dependency Graph
The project follows a strict acyclic dependency hierarchy:
routes/ (top layer - can depend on anything)
↓
app/components/ (can depend on app/* and lib/*)
↓
app/* (can only depend on lib/*)
↓
lib/ (can only depend on external libraries)
↓
external libraries (bottom layer)
Import Ordering Convention (CRITICAL): Always sort imports by dependency level:
- Third-party libraries first
- Then
lib/imports - Then
app/imports
Example:
import {derived} from "svelte/store"
import {throttle} from "throttle-debounce"
import {Profiles} from "@welshman/app"
import {Dialog} from "$lib/components"
import {app} from "@app/core"
Cleanup Pass
These are the things that most often get fixed by hand after the fact. Go through them before handing work back. The full style reference is under Development Conventions below.
- Comments — only for genuinely surprising things: a workaround, a constraint imposed by a backend or platform, an invariant that isn't visible from the code in front of you. Never comment props, never restate what the next line does, never justify an ordinary decision. If a small refactor would make the comment stale, don't write it.
- Used once, inlined — a derived value, helper, type, or named constant with a single use is indirection. Write
setTimeout(pollOnce, 3500), not aPOLL_INTERVALreferenced twice in one file. Name something only when the name is what makes the code readable. - Positive conditionals — prefer
if (ready) { ... }overif (!ready) return. Nesting is fine; when it gets deep that's the signal the function is doing too much, so split it. Many early returns belong in validation or pipeline functions, not everywhere else. - Truthiness — test values directly (
if (invoice.paid_at)) instead of comparing againstnull/undefined, and put the truthy branch first in a ternary. - Standard names —
loadingfor in-flight state,on*for handlers bound to an event, a plain verb (submit,save) for the action itself. Use{prop}shorthand when the names match. - Errors — toast a human-readable message and
console.erroranything unexpected (e.g. a non-HostingError). Never swallow an error you didn't anticipate.
State Management
Core Principles:
- Use Svelte 4 stores for all state (NOT runes outside UI components)
- Everything hangs off the single
Appinstance inapp/core.ts. There are no welshman globals — reach features withapp.use(Plugin), which is memoized per app and cheap to call inline. app/core.tsalso exportspubkey,signer,userandsessionstores (all with.get()),login, andderiveUserItem(plugin)for the current user's entry in a keyed collection.- Most global state flows through the app's
repository(unidirectional) - Query state with a plugin's
one(key)/index/all, orderiveEventsById/deriveItemsByKeyfrom@welshman/storeagainstapp.repository - Update state by building a domain writer and publishing the resulting
Command
Projections:
A Projection<T> is {get(): T, $: Readable<T>} — bind .$ in markup, call .get() in
callbacks and hot paths.
Thunks:
- Reduce UI latency by handling signatures and sending in background
- Return status that should be displayed to user
- Allow cancellation and error handling
- Immediately publish to local repository for optimistic updates
Nostr Integration
Welshman Library Suite:
@welshman/app- TheAppinstance and its plugins (Profiles, Rooms, Thunks, Router, …)@welshman/domain- A typed Reader/Writer pair per event kind, plusRelayandZapper@welshman/net- Network layer (Pool, Socket, adapters, request/publish/pull)@welshman/store- Svelte integration (deriveEventsById, deriveItemsByKey, etc.)@welshman/util- Event utilities (kinds, tags, validation, the RelaySelection routing DSL)@welshman/signer- Signing abstraction (NIP-01, NIP-07, NIP-46)@welshman/editor- Rich text editor with Nostr@welshman/content- Content parsing@welshman/feeds- Feed management
Key NIPs Implemented:
- NIP-01: Basic protocol
- NIP-44/59/17: Encrypted DMs
- NIP-07: Browser extension signing
- NIP-19: Bech32 encoding
- NIP-29: Relay-based Groups
- NIP-42: Relay authentication
- NIP-43: Relay membership
- NIP-46: Nostr Connect (remote signing)
- NIP-57: Lightning Zaps
Development Conventions
Component Parameterization:
- Only pass entity identifiers (
urlfor spaces,hfor rooms) - Derive all other data inside the component from identifiers
- Example: Don't pass
membersprop, derive it fromhinside component
CRITICAL Code Style Guidelines:
- No
null- only useundefined - Never hard-code the app's name. The brand is a build-time
VITE_PLATFORM_*variable, so user-facing copy interpolatesPLATFORM_NAMEfrom@app/env, andPLATFORM_URL,PLATFORM_LOGO,PLATFORM_ABOUTfor 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 underlib/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 inbase.cssstay 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 tounknown, they are likely because the upstream definition of the data is incorrect. - When dynamically building classes, use
cxfromclassnamesrather than embedded ternaries or svelte 4's oldclass:syntax. - When creating forms, use
FieldInlineorFieldinstead 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
@containeron the element and use@md:/@2xl:, rather thansm:/md:. The navs arehidden md:flexand take 326px out of the page atmd, so amd: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 lookupRecordin 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, usetagValue(spec, tags)/tagValues(spec, tags)from@welshman/utilrather than reaching into the tag array yourself — that means notags.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 plaintagSpec("h")when the value needs no validation. ReserventhEqfor cases with no spec equivalent, such aspartition(nthEq(0, "imeta"), tags). - Do not render a profile's
aboutdirectly (e.g.profile.about()); use theProfileAboutcomponent instead. - Use
type Propsinstead of interface when defining props for svelte components. - When a component's value/prop shape mirrors a subset of an existing type, derive it with
Pick/Partialandexportthat type from the component's<script module>(e.g. aValuestype) for callers to import, instead of re-enumerating its sub-properties. - Avoid pass-through functions except when the wrapper is part of an abstraction.
x => y()is not ok, butx => this.impl.y()is ok, for example. - Use
callfrom@welshman/libinstead of an IIFE:const x = call(() => {...}), notconst x = (() => {...})(). It reads left-to-right and drops the wrapping parens and the leading-semicolon hazard.callignores extra arguments, so it also works directly as a callback —unsubscribers.forEach(call). - To match on field equality, use
specfrom@welshman/librather than an arrow comparing properties:.filter(spec({shortcode})), not.filter(emoji => emoji.shortcode === shortcode). It takes an object of key/value pairs and matches when every pair is equal, so it covers multi-key checks too (spec({kind, pubkey})), and it accepts an array for positional matching on tags (spec(["p", pubkey])). Keep an explicit arrow when the predicate isn't plain equality — ranges, negation, or a comparison joined with&&. - For durations and timestamps, use
@welshman/lib's time helpers and constants (MINUTE,HOUR,DAY,WEEK,MONTH,YEAR) rather than raw milliseconds:int(5, MINUTE)for a duration in seconds,ago(5, MINUTE)for a past timestamp,now()for the current one, andms()/ms(int(...))only where a browser API needs milliseconds. SocheckedAt < ago(5, MINUTE)instead ofcheckedAt < Date.now() - 300_000. Write these count-first (int(3, MONTH),ago(2, WEEK)) — the declared parameter order is(unit, count), but the product is the same either way and every call site in this repo reads count-first. Nostr timestamps are seconds, so prefernow()overDate.now()for anything stored on an event. - When declaring variables in a svelte component, the order should generally be: props, constants derived from props/state, functions declared with
const, mutable variables declared withlet, effects, onMount. This order may vary due to dependencies, but should generally be adhered to.
Human-First Simplicity:
- Prefer direct, readable code over layered abstractions.
- Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity.
- Abstractions must be either: elegant and self-explanatory (bottom-up design), or used in at least 3 locations (ugly glue code).
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies. See /welshman-* skills for details.
- Favor linear control flow and explicit naming over clever patterns.
- Remove defensive checks that do not apply in this runtime model.
- When two approaches work, pick the one that feels more human and easier to maintain.
Common Tasks
Adding a New Component
- Determine if it's generic (
lib/components/) or app-specific (app/components/) - Follow naming convention:
PascalCase.svelte - Import in dependency order (3rd party → lib → app)
- Use stores for state, runes only for UI reactivity
Creating a New Route
- Add to
src/routes/following SvelteKit conventions - Use
+page.sveltefor page component - Use
+layout.sveltefor shared layouts - Top-level sync logic goes in root
+layout.svelte - Read params from the
paramsprop, typed withPageProps/LayoutPropsfrom the route's own./$types. SvelteKit passes them down in the same update as the component swap, while thepagestore is set a tick later. Only code outsidesrc/routes/reads$page.params.
Loading Data from Network
- Prefer a plugin's
one(key)/load(key), which handle outbox routing and caching - For anything else use
app.use(Network).load/requestorapp.use(Sync).pull/push— the bareload/request/publishfrom@welshman/netthrow without a context
Publishing Events
- Build a writer:
app.use(Domain).writer(Kind, reader?), then chain its setters - Wrap it:
const command = await app.use(Domain).command(writer) - Publish it:
command.publish()or.publishToRelays(urls) - Display thunk status to user (for cancel/error handling)
Plugin mutators (app.use(FollowLists).follow(...), app.use(Rooms).joinRoom(...), …) already
return a Command, so .then(publish) is usually all you need.
Managing Modals/Toasts
- Import from
app/modal.tsorapp/toast.ts - Pass component objects with parameters
- Use
$state.snapshotif calling component might unmount - Navigate with
navigatefromapp/modal.tsrather thangoto— 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, throughModalContainer'sbeforeNavigate navigateis 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
keepModaltonavigateto change the page under a modal and leave it open
Development Workflow
Agents should not run the dev server or build the app. Instead, use the following commands:
pnpm run format # Format changed files
pnpm run lint # Check formatting and linting
pnpm run check # Type check
Welshman Development:
- Clone welshman to parent directory
- Use
./scripts/link-deps.mjsto link local welshman packages - Avoid committing
pnpm.overrideschanges
Git Workflow:
masterbranch auto-deploys to production- Work on feature branches based on
devbranch - Pre-commit hooks run lint/typecheck automatically
Environment Variables
See .env.template for all options.
Mobile Development
Capacitor Integration:
- 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
Native Features:
- Push notifications (FCM/APNs)
- Deep linking (nostr: and https: URLs)
- Native signing plugin
- Keyboard management
- Safe area handling
- Badge management