9.5 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 + DaisyUI 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 {Dialog} from "$lib/components"
import {repository} from "$app/core/state"
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)
- Most global state flows through Welshman's
repository(unidirectional) - Query state using
deriveEventsMappedorderiveProfileetc - Update state by publishing events via
publishThunk
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- High-level state (pubkey, signer, repository, tracker)@welshman/net- Network layer (Pool, Socket, load, pull, request)@welshman/store- Svelte integration (deriveEventsMapped, etc.)@welshman/util- Event utilities (kinds, tags, validation)@welshman/signer- Signing abstraction (NIP-01, NIP-07, NIP-46)@welshman/router- Relay routing (inbox/outbox model)@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 - Svelte 5 runes (
$state,$derived,$effect) only in UI components - TailwindCSS styling with css components customized by theme. See lib/components for examples.
- 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
- 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. - Instead of
getTag(tagName, event.tags)?.[1] || "", usegetTagValue(tagName, event.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. - 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
Loading Data from Network
- Use utilities from
app/core/requests.ts - Or create derived stores in
app/core/state.ts - Use
load,pull, orrequestfrom@welshman/net
Publishing Events
- Create
make*function to build event template - Create
publish*function usingpublishThunk - Display thunk status to user (for cancel/error handling)
- These go in in
app/core/commands.ts
Managing Modals/Toasts
- Import from
app/util/modal.tsorapp/util/toast.ts - Pass component objects with parameters
- Use
$state.snapshotif calling component might unmount
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
./link_depsscript to 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, APK builds via
pnpm run release:android - 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