Move app-specific components, CSS families and single-caller helpers out of src/lib and document the app modules that still open components

This commit is contained in:
Jon Staab 2026-09-25 23:47:34 -07:00
parent 25e11257aa
commit 0e01055855
25 changed files with 181 additions and 195 deletions

View file

@ -20,9 +20,8 @@ and UI.
| 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
`svelte.config.js` defines the aliases `@src`, `@app`, `@lib` and `@assets`. Use `@lib`, not
SvelteKit's built-in `$lib`, which also resolves. 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`.
@ -44,12 +43,14 @@ and `sync.ts`.
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`), `deepLinks.ts` (`Search`) and `speech.ts` (`OpenRouterEnable`) import a component
so they can open a modal mid-flow. `editor/` holds `.svelte` files of its own (suggestion
- **lib → app.** `Link.svelte` imports `navigate` from `@app/modal`. Don't copy it. A lib
component that needs app behavior takes it as a prop, or moves to `src/app/components`, as
`IconInput` and its picker buttons did.
- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`, `goToEvent` opens
`NoteDetail`), `share.ts` (`Share`, `ShareEvent`), `deepLinks.ts` (`Search`) and `speech.ts`
(`OpenRouterEnable`) import a component so they can open a modal mid-flow. Push adapters and
deep links reach those flows from outside any component, and the rest have several component
callers, so each stays in one module. `editor/` holds `.svelte` files of its own (suggestion
popovers), which `makeEditor` mounts.
- Nothing under `src/app` or `src/lib` imports from `src/routes`.
@ -71,7 +72,9 @@ only gate.
## `src/app` by concern
`src/app/components` is flat except for `hosting/`. `flotilla-views` covers component conventions.
`src/app/components` is flat except for `hosting/`. It also holds the CSS families for app
concepts (`chat.css`, `room.css`, `role-badge.css`, `space-menu.css`), which `src/app/app.css`
imports. `flotilla-views` covers component conventions.
**Core and session**
- `core.ts`: the `App` store, plugin stores, `login`, and the `reader`/`writer`/`command` shortcuts
@ -95,7 +98,7 @@ only gate.
node views
**Spaces, rooms and administration**
- `relays.ts`: relay URL encoding for routes, socket status, LiveKit detection
- `relays.ts`: relay URL encoding for routes, socket status, the LiveKit endpoint and 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)
@ -110,11 +113,12 @@ only gate.
- `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)
text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom, image compression)
- `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)
- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices, and the
`AbortError`/`TimeoutError` a join rejects with)
**Identity and payments**
- `nip46.ts`, `pomade.ts` (email login), `lightning.ts` (wallet, invoices), `healthChecks.ts`
@ -133,16 +137,15 @@ Lib code is app-agnostic. It may use svelte, SvelteKit, Capacitor and welshman,
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`
- `util.ts`: small helpers (`errorMessage`, `buildUrl`, `normalizeTopic`)
- `html.ts`: DOM helpers such as `isMobile`, `createScroller`, `copyToClipboard`
- `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`)
- `components/`: the design system, entered through `theme.css` (see `flotilla-views`), which
`src/app/app.css` imports ahead of the app's own CSS families
## Boot sequence

View file

@ -228,15 +228,15 @@ lives there:
- modal chrome: `Dialog`, `Drawer`, `Modal`, `ModalBody`, `ModalHeader`, `ModalTitle`,
`ModalSubtitle`, `ModalFooter`, `Confirm`
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
`DateTimeInput`, `ImagesInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `LoadingFallback`, `Tooltip`,
`Tippy`, `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.
`component`. `Link` imports `@app` anyway; flotilla-architecture lists it 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.
@ -435,7 +435,7 @@ wrap their contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`.
`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`).
or bindable inputs from lib (`ImagesInput`) and app (`IconInput`, `TopicMultiSelect`).
Create/edit pairs come in three shapes:
@ -457,9 +457,11 @@ 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.
imports, and the app-concept families in `src/app/components/*.css`, which `src/app/app.css`
imports after `theme.css`. A class goes in `@layer components` either way. The lib families
include `button` with `button-primary|neutral|link|ghost|error` and `button-sm|xs|circle|square`,
`card`, `badge`, `input`, `select`, `textarea` and `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, navy and nature themes supply the values through `data-fl-theme`.

14
src/app/app.css Normal file
View file

@ -0,0 +1,14 @@
/* App stylesheet (imported once, in src/routes/+layout.svelte): the design
system first, then the families for flotilla's own components. */
@import "../lib/components/theme.css";
@import "./components/chat.css";
@import "./components/role-badge.css";
@import "./components/room.css";
@import "./components/space-menu.css";
/* SocketStatusIndicator builds its class as bg-{status.theme}, which the source
scanner can't see. Must stay below every @import to keep those valid. */
@source inline("bg-success bg-warning bg-error bg-gray-500");
@config "../../tailwind.config.js";

View file

@ -16,9 +16,8 @@ import {get} from "svelte/store"
import {not, nthEq, reject} from "@welshman/lib"
import {makeHttpAuth, makeHttpAuthHeader} from "@welshman/util"
import {makeRoomKey} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
import {user} from "@app/core"
import {getLivekitEndpoint} from "@app/relays"
import {pushToast} from "@app/toast"
import {
CallState,
@ -39,6 +38,42 @@ import {
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
export class AbortError extends Error {
constructor() {
super("Aborted")
this.name = "AbortError"
}
}
export class TimeoutError extends Error {
constructor(message = "Timed out") {
super(message)
this.name = "TimeoutError"
}
}
const whenAborted = (signal?: AbortSignal) => {
if (!signal) {
return new Promise<never>(() => {})
}
return new Promise<never>((_, reject) => {
const onAborted = () => reject(new AbortError())
if (signal.aborted) {
onAborted()
} else {
signal.addEventListener("abort", onAborted, {once: true})
}
})
}
const whenTimeout = (ms: number, opts: {message?: string; signal?: AbortSignal} = {}) => {
return new Promise<never>((_, reject) => {
const timeout = setTimeout(() => reject(new TimeoutError(opts.message)), ms)
opts.signal?.addEventListener("abort", () => clearTimeout(timeout), {once: true})
})
}
export const joinVoiceRoom = async (
url: string,
h: string,

View file

@ -3,8 +3,8 @@
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import IconPickerButton from "@lib/components/IconPickerButton.svelte"
import ImageInputButton from "@lib/components/ImageInputButton.svelte"
import IconPickerButton from "@app/components/IconPickerButton.svelte"
import ImageInputButton from "@app/components/ImageInputButton.svelte"
type Props = {
// The parent uploads this on submit — never `preview`, which may be a data: URL.

View file

@ -8,7 +8,6 @@
import FieldInline from "@lib/components/FieldInline.svelte"
import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import IconInput from "@lib/components/IconInput.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import {rooms} from "@app/core"
@ -17,6 +16,7 @@
import {resolveImageInput} from "@app/uploads"
import {deriveHasLivekit} from "@app/relays"
import {RoomType} from "@app/rooms"
import IconInput from "@app/components/IconInput.svelte"
type Props = {
url: string

View file

@ -14,11 +14,11 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import IconInput from "@lib/components/IconInput.svelte"
import {pushToast} from "@app/toast"
import {clearModals} from "@app/modal"
import {resolveImageInput} from "@app/uploads"
import {relayManagement, relays} from "@app/core"
import IconInput from "@app/components/IconInput.svelte"
type Props = {
url: string

View file

@ -12,12 +12,11 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {AbortError, TimeoutError} from "$lib/util"
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {displayRoom} from "@app/rooms"
import {deriveCallParticipants, loadCallParticipants} from "@app/call"
import {joinVoiceRoom} from "@app/callEngine"
import {AbortError, TimeoutError, joinVoiceRoom} from "@app/callEngine"
import {popModal} from "@app/modal"
import {pushToast} from "@app/toast"

View file

@ -15,7 +15,6 @@
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import Input from "@lib/components/Input.svelte"
import IconInput from "@lib/components/IconInput.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Modal from "@lib/components/Modal.svelte"
@ -29,6 +28,7 @@
import {HOSTING_RELAY_DOMAIN} from "@app/env"
import {pushToast} from "@app/toast"
import {resolveImageInput} from "@app/uploads"
import IconInput from "@app/components/IconInput.svelte"
type Props = {
mode: "create" | "edit"

View file

@ -0,0 +1,44 @@
@layer components {
.role-badge {
@apply inline-flex min-w-0 items-center gap-1.5 rounded-full border-2 border-solid px-2 py-1 text-xs font-normal;
background: color-mix(in srgb, var(--role-color) 12%, transparent);
color: var(--role-color);
border-color: color-mix(in srgb, var(--role-color) 22%, transparent);
}
.role-badge__dot {
@apply size-1.5 shrink-0 rounded-full;
background-color: var(--role-color);
}
.role-badge--md {
@apply text-sm;
}
.role-badge--md .role-badge__dot {
@apply size-2;
}
[data-fl-theme="clay"] .role-badge {
@apply bg-surface border-line rounded-xl;
border-width: var(--border-thin, 2px);
border-color: color-mix(in srgb, var(--role-color) 22%, var(--line));
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
box-shadow:
4px 4px 0 color-mix(in srgb, var(--role-color) 28%, transparent),
var(--shadow-inset);
}
[data-fl-theme="clay"] .role-badge__dot {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--role-color) 18%, transparent);
}
[data-fl-theme="clay"] .role-badge--md {
@apply px-2.5 py-1.5;
box-shadow:
5px 5px 0 color-mix(in srgb, var(--role-color) 28%, transparent),
var(--shadow-inset);
}
}

View file

@ -83,6 +83,10 @@
@apply flex items-center gap-1 text-sm;
}
body.keyboard-open .room {
@apply mb-0;
}
[data-fl-theme="clay"] .room {
@apply bg-surface;

View file

@ -3,7 +3,6 @@ import {call, on, simpleCache} from "@welshman/lib"
import {normalizeRelayUrl} from "@welshman/util"
import {AuthStateEvent, AuthStatus, SocketEvent, SocketStatus} from "@welshman/net"
import {throttled} from "@welshman/store"
import {checkRelayHasLivekit} from "$lib/livekit"
import {app} from "@app/core"
import {relaysMostlyRestricted} from "@app/policies"
import {colorFor} from "@app/theme"
@ -96,8 +95,16 @@ export const deriveSocketStatus = (url: string) =>
}),
)
const livekitUrl = (url: string) =>
url.replace(/^ws/, "http").replace(/\/$/, "") + "/.well-known/nip29/livekit"
export const getLivekitEndpoint = (url: string, roomId: string) => `${livekitUrl(url)}/${roomId}`
export const deriveHasLivekit = simpleCache(([url]: [string]) =>
readable<boolean | undefined>(undefined, set => {
checkRelayHasLivekit(url).then(has => set(has))
fetch(livekitUrl(url)).then(
response => set(response.status === 204),
() => set(false),
)
}),
)

View file

@ -1,8 +1,7 @@
import type {Maybe} from "@welshman/lib"
import {first, normalizeUrl, parseJson, sha256, simpleCache} from "@welshman/lib"
import {first, normalizeUrl, parseJson, randomId, sha256, simpleCache} from "@welshman/lib"
import {canUploadBlob, encryptFile, makeBlossomAuthEvent, uploadBlob} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
import {compressFile} from "@lib/html"
import {app, blossomServerLists, relays} from "@app/core"
import {DEFAULT_BLOSSOM_SERVERS} from "@app/env"
@ -68,6 +67,39 @@ export type CompressFileOptions = {
mimeType?: string
}
const compressFile = async (file: File, options: CompressFileOptions) => {
const {default: Compressor} = await import("compressorjs-next")
return new Promise<File>((resolve, reject) => {
new Compressor(file, {
maxWidth: 2048,
maxHeight: 2048,
convertTypes: ["image/png"],
...options,
success: result => {
// Capacitor's fetch interceptor checks instanceof File, and canvas.toBlob returns a Blob.
if (result instanceof File) {
resolve(result)
} else {
resolve(new File([result], file.name || "upload", {type: result.type}))
}
},
error: e => {
// Non-images break compressor, return the original file
if (e.toString().includes("File or Blob")) {
resolve(
new File([file], `${randomId()}.${file.type.split("/")[1] || "bin"}`, {
type: file.type,
}),
)
} else {
reject(e)
}
},
})
})
}
// Webp/gif/svg either break the compressor or lose animation, pass them through untouched
export const compressFileForUpload = async (file: File, options: CompressFileOptions = {}) =>
file.type.match("image/(webp|gif|svg)") ? file : compressFile(file, options)

View file

@ -35,24 +35,4 @@
border-color: color-mix(in srgb, var(--warning) 25%, transparent);
}
}
.role-badge {
@apply inline-flex min-w-0 items-center gap-1.5 rounded-full border-2 border-solid px-2 py-1 text-xs font-normal;
background: color-mix(in srgb, var(--role-color) 12%, transparent);
color: var(--role-color);
border-color: color-mix(in srgb, var(--role-color) 22%, transparent);
}
.role-badge__dot {
@apply size-1.5 shrink-0 rounded-full;
background-color: var(--role-color);
}
.role-badge--md {
@apply text-sm;
}
.role-badge--md .role-badge__dot {
@apply size-2;
}
}

View file

@ -201,9 +201,6 @@ body.keyboard-open {
body.keyboard-open .hide-on-keyboard {
@apply hidden;
}
body.keyboard-open .room {
@apply mb-0;
}
/* ----------------------------------------------------------------------------
Fonts shared/default across all themes

View file

@ -108,27 +108,4 @@
.chat-editor .tiptap {
border-color: var(--line);
}
.role-badge {
@apply bg-surface border-line rounded-xl;
border-width: var(--border-thin, 2px);
border-color: color-mix(in srgb, var(--role-color) 22%, var(--line));
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
box-shadow:
4px 4px 0 color-mix(in srgb, var(--role-color) 28%, transparent),
var(--shadow-inset);
}
.role-badge__dot {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--role-color) 18%, transparent);
}
.role-badge--md {
@apply px-2.5 py-1.5;
box-shadow:
5px 5px 0 color-mix(in srgb, var(--role-color) 28%, transparent),
var(--shadow-inset);
}
}

View file

@ -1,5 +1,5 @@
/* ============================================================================
Design-system entry point (imported once, in src/routes/+layout.svelte).
Design-system entry point (imported once, by src/app/app.css).
tailwindcss — framework + our @theme token registration (via base.css)
base.css — theme-independent structure (base, utilities, integrations)
@ -24,7 +24,6 @@
@import "./button.css";
@import "./card.css";
@import "./card-button.css";
@import "./chat.css";
@import "./content-markdown.css";
@import "./content-search.css";
@import "./controls.css";
@ -36,14 +35,6 @@
@import "./menu.css";
@import "./page.css";
@import "./primary-nav.css";
@import "./room.css";
@import "./spinner.css";
@import "./secondary-nav.css";
@import "./space-menu.css";
@import "./tooltip.css";
/* SocketStatusIndicator builds its class as bg-{status.theme}, which the source
scanner can't see. Must stay below every @import to keep those valid. */
@source inline("bg-success bg-warning bg-error bg-gray-500");
@config "../../../tailwind.config.js";

View file

@ -1,5 +1,5 @@
import {readable} from "svelte/store"
import {sleep, randomId} from "@welshman/lib"
import {sleep} from "@welshman/lib"
import {Capacitor} from "@capacitor/core"
export {preventDefault, stopPropagation} from "svelte/legacy"
@ -160,44 +160,6 @@ export const isIntersecting = async (element: Element) =>
observer.observe(element)
})
export const compressFile = async (
file: File | Blob,
options: Record<string, any> = {},
): Promise<File> => {
const {default: Compressor} = await import("compressorjs-next")
return new Promise<File>((resolve, _reject) => {
new Compressor(file, {
maxWidth: 2048,
maxHeight: 2048,
convertTypes: ["image/png"],
...options,
success: result => {
// Capacitor's fetch interceptor checks instanceof File, and canvas.toBlob returns a Blob.
const f =
result instanceof File
? result
: new File([result], (result as any).name || (file as any).name || "upload", {
type: result.type,
})
resolve(f)
},
error: e => {
// Non-images break compressor, return the original file
if (e.toString().includes("File or Blob")) {
if (file instanceof Blob) {
file = new File([file], `${randomId()}.${file.type}`, {type: file.type})
}
return resolve(file as File)
}
_reject(e)
},
})
})
}
export const escapeHtml = (html: string) => {
const element = document.createElement("div")

View file

@ -1,23 +0,0 @@
const toHttpUrl = (url: string) =>
url
.replace(/^wss:\/\//, "https://")
.replace(/^ws:\/\//, "http://")
.replace(/\/$/, "")
const livekitEndpoint = (url: string, roomId?: string) => {
const base = `${toHttpUrl(url)}/.well-known/nip29/livekit`
return roomId ? `${base}/${roomId}` : base
}
export const checkRelayHasLivekit = async (url: string): Promise<boolean> => {
const endpoint = livekitEndpoint(url)
try {
const response = await fetch(endpoint)
return response.status === 204
} catch {
return false
}
}
export const getLivekitEndpoint = (url: string, roomId: string) => livekitEndpoint(url, roomId)

View file

@ -44,44 +44,6 @@ export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1)
export const errorMessage = (err: unknown) => String(err).replace(/^.*Error: /, "")
export class AbortError extends Error {
constructor() {
super("Aborted")
this.name = "AbortError"
}
}
export class TimeoutError extends Error {
constructor(message = "Timed out") {
super(message)
this.name = "TimeoutError"
}
}
/** Returns a promise that rejects with AbortError when signal aborts. Use with Promise.race. */
export const whenAborted = (signal?: AbortSignal) => {
if (!signal) {
return new Promise<never>(() => {})
}
return new Promise<never>((_, reject) => {
const onAborted = () => reject(new AbortError())
if (signal.aborted) {
onAborted()
} else {
signal.addEventListener("abort", onAborted, {once: true})
}
})
}
/** Rejects with TimeoutError after ms, clearing the timer when `signal` aborts. */
export const whenTimeout = (ms: number, opts: {message?: string; signal?: AbortSignal} = {}) => {
return new Promise<never>((_, reject) => {
const timeout = setTimeout(() => reject(new TimeoutError(opts.message)), ms)
opts.signal?.addEventListener("abort", () => clearTimeout(timeout), {once: true})
})
}
export const buildUrl = (base: string | URL, ...pathname: string[]) => {
const url = new URL(base)

View file

@ -1,5 +1,5 @@
<script lang="ts">
import "@lib/components/theme.css"
import "@app/app.css"
import "@welshman/editor/index.css"
import * as nip19 from "nostr-tools/nip19"
import type {Unsubscriber} from "svelte/store"