Compare commits

...

91 commits

Author SHA1 Message Date
Jon Staab
91a8995a98 Stop recommending relay.tools 2026-08-02 07:13:43 -07:00
Jon Staab
934f1cdf10 Optimize deriveUserItem, avoid double load of quotes 2026-07-30 09:50:41 -07:00
Jon Staab
2a5849afcb Fix tooltip z index 2026-07-29 17:30:59 -07:00
Jon Staab
9a80d059bb Migrate to new welshman 2026-07-29 15:52:13 -07:00
Jon Staab
d02e0ed56a Add hosting nav item 2026-07-29 15:49:59 -07:00
Jon Staab
b0ebb988f1 Bump version, update changelog 2026-07-29 14:49:46 -07:00
Jon Staab
954a11b923 Fix nav safe are inset, qr code reactivity 2026-07-29 11:00:26 -07:00
Jon Staab
6fed1019df Perf optimizations for chat rooms 2026-07-27 17:13:14 -07:00
Jon Staab
8953b0e04d Fix agent errors 2026-07-27 16:43:54 -07:00
Jon Staab
a2c5a8e2ff Clean up convention violations, dead code, and daisyUI leftovers 2026-07-27 15:38:23 -07:00
Jon Staab
c0c8354a76 Remove dead CSS left over from the app.css split and fix emoji button hit target 2026-07-27 15:25:31 -07:00
Jon Staab
ed61081b01 Fix key backup gating, mention alerts toggle, and silent load failures 2026-07-27 15:21:39 -07:00
Jon Staab
73f2fb0ec2 Fix stuck hosting error card and blocked billing portal redirect 2026-07-27 15:18:07 -07:00
Jon Staab
0f17e62e54 Fix pin references for non-addressable events, malformed shelf addresses, and profile links 2026-07-27 15:16:21 -07:00
Jon Staab
883528242c Fix space auth prompts, invite failures, and admin check spam
Cache admin checks per space with a ttl, stop retrying auth after a denial,
surface invites we couldn't fetch a claim for, and trim access delays.
2026-07-27 15:09:13 -07:00
Jon Staab
fe90a9f0ba Fix call mute failures, stale session writes, and leaked audio elements 2026-07-27 15:01:48 -07:00
Jon Staab
eae644ab3c Fix connection teardown and sync lifecycle on login 2026-07-27 14:57:02 -07:00
Jon Staab
ae5992c948 refactor h/non-h rooms into a single component 2026-07-27 14:11:46 -07:00
Jon Staab
17b0382c2e formatting 2026-07-27 13:48:54 -07:00
Jon Staab
2ffe51180b Fix new messages indicator when coming back to the tab 2026-07-27 13:38:45 -07:00
Jon Staab
cf938c6397 Bring caravel hosting in-app 2026-07-27 13:19:28 -07:00
Aditya Chaudhary
37e6dd6b7d chore: hide space menu scrollbars while keeping scroll. (#328)
Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/328
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
Co-committed-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-07-21 18:06:46 +00:00
Aditya Chaudhary
2d6810725f fix: clear space badge when navigating to a space (#327)
Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/327
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
Co-committed-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-07-21 18:05:39 +00:00
Aditya Chaudhary
1eebe1698a feat: add room invite links with combined space and room join flow (#319)
Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/319
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
Co-committed-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-07-20 23:35:24 +00:00
Aditya Chaudhary
16a446095a fix(video): show avatar tiles for audio-only and camera-off callers (#325)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
Co-committed-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-07-20 23:34:06 +00:00
Aditya Chaudhary
caa16b6770 chore: improve mobile UI, zap modals, and interaction polish (#316)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
Co-committed-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-07-15 21:23:43 +00:00
Jon Staab
e1c439fb6b Publish events signed by the relay to the relay 2026-07-10 08:17:01 -07:00
Jon Staab
e9f896f5ef Tweak side nav sections 2026-07-08 15:20:16 -07:00
Jon Staab
3a6be2fc92 Fix nav safe are inset, qr code reactivity 2026-07-08 15:12:50 -07:00
Jon Staab
ba7a690d6f Make downloadText work on native 2026-07-08 14:53:49 -07:00
Jon Staab
47be12ff57 Bring back attemptRelayAccess for default spaces 2026-07-08 14:43:44 -07:00
Jon Staab
f7d0cb7413 remove claims from default space urls, fix universal link handlers 2026-07-08 14:33:25 -07:00
Jon Staab
c139722261 Make content components reactive 2026-07-08 14:13:04 -07:00
Jon Staab
a254ec10f1 Fix new messages indicator 2026-07-08 11:53:57 -07:00
Jon Staab
8f7016b60c Back to hue-only role colors 2026-07-08 11:18:28 -07:00
Jon Staab
1581d909f0 Fix editing featured content, add menu to boards and ability to share boards/pins to chat 2026-07-08 11:18:28 -07:00
Jon Staab
269630efc2 Fix room suggestions based on platform relays 2026-07-08 11:18:28 -07:00
Jon Staab
7fe145dbef Trim padding on push logo 2026-07-08 11:18:27 -07:00
Aditya Chaudhary
f4fc9b82c3 feat: redesign mobile space sidebar with card-based layout. (#318)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
Co-committed-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
2026-07-07 21:23:52 +00:00
Jon Staab
7011d0ac81 Ignore generated icons 2026-07-07 13:50:38 -07:00
Jon Staab
f00687d20e Generate push icon 2026-07-07 12:31:10 -07:00
Jon Staab
5843b3e33a Tweak room name display 2026-07-06 13:32:19 -07:00
Jon Staab
c694654417 Fix order for joining spaces 2026-07-06 13:31:58 -07:00
Jon Staab
9cb5e54aeb Avoid showing auth error before join dialog 2026-07-06 13:08:30 -07:00
Jon Staab
774e2da463 Add description to pins 2026-07-06 11:53:12 -07:00
Jon Staab
f20414975d Whenever our pubkey changes clear connections and reopen 2026-07-06 11:40:00 -07:00
Jon Staab
00b3fe054c Only show connection status in space join on error 2026-07-06 11:35:42 -07:00
Jon Staab
2f7276a92d Only show space join status if there's an error 2026-07-03 17:24:14 -07:00
Jon Staab
20369cc615 Split up spacejoin settings 2026-07-03 17:17:57 -07:00
Jon Staab
1ef1c22007 Fix empty favorited by circles 2026-07-03 17:14:51 -07:00
Jon Staab
e52cbcc23b Fix join page on white labeled instance 2026-07-03 17:01:37 -07:00
Jon Staab
9ca73eb162 Add small badge 2026-07-03 16:50:12 -07:00
Jon Staab
e36f2d995e Fix stale data in tippy 2026-07-03 16:48:23 -07:00
Jon Staab
99abe17151 Lighten up social graph loading 2026-07-03 16:36:52 -07:00
Jon Staab
91f0d30c84 Set default relays, include hint when loading groups 2026-07-03 16:27:35 -07:00
Jon Staab
ff3af380f8 Make featured content a grid 2026-07-03 16:20:55 -07:00
Jon Staab
e32c536787 Fix reactivity 2026-07-03 16:00:24 -07:00
Jon Staab
530e99a2ff Publish to default space relays on signup 2026-07-03 16:00:24 -07:00
Jon Staab
a6d58ba79f Swap datepicker implementations 2026-07-03 16:00:24 -07:00
Jon Staab
213605ecdf Fix date picker initial value 2026-07-03 16:00:24 -07:00
Jon Staab
ebefc83ef8 Add button-input 2026-07-03 16:00:24 -07:00
Jon Staab
b8f64a5f49 Allow pinning anything, fix a few display bugs 2026-07-03 14:02:11 -07:00
Jon Staab
c2684497f9 Fallback file upload for room/space images 2026-07-03 13:03:04 -07:00
Jon Staab
647375b8c9 Fix missing events on thunk publish in chat 2026-07-03 10:49:11 -07:00
Jon Staab
9e53cf3c61 Fix hidden profile circle 2026-07-03 10:11:12 -07:00
Jon Staab
78f23220e9 Attempt to fix spammy space join dialog 2026-07-03 09:50:37 -07:00
Jon Staab
79c154bdcb Migrate popovers to tippys 2026-07-03 09:20:45 -07:00
Jon Staab
8d7d021594 Fix popover clipping 2026-07-02 17:32:44 -07:00
Jon Staab
ac2b8ad13f Add profile info dialog 2026-07-02 17:27:28 -07:00
Jon Staab
531717119e Add profile info modal 2026-07-02 17:01:31 -07:00
Jon Staab
6046647aa0 Fix loading in profile picture input 2026-07-02 16:52:36 -07:00
Jon Staab
5bd4014d6d Tweak syncing, fix logo generation 2026-07-02 16:36:58 -07:00
Jon Staab
27252f543a Fix multiple choice poll selections not saving, bring back base64 profile image fallback but compressed 2026-07-02 14:39:21 -07:00
Jon Staab
e575be921d Compress base64 fallback 2026-07-02 14:29:30 -07:00
Jon Staab
4cba342a04 Switch from ellipsize to truncate 2026-07-02 14:22:55 -07:00
Jon Staab
05954752e2 Fix mobile navigation 2026-07-02 12:24:56 -07:00
Jon Staab
ff5d7aeb87 Add the ability to save events to the library 2026-07-02 11:36:05 -07:00
Jon Staab
61a8c44750 Prevent initial font size flash 2026-07-01 16:33:21 -07:00
Matt Lorentz
a2ab23c7ae Fix call icon consistency (#312)
Co-authored-by: Matt Lorentz <5+mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <5+mplorentz@noreply.coracle.social>
2026-07-01 23:25:29 +00:00
Jon Staab
61198f4f10 Add pinboards and content library 2026-07-01 16:23:10 -07:00
Jon Staab
518a312972 Disable negentropy in sync temporarily 2026-07-01 11:48:59 -07:00
Jon Staab
c5403f786f Prevent font size flash on settings change 2026-07-01 11:20:49 -07:00
Jon Staab
c88a981fb6 UI tweaks, change how role colors work 2026-07-01 10:23:23 -07:00
Jon Staab
182e2ce6fd work on white labeling with acl'd default spaces 2026-06-30 11:28:04 -07:00
Jon Staab
e5c752ce9e remove old env vars, add remove user to profile detail modal 2026-06-30 10:02:02 -07:00
Jon Staab
fd8795821e Fix some display bugs 2026-06-29 17:20:55 -07:00
Jon Staab
77ac59008b fix some feed loading bugs 2026-06-29 16:28:11 -07:00
Jon Staab
10119aaa6d Simplify profile notes loading 2026-06-29 15:51:18 -07:00
userAdityaa
0b26eb8058 feat: add full profile page at /people/[npub] 2026-06-29 21:19:53 +05:30
Jon Staab
486385cf1f Refactor css, lib components, add themes 2026-06-26 17:25:34 -07:00
Matt Lorentz
90561d46ad Refactor voice and video module (#310)
Co-authored-by: Matt Lorentz <5+mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <5+mplorentz@noreply.coracle.social>
2026-06-26 22:56:49 +00:00
475 changed files with 17405 additions and 10979 deletions

View file

@ -1,634 +1,279 @@
--- ---
name: welshman-app name: welshman-app
description: "Use this skill when working with @welshman/app: high-level Svelte stores for nostr apps, session management, WoT (web of trust), making requests, publishing events, user data, or relay selection at the app layer." 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."
--- ---
# welshman/app — Application Layer Stores # welshman/app — The App Instance and its Plugins
`@welshman/app` is the top-level application framework in the welshman stack. It wires together `@welshman/net` (subscriptions/publishing), `@welshman/store` (reactive collections), `@welshman/router` (relay selection), and `@welshman/signer` (key management) into ready-to-use Svelte stores and high-level utilities. It powers production apps like Coracle and Flotilla. `@welshman/app` composes `net`, `store`, `domain`, `signer`, and `feeds` into an application
framework built around a single `App` object.
## Installation ## Installation
```bash ```bash
npm install @welshman/app npm i @welshman/app
# or
pnpm add @welshman/app
yarn add @welshman/app
``` ```
## Key Exports ## The App
### Core Singletons ```typescript
import {App, createApp, User} from "@welshman/app"
| Export | Description | const app = createApp({
user: await User.fromSigner(signer), // omit for a signed-out app
config: {
dufflepudUrl: "https://dufflepud.example.com",
getDefaultRelays: () => ["wss://relay.example.com"],
getIndexerRelays: () => ["wss://indexer.example.com"],
getSearchRelays: () => ["wss://search.example.com"],
},
getAdapter, // optional: custom net adapters (tests, mocks)
policies, // optional: overrides the defaults
})
```
An `App` owns everything scoped to one identity:
| Property | What it is |
|---|---| |---|---|
| `repository` | Singleton `Repository` from `@welshman/net`; non-DVM, non-ephemeral events received from the pool are stored here. WRAP (NIP-59) events are handled separately via `unwrapAndStore` and require `shouldUnwrap` to be set to `true` to process. | | `app.user` | the signed-in `User`, or `undefined` |
| `tracker` | Singleton `Tracker`; maps event IDs to the relays they were seen on | | `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 |
### Session Management `createApp` is `new App` plus `defaultAppPolicies`. Use `new App({policies: [...]})` for a bare app.
| Export | Description | **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:
```typescript
app.use(Profiles).load(pubkey)
app.use(RelayLists).writeUrls(pubkey).get()
```
### Plugin base classes
| Base | Shape |
|---|---| |---|---|
| `pubkey` | `Writable<string \| undefined>` — active session's pubkey | | `MapPlugin<T>` | a plain keyed map of non-event data (relay stats, NIP-11 info) |
| `session` | `Readable<Session \| undefined>` — derived from `pubkey` + `sessions` | | `LoadableMapPlugin<T>` | a `MapPlugin` that knows how to `fetch(key)` from the network |
| `sessions` | `Writable<Record<string, Session>>` — all loaded sessions | | `DerivedPlugin<T>` | a keyed collection **derived from the repository** — the repository is the source of truth, never a duplicated map |
| `signer` | `Readable<ISigner \| undefined>` — signer for the active session | | `RelayScopedDerivedPlugin<T>` | keyed by `getKey(item, url)` per relay, for data that only means something relative to a relay |
| `signerLog` | `WritableWithGetter<SignerLogEntry[]>` — writable store that the session layer appends signer-operation entries to (useful for UI feedback during remote signing) | | `RelaySignedDerivedPlugin<T>` | the same, but only accepts events authored by the relay's NIP-11 `self` pubkey (NIP-29 room state, relay membership/roles) |
| `SessionMethod` | Enum: `Nip01`, `Nip07`, `Nip46`, `Nip55`, `Pomade`, `Pubkey`, `Anonymous` |
**Login functions** (all call `addSession` internally): Derived plugins expose:
- `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
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)`.
### Available plugins
**Core:** `Network`, `Router`, `Domain`, `Thunks`, `Sync`, `Logger`, `Plaintext`
**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.
```typescript ```typescript
loginWithNip01(secret: string): void import {User, createApp, nip07, toSession} from "@welshman/app"
loginWithNip07(pubkey: string): void
loginWithNip46(pubkey, clientSecret, signerPubkey, relays): void const session = toSession(nip07, {pubkey})
loginWithNip55(pubkey: string, signerPackageName: string): void const user = await User.fromSession(session) // undefined if the handler can't build a signer
loginWithPomade(pubkey, email, clientOptions): void
loginWithPubkey(pubkey: string): void // read-only const app = createApp({user})
``` ```
**Session utilities**: `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:
```typescript ```typescript
addSession(session: Session): void // add and activate import {Domain, publish} from "@welshman/app"
dropSession(pubkey: string): void // remove and clean up signer import {Note} from "@welshman/domain"
getSession(pubkey: string): Session | undefined
updateSession(pubkey, fn): void const writer = app.use(Domain).writer(Note).setContent("hello")
clearSessions(): void const command = await app.use(Domain).command(writer)
nip46Perms: string // default NIP-46 permission string
command.publish() // to the resolved relays
command.publishToRelays(urls) // to specific relays
command.publishAsRelay(url) // signed by the relay itself (NIP-86)
``` ```
**Gift wrap (NIP-59)**: Plugin mutators already return a `Command`, so `.then(publish)` is the common shape:
```typescript ```typescript
shouldUnwrap: Writable<boolean> // must be true to process incoming wraps await app.use(FollowLists).follow(["p", pubkey]).then(publish)
wrapManager: WrapManager // tracks wrap↔rumor mappings await app.use(RelayLists).addWriteUrl(url).then(publish)
unwrapAndStore(wrap: SignedEvent): Promise<void>
``` ```
### Publishing — Thunks Free-function forms exist for pipelines: `publish`, `publishToRelays(urls)`,
`publishAsRelay(url)`, `signAsRelay(url)`.
| Export | Description | ### Thunks
|---|---|
| `publishThunk(options: ThunkOptions): Thunk` | Create, enqueue, and optimistically publish an event |
| `Thunk` | Class representing a single in-flight publish |
| `MergedThunk` | Aggregates multiple `Thunk`s (used by `sendWrapped`) |
| `thunks` | `Writable<Thunk[]>` — all active thunks |
| `abortThunk(thunk)` | Abort all constituent thunks |
| `retryThunk(thunk)` | Re-publish with original options |
| `mergeThunks(thunks[])` | Combine into a `MergedThunk` |
**Thunk status helpers**: `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 ```typescript
thunkIsComplete(thunk): boolean const thunk = app.use(Thunks).publish({event, relays})
getThunkError(thunk): string | undefined
getThunkUrlsWithStatus(statuses, thunk): string[] thunk.getUrlsWithStatus(PublishStatus.Success)
getCompleteThunkUrls(thunk): string[] thunk.getFailedUrls()
getFailedThunkUrls(thunk): string[] thunk.isComplete()
waitForThunkError(thunk): Promise<string> await thunk.waitForError() // "" when everything succeeded
waitForThunkCompletion(thunk): Promise<void> await thunk.waitForCompletion()
``` ```
`ThunkOptions`: `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 ```typescript
type ThunkOptions = { const network = app.use(Network)
event: EventTemplate // unsigned — will be signed lazily
relays: string[] network.load({relays, filters}) // batched, deduped, shared loader
recipient?: string // if set, event is NIP-59 gift-wrapped network.request({relays, filters, onEvent})
delay?: number // ms to wait before sending (abort window) network.publish({event, relays})
pow?: number // proof-of-work difficulty target network.loadUsingOutbox(pubkey, filter) // newest matching event from the author's write relays
timeout?: number // ms per relay before marking as timed out network.loadAllUsingOutbox(pubkey, filter) // every matching event
// PublishOptions callbacks: onSuccess, onFailure, onPending, onTimeout, onAborted, onComplete ```
Prefer a plugin's `one(key)` / `load(key)` when one exists — they handle outbox routing and
caching for you. The bare `load`/`request`/`publish` from `@welshman/net` need an explicit
`context`; `Network` supplies `app.netContext`.
## Relay selection
Routing is the `RelaySelection` DSL from `@welshman/util`, resolved by `app.use(Router)`:
```typescript
import {outbox, inbox, seen, userOutbox, indexers, relay, relays} from "@welshman/util"
const scenario = await app.use(Router).resolve([userOutbox(), outbox(pubkey)])
const urls = scenario.getUrls()
// single best relay for a route
const hint = await app.use(Router).resolver.relay([outbox(event.pubkey)])
```
Selections are weighted (`outbox(pubkey, 2)`), and resolution is **async** — it may need to load
the target's relay list first.
## App policies
An `AppPolicy` is `(app) => Unsubscriber`, applied once at construction and torn down by
`cleanup()`. Policies own everything that subscribes or wires components together, keeping the
data classes free of side effects.
Built-ins: `appPolicyIngest`, `appPolicyRelayStats`, `appPolicyWraps`, `appPolicyCacheDecrypt`,
`appPolicyLogSignerMethods`, plus auth: `appPolicyAuthNever`, `appPolicyAuthAlways`,
`appPolicyAuthUnlessBlocked`, and `makeAppPolicyAuth(shouldAuth)` for a custom predicate.
```typescript
const app = createApp({
user,
policies: [...defaultAppPolicies, appPolicyAuthUnlessBlocked, myPolicy],
})
const myPolicy: AppPolicy = app => {
const unsubscribe = on(app.repository, "update", handleUpdate)
return unsubscribe
} }
``` ```
### Commands (Higher-level Thunk Factories) **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.
Most return a `Thunk` (or `Promise<Thunk>`). They automatically load the relevant user list before modifying it. Exception: `manageRelay` returns `Promise<Response>` (an HTTP response from the NIP-86 management endpoint), not a Thunk. ## Web of trust
| Export | Description |
|---|---|
| `setProfile(profile: Profile)` | Publish NIP-01 profile metadata |
| `follow(tag: string[])` | Add to NIP-02 follow list |
| `unfollow(value: string)` | Remove from follow list |
| `mutePublicly(tag)` | Add to public mute list |
| `mutePrivately(tag)` | Add to private (encrypted) mute list |
| `unmute(value)` | Remove from mute list |
| `setMutes({publicTags?, privateTags?})` | Replace entire mute list |
| `pin(tag)` / `unpin(value)` | Manage pin list |
| `addRelay(url, mode)` / `removeRelay(url, mode)` | NIP-65 relay list management |
| `setRelays(tags)` / `setReadRelays(urls)` / `setWriteRelays(urls)` | Bulk relay list updates |
| `addMessagingRelay(url)` / `removeMessagingRelay(url)` | NIP-17 messaging relay list |
| `addBlockedRelay(url)` / `removeBlockedRelay(url)` | Blocked relay list |
| `addSearchRelay(url)` / `removeSearchRelay(url)` | Search relay list |
| `sendWrapped({event, recipients, ...options})` | NIP-59 gift-wrap to multiple recipients |
| `manageRelay(url, request)` | NIP-86 relay management |
| `createRoom` / `editRoom` / `deleteRoom` / `joinRoom` / `leaveRoom` | NIP-29 group room management |
### Profiles
```typescript ```typescript
profilesByPubkey: Readable<Map<string, Profile>> const wot = app.use(Wot)
profiles: Readable<Profile[]>
getProfile(pubkey: string): Profile | undefined wot.follows(pubkey).get()
loadProfile(pubkey, relayHints?): Promise<void> wot.followers(pubkey).get()
forceLoadProfile(pubkey, relayHints?): Promise<void> wot.network(pubkey).$ // follows-of-follows
deriveProfile(pubkey: string): Readable<Profile | undefined> // auto-loads wot.followsWhoFollow(pubkey, target).$
deriveProfileDisplay(pubkey: string): Readable<string> // display name with fallback wot.wotScore(pubkey, target).$
displayProfileByPubkey(pubkey: string): string // synchronous
``` ```
### Follow / Mute / Pin Lists ## Feeds and sync
Each list type follows the same pattern (`follow` shown, `mute` and `pin` are identical):
```typescript ```typescript
followListsByPubkey: Readable<Map<string, List>> app.use(Feeds).makeFeedController({feed, onEvent, ...})
followLists: Readable<List[]> app.use(Feeds).getPubkeysForScope(scope)
getFollowList(pubkey): List | undefined app.use(Feeds).forAuthor(pubkey).$
loadFollowList(pubkey, relayHints?): Promise<void>
forceLoadFollowList(pubkey, relayHints?): Promise<void> app.use(Sync).pull({relays, filters}) // negentropy: fetch what we're missing
deriveFollowList(pubkey): Readable<List | undefined> app.use(Sync).push({relays, filters}) // publish what the relay is missing
``` ```
### Relay Lists ## Using welshman stores outside Svelte
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.
```typescript ```typescript
// NIP-65 relay lists // React
relayListsByPubkey / relayLists / getRelayList / loadRelayList / deriveRelayList const useStore = <T>(store: Readable<T>): T => {
const [value, setValue] = useState<T>(() => get(store))
// NIP-17 messaging relay lists
messagingRelayListsByPubkey / messagingRelayLists / getMessagingRelayList / loadMessagingRelayList / deriveMessagingRelayList
// Blocked relay lists
blockedRelayListsByPubkey / getBlockedRelayList / loadBlockedRelayList / deriveBlockedRelayList
// Search relay lists (internal only — not exported from @welshman/app)
// Use userSearchRelayList / loadUserSearchRelayList / forceLoadUserSearchRelayList from user.ts instead
```
### Outbox Loading
`makeOutboxLoader` creates a loader function for any event kind. It looks up the target pubkey's relay list (fetching it if needed), then fetches events from their write relays using the outbox model. This is the internal mechanism used by all built-in `loadX` helpers.
```typescript
import {makeOutboxLoader} from '@welshman/app'
// Signature: makeOutboxLoader(kind, filter?, limit?)
// Returns: (pubkey: string, relayHints?: string[]) => Promise<void>
// Results are stored in repository — read via the derived store/getter, not the return value.
// Loader for kind 1 notes (default limit = 1)
const loadNote = makeOutboxLoader(1)
await loadNote('target-pubkey')
// With extra filter constraints
const loadRecentNotes = makeOutboxLoader(1, {since: Math.floor(Date.now() / 1000) - 86400})
await loadRecentNotes('target-pubkey')
// Override the limit via the third positional argument (not inside the filter object)
const loadMany = makeOutboxLoader(1, {}, 20)
await loadMany('target-pubkey')
// With relay hints to seed the lookup
await loadNote('target-pubkey', ['wss://relay.damus.io/'])
```
**Relay URL helpers** (exported from index):
```typescript
getPubkeyRelays(pubkey: string, mode?: RelayMode): string[]
derivePubkeyRelays(pubkey: string, mode?: RelayMode): Readable<string[]>
```
### User Data Stores (current session)
These automatically derive from the active `pubkey` and trigger a load on first access:
```typescript
userProfile: Readable<Profile | undefined>
userFollowList: Readable<List | undefined>
userMuteList: Readable<List | undefined>
userPinList: Readable<List | undefined>
userRelayList: Readable<List | undefined>
userMessagingRelayList: Readable<List | undefined>
userSearchRelayList: Readable<List | undefined>
userBlockedRelayList: Readable<List | undefined>
userBlossomServerList: Readable<List | undefined>
```
Corresponding loaders (operate on the current session's pubkey):
```typescript
loadUserProfile(relays?)
forceLoadUserProfile(relays?)
loadUserFollowList / forceLoadUserFollowList
loadUserMuteList / forceLoadUserMuteList
loadUserPinList / forceLoadUserPinList
loadUserRelayList / forceLoadUserRelayList
loadUserMessagingRelayList / forceLoadUserMessagingRelayList
// ...etc for each list type
```
### Router
```typescript
import {Router, routerContext, addMaximalFallbacks, addMinimalFallbacks} from '@welshman/router'
// The index.ts wires up routerContext automatically:
// routerContext.getUserPubkey, getPubkeyRelays, getRelayQuality, getDefaultRelays, etc.
Router.get() // singleton with app-wired context
Router.get().FromUser() // relays to publish from the current user
Router.get().ForPubkey(pubkey) // relays to read a pubkey's events
Router.get().Event(event) // best relay for a specific event
Router.get().Index() // indexer/bootstrap relays
Router.get().FromRelays(urls) // relay set from explicit URLs
.policy(addMaximalFallbacks) // add fallback relays
.limit(8)
.getUrls() // string[]
.getUrl() // string | undefined (first)
```
`routerContext` settings (configure before using router):
```typescript
import {routerContext} from '@welshman/router' // from @welshman/router, not @welshman/app
routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"]
```
### Tag Utilities
```typescript
tagPubkey(pubkey: string): string[] // ["p", pubkey, relayHint, displayName]
tagEvent(event, url?, mark?): string[][] // e-tag (+ a-tag if replaceable)
tagEventPubkeys(event): string[][] // p-tags for all mentioned pubkeys (excl. self)
tagEventForQuote(event, relay?): string[] // q-tag
tagEventForReply(event, relay?): string[][] // full reply thread tags
tagEventForComment(event, relay?): string[][]// NIP-22 comment tags
tagEventForReaction(event, relay?): string[][]// reaction tags
tagZapSplit(pubkey, split?): string[] // zap tag
```
### Web of Trust (WoT)
```typescript
// Reactive stores
followersByPubkey: Readable<Map<string, Set<string>>>
mutersByPubkey: Readable<Map<string, Set<string>>>
wotGraph: Writable<Map<string, number>> // pubkey → score; rebuilt on follow/mute changes
maxWot: Readable<number>
// Synchronous getters
getFollows(pubkey): string[]
getMutes(pubkey): string[]
getFollowers(pubkey): string[]
getMuters(pubkey): string[]
getNetwork(pubkey): string[] // follows-of-follows (excludes direct follows)
getFollowsWhoFollow(pubkey, target): string[]
getFollowsWhoMute(pubkey, target): string[]
getWotScore(pubkey, target): number // follows-who-follow minus follows-who-mute
// Per-user reactive score
getUserWotScore(tpk: string): number
deriveUserWotScore(tpk: string): Readable<number>
```
### Handles & Zappers
```typescript
handlesByNip05: Writable<Map<string, Handle>>
deriveHandle(nip05: string): Readable<Handle | undefined> // auto-loads
loadHandle(nip05): Promise<void>
zappersByLnurl: Writable<Map<string, Zapper>>
deriveZapper(lnurl: string): Readable<Zapper | undefined> // auto-loads
loadZapper(lnurl): Promise<void>
```
### Feeds
```typescript
import {makeFeedController} from '@welshman/app'
import {makeKindFeed} from '@welshman/feeds'
// makeFeedController wraps FeedController with app-level scope/WoT helpers
const ctrl = makeFeedController({
feed: makeKindFeed(NOTE),
useWindowing: true,
signal: abortController.signal,
onEvent: (e) => { /* handle event */ },
onExhausted: () => { /* no more events */ },
})
ctrl.load(100)
abortController.abort()
```
WoT-scoped feed helpers (passed automatically to `FeedController`):
```typescript
getPubkeysForScope(scope: string): string[] // Scope.Self|Follows|Network|Followers
getPubkeysForWOTRange(min: number, max: number): string[] // fractional of maxWot
```
### Sync (Negentropy)
```typescript
import {pull, push, hasNegentropy} from '@welshman/app'
// pull/push use negentropy if the relay supports it, falling back to plain requests
await pull({relays, filters})
await push({relays, filters})
hasNegentropy(url: string): boolean
```
### Application Context
```typescript
import {appContext} from '@welshman/app'
appContext.dufflepudUrl = 'https://my-dufflepud.example.com'
```
[Dufflepud](https://github.com/coracle-social/dufflepud) is an optional proxy server for NIP-05 lookups, zapper resolution, relay metadata, and link previews. Not required but helps bypass CORS.
---
## Common Patterns
### Login and publish a note
```typescript
import {makeSecret} from '@welshman/util'
import {loginWithNip07, publishThunk, signer} from '@welshman/app'
import {Router} from '@welshman/router'
import {NOTE, makeEvent} from '@welshman/util'
import {Nip07Signer} from '@welshman/signer'
// NIP-07 login
const nip07 = new Nip07Signer()
const pubkey = await nip07.getPubkey()
loginWithNip07(pubkey)
// Publish with optimistic local update and 3s undo window
const thunk = publishThunk({
event: makeEvent(NOTE, {content: 'Hello Nostr!'}),
relays: Router.get().FromUser().getUrls(),
delay: 3000,
})
// Subscribe to per-relay status
thunk.subscribe($thunk => {
for (const [url, result] of Object.entries($thunk.results)) {
console.log(url, result.status, result.detail)
}
})
// Soft-undo within delay window
setTimeout(() => thunk.controller.abort(), 1000)
// Wait for all relays to finish (thunk.complete is a Deferred<void>)
await thunk.complete
```
### Derive a reactive profile
```typescript
import {deriveProfile, deriveProfileDisplay} from '@welshman/app'
const targetPubkey = '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'
// Reactive store — loads the profile in the background on first subscribe
const profile = deriveProfile(targetPubkey)
// Reactive display name with npub fallback
const name = deriveProfileDisplay(targetPubkey)
// In Svelte
// $: displayName = $name
```
### Reply to an event
```typescript
import {publishThunk, tagEventForReply, tagPubkey, signer} from '@welshman/app'
import {Router} from '@welshman/router'
import {NOTE, makeEvent} from '@welshman/util'
import type {TrustedEvent} from '@welshman/util'
async function replyTo(parent: TrustedEvent, content: string) {
const tags = tagEventForReply(parent)
return publishThunk({
event: makeEvent(NOTE, {content, tags}),
relays: Router.get().PublishEvent(parent).getUrls(),
})
}
```
### Send a NIP-59 gift-wrapped DM
```typescript
import {sendWrapped} from '@welshman/app'
import {DIRECT_MESSAGE, makeEvent} from '@welshman/util'
const mergedThunk = await sendWrapped({
event: makeEvent(DIRECT_MESSAGE, {content: 'secret message'}),
recipients: [recipientPubkey],
})
// Monitor combined status
mergedThunk.subscribe($t => {
for (const [url, result] of Object.entries($t.results)) {
console.log(url, result.status)
}
})
```
### Follow/unfollow
```typescript
import {follow, unfollow} from '@welshman/app'
// tag format: ["p", pubkey] or ["p", pubkey, relayHint, petname]
await follow(["p", "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"])
await unfollow("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322")
```
### Web of Trust filtering
```typescript
import {deriveUserWotScore, getWotScore, wotGraph, maxWot} from '@welshman/app'
import {get} from 'svelte/store'
// Filter a list of pubkeys to those with positive WoT score
const $graph = get(wotGraph)
const trusted = pubkeys.filter(pk => ($graph.get(pk) ?? 0) > 0)
// Reactive score for a single user
const score = deriveUserWotScore(somePubkey)
// Normalize by max score (0–1 range)
const $max = get(maxWot)
const normalized = ($graph.get(somePubkey) ?? 0) / ($max || 1)
```
### Load a feed of notes
```typescript
import {makeFeedController, getPubkeysForScope} from '@welshman/app'
import {makeKindFeed} from '@welshman/feeds'
import {NOTE} from '@welshman/util'
const abort = new AbortController()
const ctrl = makeFeedController({
feed: makeKindFeed(NOTE),
useWindowing: true,
signal: abort.signal,
onEvent: event => console.log(event),
onExhausted: () => console.log('no more events'),
})
ctrl.load(50)
// cleanup
abort.abort()
```
---
## Integration Notes
- `@welshman/app` **re-exports nothing** from `@welshman/net`, `@welshman/router`, etc. Import those directly when you need low-level primitives (`load`, `request`, `publish`, `Router` scenarios beyond `FromUser`).
- The `index.ts` bootstrap code runs on import and automatically wires `routerContext` (pubkey relays, relay quality, default/indexer/search relays) and hooks `Pool` to store incoming events in `repository`. **Import `@welshman/app` early in your app entry point** so this runs before any requests. The canonical side-effect import pattern is:
```typescript
// app entry point — must be first, before any @welshman/net or @welshman/router imports
import "@welshman/app"
// Then optionally override defaults
import {routerContext} from "@welshman/router"
routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"]
```
- `repository` and `tracker` are singletons shared across the whole app. All subscriptions made through `@welshman/net` that pass through the pool will populate `repository` automatically.
- `Router` is imported from `@welshman/router` but `routerContext` is configured by `@welshman/app/index.ts`. Use `Router.get()` (not `new Router(...)`) to get the app-configured singleton.
- `deriveProfile`, `deriveFollowList`, etc. use `makeLoadItem` under the hood: they fire a network request on first subscribe if data is not already in the repository, then resolve immediately on subsequent subscribes.
- `userFollowList`, `userMuteList`, etc. are derived from `pubkey`. They automatically re-derive when the active session changes (multi-account support).
---
## Using Welshman Stores Outside Svelte
All welshman stores implement the Svelte store contract: a `subscribe(callback) → unsubscribe` method where the callback fires **synchronously** with the current value on first call, then again on every change. This makes them trivially adaptable to any reactive framework — no Svelte runtime required, only the type imports.
### React
```typescript
import {useState, useEffect} from 'react'
import type {Readable, Writable} from 'svelte/store'
// Returns the current store value; re-renders when it changes.
function useReadable<T>(store: Readable<T>): T {
const [value, setValue] = useState<T>(() => {
// subscribe fires synchronously — capture the initial value then unsub immediately
let initial!: T
store.subscribe(v => { initial = v })()
return initial
})
useEffect(() => store.subscribe(setValue), [store]) useEffect(() => store.subscribe(setValue), [store])
return value return value
} }
// Returns [currentValue, setter] — setter calls store.set directly.
function useWritable<T>(store: Writable<T>): [T, (value: T) => void] {
return [useReadable(store), store.set]
}
``` ```
Usage: For a `Projection`, subscribe to `.$` and read `.get()` for a synchronous snapshot.
```tsx ## Related skills
import {userProfile, pubkey} from '@welshman/app'
function ProfileHeader() { - `welshman-domain` — the readers/writers every plugin decodes events with
const profile = useReadable(userProfile) - `welshman-net` — sockets, adapters, request/publish lifecycle, auth
const [currentPubkey, setPubkey] = useWritable(pubkey) - `welshman-store` — the repository and the derive helpers plugins are built on
- `welshman-signer` — signer implementations behind `User`
return <div>{profile?.name ?? currentPubkey}</div> - `welshman-util` — kinds, filters, tag specs, and the `RelaySelection` DSL
}
```
### SolidJS
```typescript
import {createSignal, onCleanup} from 'solid-js'
import type {Readable, Writable} from 'svelte/store'
// Returns a SolidJS accessor (getter function); updates reactively.
function useReadable<T>(store: Readable<T>): () => T {
let initial!: T
store.subscribe(v => { initial = v })() // sync capture then unsubscribe
const [value, setValue] = createSignal<T>(initial)
onCleanup(store.subscribe(v => setValue(() => v)))
return value
}
// Returns [accessor, setter].
function useWritable<T>(store: Writable<T>): [() => T, (value: T) => void] {
return [useReadable(store), store.set]
}
```
Usage:
```tsx
import {userProfile} from '@welshman/app'
function ProfileHeader() {
const profile = useReadable(userProfile)
return <div>{profile()?.name}</div>
}
```
### Vue
```typescript
import {ref, onUnmounted} from 'vue'
import type {Readable, Writable} from 'svelte/store'
function useReadable<T>(store: Readable<T>) {
let initial!: T
store.subscribe(v => { initial = v })()
const value = ref<T>(initial)
const unsub = store.subscribe(v => { value.value = v as any })
onUnmounted(unsub)
return value // use as a readonly ref
}
```
### Notes
- **No Svelte runtime needed.** Only `svelte/store` types are imported. The store objects themselves ship with `@welshman/app`.
- **Welshman stores with `.get()`** (created via `withGetter`) can be read synchronously without subscribing — useful in event handlers and callbacks outside any reactive context. Most writable stores in `@welshman/app` expose `.get()`.
- **`subscribe` always fires immediately.** Unlike many observable libraries, the initial emission is synchronous, so the `useState` / `createSignal` initial value is always populated on first render.
---
## Gotchas & Tips
- **Thunks sign lazily.** `publishThunk` returns synchronously and immediately writes an unsigned/hashed event to `repository` for optimistic UI. Actual signing happens in a background queue. Do not assume the event has an `id` suitable for embedding in other events until signing completes.
- **`delay` is an undo window, not a debounce.** The thunk starts the delay timer immediately; if not aborted before `delay` ms, it signs and publishes. Calling `thunk.controller.abort()` after the delay has elapsed does nothing.
- **`sendWrapped` uses `recipients`, not `pubkeys`.** The docs example uses `pubkeys` but the actual type is `recipients: string[]`.
- **Gift wrap processing requires opt-in.** Set `shouldUnwrap.set(true)` to enable automatic NIP-59 unwrapping of incoming `kind:1059` events. Without this, wrapped events are silently discarded.
- **`commands` force-load lists before modifying them.** `follow()`, `unfollow()`, etc. call `forceLoadUserFollowList` to ensure they have the latest list before adding/removing, preventing accidental list truncation. Do not call these in rapid succession without awaiting each one.
- **WoT graph is rebuilt at most once per second** (throttled). Do not expect `wotGraph` to reflect a `follow()` call immediately; subscribe to the store instead.
- **`routerContext.getDefaultRelays` is throttled** with a 200 ms window by default in `index.ts`. It returns up to the 5 highest-quality known relays. Override it before any relay connections if you want a fixed bootstrap list.
- **Multiple sessions are supported.** Call `loginWith*` multiple times to add sessions. Switch the active session with `pubkey.set(otherPubkey)`. Remove a session with `dropSession(pubkey)` — this also cleans up the cached signer.
- **Stores have `.get()` via `withGetter`.** `pubkey.get()`, `signer.get()`, `session.get()`, `signerLog.get()`, `shouldUnwrap.get()` all work without `get()` from `svelte/store`. Use this for synchronous reads outside of reactive contexts.
- **`appContext.dufflepudUrl` must be set before first handle/zapper load.** There is no lazy re-fetch; set it at app startup.

View file

@ -0,0 +1,197 @@
---
name: welshman-domain
description: "Use this skill when working with @welshman/domain: reading or writing a specific nostr event kind, parsing tags, building events to publish, or adding support for a new kind. Provides a typed Reader/Writer pair per kind so you never hand-parse tags."
---
# welshman/domain — Typed Readers and Writers per Kind
`@welshman/domain` replaces ad-hoc tag digging. Every supported kind gets a **Reader** (typed
accessors over an event) and a **Writer** (a builder that renders a new event), paired in a
`KindFactory`.
**The rule this package exists to enforce:** never reach into `event.tags` yourself. Use the
reader's getter (`note.content()`, `roomMeta.name()`, `goal.amount()`). If a kind has no reader,
use `tagValue`/`tagValues` from `@welshman/util` — not `tags.find(...)`.
## Installation
```bash
npm i @welshman/domain
```
## The three pieces
```typescript
import {KindFactory} from "@welshman/domain"
KindFactory // the exported per-kind object, e.g. `Note`, `Profile`, `RoomMeta`
.configure(ctx) // binds a resolver/signer -> ConfiguredKind
.reader(event) // -> Reader instance (call .parse() before use)
.writer(reader?) // -> Writer, optionally seeded from an existing reader to edit it
```
You rarely call `configure` directly — the `Domain` plugin does it for you.
## Usage through an app
```typescript
import {Domain} from "@welshman/app"
import {Note, Profile} from "@welshman/domain"
// Read: returns a parsed reader, ready to use
const note = app.use(Domain).reader(Note)(event)
note.content() // typed accessors, no tag digging
note.author()
note.createdAt()
// Write: build, then wrap in a Command to publish
const writer = app.use(Domain).writer(Note).setContent("hello").addMention(pubkey)
const command = await app.use(Domain).command(writer)
await command.publish().waitForError()
// Edit an existing event: seed the writer with its reader
const profile = app.use(Domain).reader(Profile)(profileEvent)
const edit = app.use(Domain).writer(Profile, profile).setName("new name")
```
## Sync vs async readers
Some kinds decrypt on parse (encrypted lists, app data), so `parse()` is async for them:
- `EventReader.parse(): this`
- `AsyncEventReader.parse(): Promise<this>`
`Parsed<R> = ReturnType<R["parse"]>` captures which one you get. `app.use(Domain).reader(F)`
returns `Parsed<R>` — the reader itself for sync kinds, a promise for async ones. That's exactly
what `EventToItem` accepts, so collections keep their synchronous path where the kind allows one.
```typescript
const note = app.use(Domain).reader(Note)(event) // NoteReader
const list = await app.use(Domain).reader(MuteList)(event) // MuteListReader (decrypts)
```
## Reader base API
Every reader inherits from `BaseEventReader`:
| Method | Returns |
|---|---|
| `id()` | event id |
| `author()` | pubkey |
| `content()` | content string |
| `tags()` | raw tags (prefer a typed getter when one exists) |
| `createdAt()` | timestamp |
| `identifier()` | `d` tag |
| `address()` | `kind:pubkey:d` |
| `room()` | `h` tag |
| `protect()` | whether the event carries `-` (NIP-70) |
| `expiration()` | `expiration` tag as a number |
| `emojis()` | parsed `emoji` tags |
| `zapSplits()` | parsed `zap` tags |
Kind-specific readers add their own on top — `RoomMetaReader.name()`, `ZapGoalReader.amount()`,
`RelayMembersReader.isMember(pubkey)`, and so on.
## Writer base API
Writers are chainable and validate on render:
```typescript
writer
.setContent(content)
.addTags(...tags)
.dropTags(pred) // also keepTags(pred)
.setIdentifier(d) // defaults to a random id
.setRoom(url, h) // NIP-29 `h` tag + forced relay
.setProtected(true) // NIP-70 `-`
.setExpiration(ts)
.addMention(pubkey)
.addQuote(event)
.addZapSplit(pubkey, split)
.addEmoji(shortcode, url)
.forceRelays(...urls) // bypass routing for relay-scoped kinds
```
`readonly requiresRelays` marks kinds that are meaningless without an explicit relay (NIP-29 room
ops, relay membership). Subclasses override `validate()` to enforce kind-specific invariants —
e.g. `RoomUpdatePins` throws without a room, `ZapGoal` requires an amount and at least one relay.
## Display helpers
A few kinds export display helpers alongside their reader:
```typescript
import {displayPubkey} from "@welshman/domain"
profile.display("Anonymous") // ProfileReader: name/display_name, or the fallback
displayPubkey(pubkey) // 'npub1abc...xyz'
```
## Adding a new kind
1. Add the kind constant to `@welshman/util`'s `Kinds.ts`.
2. Create `packages/domain/src/kinds/YourKind.ts` with a Reader, a Writer, and the factory:
```typescript
import {uniq, spec} from "@welshman/lib"
import {YOUR_KIND, hexTags, tagValues} from "@welshman/util"
import {EventReader} from "../core/EventReader.js"
import {EventWriter} from "../core/EventWriter.js"
import {KindFactory} from "../core/Kind.js"
export class YourKindReader extends EventReader {
pubkeys() {
return uniq(tagValues(hexTags("p"), this.event.tags))
}
}
export class YourKindWriter extends EventWriter<YourKindReader> {
readonly requiresRelays = true
validate() {
super.validate()
if (!this.roomTag) throw new Error("YourKind requires a room")
}
setPubkeys(pubkeys: string[]) {
return this.dropTags(spec(["p"])).addTags(...uniq(pubkeys).map(pk => ["p", pk]))
}
}
export const YourKind = new KindFactory({
kind: YOUR_KIND,
reader: YourKindReader,
writer: YourKindWriter,
})
```
3. Export it from `packages/domain/src/index.ts`.
4. Add `packages/domain/__tests__/YourKind.test.ts` — every kind has one. Use the `read`, `write`,
and `buildTemplate` helpers from `./helpers.js`, and cover: reading represented tags,
round-tripping without duplicating tags, and any `validate()` rule.
## Available kinds
Profile, Note, Comment, Thread, Delete, Reaction, Report, Poll, PollResponse, Classified,
TimeEvent, AppData, Handler, HandlerRecommendation, Feed, Pin, Pinboard.
Lists: FollowList, MuteList, PinList, BookmarkList, TopicList, EmojiList, RelayList, RelaySet,
SearchRelayList, MessagingRelayList, BlossomServerList, BlockedRelayList, CommunityList,
FeedList, RoomList.
Zaps: ZapRequest, ZapReceipt, ZapGoal.
NIP-29 rooms: RoomMeta, RoomCreate, RoomEdit, RoomDelete, RoomJoin, RoomLeave, RoomAddMember,
RoomRemoveMember, RoomMembers, RoomAdmins, RoomCreatePermission, RoomPins, RoomUpdatePins.
Relay membership: RelayInvite, RelayJoin, RelayLeave, RelayAddMember, RelayRemoveMember,
RelayMembers, RelayRole.
## Related skills
- `welshman-app` — the `Domain` plugin, `Command`, and the collections that decode events for you
- `welshman-util` — kind constants, tag specs (`tagValue`, `hexTags`, `addressTags`), filters
- `welshman-store` — indexing decoded readers into reactive collections

View file

@ -138,8 +138,8 @@ import {get, writable} from "svelte/store"
import {Node, Extension, mergeAttributes} from "@tiptap/core" import {Node, Extension, mergeAttributes} from "@tiptap/core"
import {Plugin, PluginKey} from "@tiptap/pm/state" import {Plugin, PluginKey} from "@tiptap/pm/state"
import type {NodeViewRendererProps} from "@tiptap/core" import type {NodeViewRendererProps} from "@tiptap/core"
import {Router} from "@welshman/router" import {Profiles, Router, createSearch} from "@welshman/app"
import {createSearch, profiles, searchProfiles, deriveProfileDisplay} from "@welshman/app" import {outbox} from "@welshman/util"
import { import {
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps, Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
} from "@welshman/editor" } from "@welshman/editor"
@ -323,8 +323,8 @@ const onSubmit = (editor: Editor) => {
## Integration Notes ## Integration Notes
- **`@welshman/app`** — `profileSearch` and `deriveProfileDisplay` are the typical sources for mention autocomplete data and display names. - **`@welshman/app`** — `app.use(Profiles).profileSearch` and `app.use(Profiles).display(pubkey)` are the typical sources for mention autocomplete data and display names.
- **`@welshman/router`** — `Router.get().FromPubkeys([pubkey]).getUrls()` provides the relay hints encoded into nprofile bech32 strings. - **`@welshman/app`** — relay hints for nprofile bech32 strings come from `app.use(Router).resolve([outbox(pubkey)])`.
- **`@welshman/util`** — `fromNostrURI` is used internally by `EventNodeView` to strip the `nostr:` scheme before displaying. - **`@welshman/util`** — `fromNostrURI` is used internally by `EventNodeView` to strip the `nostr:` scheme before displaying.
- **`nostr-editor`** — `WelshmanExtension` extends `NostrExtension` from this package. Storage at `editor.storage.nostr` (including `getEditorTags()`) is provided by `nostr-editor`, not welshman itself. - **`nostr-editor`** — `WelshmanExtension` extends `NostrExtension` from this package. Storage at `editor.storage.nostr` (including `getEditorTags()`) is provided by `nostr-editor`, not welshman itself.
- **`@tiptap/core`** — `Editor`, `NodeViewProps`, and all extension primitives come from Tiptap. Welshman does not re-export every Tiptap helper; import additional ones directly from `@tiptap/core` as needed. - **`@tiptap/core`** — `Editor`, `NodeViewProps`, and all extension primitives come from Tiptap. Welshman does not re-export every Tiptap helper; import additional ones directly from `@tiptap/core` as needed.

View file

@ -387,9 +387,9 @@ const label = formatTimestampRelative(event.created_at) // "3 hours ago"
```typescript ```typescript
import { on } from '@welshman/lib' import { on } from '@welshman/lib'
import { repository } from '@welshman/app'
const unsub = on(repository, 'update', updates => { // Each App owns its repository.
const unsub = on(app.repository, 'update', updates => {
console.log('added', updates.flatMap(u => u.added).length, 'events') console.log('added', updates.flatMap(u => u.added).length, 'events')
}) })

View file

@ -5,7 +5,7 @@ description: "Use this skill when working with @welshman/net: relay connections,
# welshman/net — Relay Network Layer # welshman/net — Relay Network Layer
`@welshman/net` is the core networking layer for welshman-based nostr apps. It manages WebSocket relay connections, subscriptions, event publishing, NIP-42 auth, and NIP-77 negentropy sync. It sits below `@welshman/app` (which provides higher-level reactive stores and routing) 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 an `App` instance and its plugins) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities.
## Installation ## Installation
@ -135,10 +135,10 @@ Emits `"update"` with `RepositoryUpdate` (`{ added: TrustedEvent[], removed: Set
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `netContext` | Global `NetContext` config object | | `NetContext` | `{ pool?, repository?, getAdapter? }` |
| `NetContext` | `{ pool, repository, isEventValid, isEventDeleted, getAdapter? }` |
Mutate `netContext` fields directly to change global defaults; pass `context` to individual calls to override per-request. Pass `context` to each `request`/`publish`/`load` call. An `App` from `@welshman/app` owns one per
instance (`app.netContext`), and `app.use(Network)` supplies it for you.
### Negentropy / Diff (NIP-77) ### Negentropy / Diff (NIP-77)
@ -366,7 +366,7 @@ repo.load(storedEvents)
### Startup: bulk-load Tracker state ### Startup: bulk-load Tracker state
```typescript ```typescript
import {tracker} from '@welshman/app' // singleton wired to the pool and repository // `app.tracker` — wired to that app's pool and repository
// Build the map from your stored relay<->event mappings // Build the map from your stored relay<->event mappings
const relaysById = new Map<string, Set<string>>() const relaysById = new Map<string, Set<string>>()
@ -384,7 +384,7 @@ tracker.load(relaysById)
```typescript ```typescript
import {on, batch} from '@welshman/lib' import {on, batch} from '@welshman/lib'
import {repository} from '@welshman/app' // singleton; or Repository.get() standalone // `app.repository`, or `new Repository()` when using @welshman/net standalone
import type {RepositoryUpdate} from '@welshman/net' import type {RepositoryUpdate} from '@welshman/net'
import type {TrustedEvent} from '@welshman/util' import type {TrustedEvent} from '@welshman/util'
@ -417,8 +417,8 @@ on(
- **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent` (already verified). - **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, 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/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` with reactive Svelte stores, a router, and higher-level helpers. Most app-level code should use `@welshman/app`; drop down to `@welshman/net` only for raw relay I/O or when building non-Svelte clients. - **`@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`** — shared singleton used as the default by `request`, `requestOne`, and the repository. Override fields on `netContext` at startup, or pass a `context` object per-call to isolate behavior. - **`NetContext`** — passed explicitly per call. Each `App` owns its own pool and repository, which keeps one identity's data out of another's.
--- ---

View file

@ -1,291 +0,0 @@
---
name: welshman-router
description: "Use this skill when working with @welshman/router: relay selection, routing strategies, scenario-based relay routing, or choosing which relays to use for reads/writes."
---
# welshman/router — Relay Selection
`@welshman/router` provides scenario-based relay selection for nostr clients. It answers the question "which relays should I use for this operation?" by scoring candidate relays based on pubkey relay lists, relay quality, and configurable fallback policies. It sits between `@welshman/util` (types/helpers) and `@welshman/net` (actual relay connections), and is wrapped by `@welshman/app` for full-stack usage.
## Installation
```bash
npm install @welshman/router
# or
pnpm add @welshman/router
yarn add @welshman/router
```
Peer dependencies: `@welshman/lib`, `@welshman/net`, `@welshman/util`.
## Key Exports
### Router (class)
The main entry point. Use as a singleton via `Router.configure()` + `Router.get()`, or instantiate directly with options.
| Method | Description |
|--------|-------------|
| `Router.configure(options)` | Merge options into the global `routerContext` |
| `Router.get()` | Return a `Router` instance using the global context |
| `new Router(options)` | Create a router that overrides specific options from the global context |
**RouterOptions** (all optional):
| Option | Signature | Description |
|--------|-----------|-------------|
| `getUserPubkey` | `() => string \| undefined` | Current user's pubkey |
| `getPubkeyRelays` | `(pubkey, mode?) => string[]` | Relays for a pubkey; `mode` is `"read"`, `"write"`, or `"messaging"` |
| `getDefaultRelays` | `() => string[]` | Fallback relays of last resort |
| `getIndexerRelays` | `() => string[]` | Relays that index profiles and relay lists (NIP-65) |
| `getSearchRelays` | `() => string[]` | Relays supporting NIP-50 search |
| `getRelayQuality` | `(url) => number` | Quality score 0–1 for a relay (affects selection ranking) |
| `getLimit` | `() => number` | Max relays returned by `getUrls()` (default: 3) |
**Default behavior:** if `getPubkeyRelays` is not configured, the router falls back to querying the local `Repository` (from `@welshman/net`) for kind-10002 events.
### Router Scenario Methods
All return a `RouterScenario`. Naming convention: `For*` = relays to write to (so others can read), `From*` = relays to read from (author's outbox).
| Method | Description |
|--------|-------------|
| `FromRelays(relays)` | Use an explicit list of relay URLs |
| `ForUser()` | User's read relays (where others can send things to the user) |
| `FromUser()` | User's write relays (user's outbox) |
| `MessagesForUser()` | User's messaging relays (NIP-17 DMs) |
| `ForPubkey(pubkey)` | A pubkey's read relays |
| `FromPubkey(pubkey)` | A pubkey's write relays (outbox) |
| `MessagesForPubkey(pubkey)` | A pubkey's messaging relays |
| `ForPubkeys(pubkeys)` | Merged read relays for multiple pubkeys |
| `FromPubkeys(pubkeys)` | Merged write relays for multiple pubkeys |
| `MessagesForPubkeys(pubkeys)` | Merged messaging relays for multiple pubkeys |
| `Event(event)` | Event author's write relays (where the event lives) |
| `Replies(event)` | Event author's read relays (where replies should be sent) |
| `PublishEvent(event)` | Author's outbox + mentioned pubkeys' read relays; hard-limits to 30 |
| `Quote(event, id, hints)` | Best relays to find a quoted event; checks tag relay hints and author pubkey from tag |
| `EventParents(event)` | Relays for fetching parent events (from ancestor tags + mentioned pubkeys) |
| `EventRoots(event)` | Relays for fetching root events |
| `Search()` | Search relays |
| `Index()` | Indexer relays |
| `Default()` | Default/fallback relays |
| `merge(scenarios)` | Combine multiple scenarios into one |
### RouterScenario (class)
Immutable builder — every builder method returns a new instance. Terminal methods (`getUrls()`, `getUrl()`) return relay URLs, not instances.
| Method | Description |
|--------|-------------|
| `getUrls()` | Execute selection; returns `string[]` |
| `getUrl()` | Returns the first selected URL or `undefined` |
| `limit(n)` | Override max relay count for this scenario |
| `weight(scale)` | Multiply all selection weights by `scale` |
| `policy(fn)` | Set fallback policy |
| `allowLocal(bool)` | Allow `ws://localhost` / `ws://127.*` URLs (default: false) |
| `allowOnion(bool)` | Allow `.onion` URLs (default: false) |
| `allowInsecure(bool)` | Allow plain `ws://` non-onion URLs (default: false) |
| `filter(fn)` | Filter the internal `Selection[]` |
| `update(fn)` | Map over the internal `Selection[]` |
### Fallback Policies
Applied after relay scoring when not enough relays are found. Draw from `getDefaultRelays`.
| Export | Behavior |
|--------|----------|
| `addNoFallbacks` | Never add fallbacks (default) |
| `addMinimalFallbacks` | Add 1 fallback only if zero relays were selected |
| `addMaximalFallbacks` | Fill remaining slots up to the limit |
### Filter Selection
| Export | Description |
|--------|-------------|
| `getFilterSelections(filters, rules?)` | Returns `RelaysAndFilters[]` — optimized relay+filter combos for a subscription |
| `RelaysAndFilters` | `{ relays: string[], filters: Filter[] }` |
| `defaultFilterSelectionRules` | The default ordered rule array |
| `getFilterSelectionsForSearch` | Rule: search filters → search relays (weight 10) |
| `getFilterSelectionsForWraps` | Rule: kind-1059 wraps without authors → user messaging relays |
| `getFilterSelectionsForIndexedKinds` | Rule: kinds 0/3/10002/10050 → indexer relays |
| `getFilterSelectionsForAuthors` | Rule: author filters → each author's outbox (split into up to 30 chunks) |
| `getFilterSelectionsForUser` | Rule: low-weight (0.2) baseline that always fires for every filter → user's read relays. It is not conditional on other rules failing. |
### Other Exports
| Export | Description |
|--------|-------------|
| `INDEXED_KINDS` | `[PROFILE, RELAYS, MESSAGING_RELAYS, FOLLOWS]` — kinds routed to indexers |
| `makeSelection(relays, weight?)` | Create a `Selection` object; validates and normalizes URLs |
| `Selection` | `{ weight: number, relays: string[] }` |
| `FallbackPolicy` | `(count: number, limit: number) => number` |
| `routerContext` | The global mutable options object updated by `Router.configure()` |
## Common Patterns
### 1. Configure once at app startup
`Router.get()` is the primary entry point — it returns a `Router` instance using the global `routerContext`. Call `Router.configure()` once at startup to set options, or assign directly to `routerContext` for individual overrides.
```typescript
import {Router} from '@welshman/router'
Router.configure({
getUserPubkey: () => myStore.userPubkey,
getPubkeyRelays: (pubkey, mode) => myStore.getRelaysForPubkey(pubkey, mode),
getDefaultRelays: () => ['wss://relay.example.com/', 'wss://relay2.example.com/'],
getIndexerRelays: () => ['wss://indexer.example.com/', 'wss://indexer2.example.com/'],
getSearchRelays: () => ['wss://search.example.com/', 'wss://search2.example.com/'],
getRelayQuality: (url) => myStore.getRelayQuality(url),
getLimit: () => 5,
})
```
When using `@welshman/app`, it pre-configures `Router` automatically. The two most common customization points when using `@welshman/app` are `getDefaultRelays` and `getIndexerRelays`, which you can assign directly on `routerContext`:
```typescript
import {routerContext} from '@welshman/router'
routerContext.getDefaultRelays = () => [
'wss://relay.example.com/',
'wss://relay2.example.com/',
]
routerContext.getIndexerRelays = () => [
'wss://indexer.example.com/',
'wss://indexer2.example.com/',
]
```
### 2. Fetch events from specific pubkeys
```typescript
import {Router} from '@welshman/router'
const relays = Router.get().FromPubkeys(['pubkey1', 'pubkey2']).getUrls()
// relays is string[] — pass to your subscription
```
### 3. Publish an event
```typescript
import {Router} from '@welshman/router'
import type {TrustedEvent} from '@welshman/util'
function getPublishRelays(event: TrustedEvent): string[] {
return Router.get().PublishEvent(event).getUrls()
// Automatically includes author's outbox + mentioned pubkeys' read relays
// Hard-limited to 30 relays for deliverability
}
```
### 4. Find a quoted/referenced event with fallbacks
```typescript
import {Router, addMaximalFallbacks} from '@welshman/router'
import type {TrustedEvent} from '@welshman/util'
function getQuoteRelays(event: TrustedEvent, quotedId: string, hints: string[]) {
return Router.get()
.Quote(event, quotedId, hints)
.policy(addMaximalFallbacks)
.limit(8)
.getUrls()
}
```
### 5. Common scenario cheat-sheet
```typescript
import {Router} from '@welshman/router'
const router = Router.get()
// Read relays for the current user (where others deliver events to you)
router.ForUser().getUrls()
// Write relays for the current user (your outbox)
router.FromUser().getUrls()
// Best relays to deliver an event to a pubkey (their inbox)
router.ForPubkey('pubkey').getUrls()
// Best relays to fetch events authored by a pubkey (their outbox)
router.FromPubkey('pubkey').getUrls()
// Indexer relays (profiles, relay lists)
router.Index().getUrls()
// Cap relay count for this scenario only
router.ForPubkey('pubkey').limit(3).getUrls()
// Merge multiple scenarios; relay URLs are deduplicated when getUrls() is called
router.merge([
router.FromUser(),
router.Index(),
]).getUrls()
```
### 6. Build subscriptions with getFilterSelections
```typescript
import {getFilterSelections} from '@welshman/router'
import type {Filter} from '@welshman/util'
const filters: Filter[] = [
{kinds: [1], authors: ['pubkey1', 'pubkey2']},
{kinds: [0], search: 'bitcoin'},
]
for (const {relays, filters} of getFilterSelections(filters)) {
// Open one subscription per relay group
myPool.subscribe(relays, filters)
}
```
### 7. Use a custom filter routing rule
```typescript
import {
Router,
getFilterSelections,
defaultFilterSelectionRules,
type RelaysAndFilters,
} from '@welshman/router'
import type {Filter} from '@welshman/util'
// Add a rule that sends kind-1 to a dedicated relay
const myRule = (filter: Filter) => {
if (!filter.kinds?.includes(1)) return []
return [{filter, scenario: Router.get().FromRelays(['wss://notes.example.com/'])}]
}
const selections = getFilterSelections(filters, [myRule, ...defaultFilterSelectionRules])
```
## Integration Notes
- **`@welshman/util`** — Router imports `TrustedEvent`, `Filter`, `RelayMode`, `PROFILE`, `RELAYS`, `MESSAGING_RELAYS`, `FOLLOWS`, `WRAP`, `normalizeRelayUrl`, and tag-parsing helpers. All relay URLs are normalized with `normalizeRelayUrl` and validated with `isRelayUrl` before use.
- **`@welshman/net`** — The default `getPubkeyRelays` implementation queries `Repository.get()` (the in-memory event store from `@welshman/net`) for kind-10002 events. Override it if you maintain relay lists elsewhere.
- **`@welshman/app`** — The app layer pre-configures `Router` using its own stores (relay lists, connection quality). If you use `@welshman/app`, call `Router.configure` only to override specific options; the app layer handles the rest.
- **`@welshman/lib`** — Used internally for utilities (`sortBy`, `shuffle`, `uniq`, etc.); no direct integration needed.
## Gotchas & Tips
- **Relay list events must be in the Repository for pubkey routing to work.** The default `getPubkeyRelays` implementation reads kind-10002 (NIP-65) relay list events from the global in-memory `Repository`. If those events haven't been loaded — either from a local cache at startup or fetched from the network — the Router has no relay list data for that pubkey and silently falls back to default/indexer relays. When using `@welshman/app`, relay lists are fetched automatically as part of profile loading (`loadRelayList`, `makeOutboxLoader`). Without `@welshman/app`, you must fetch and load them yourself before calling pubkey-based scenarios.
- **`For*` vs `From*`**: `ForPubkey` returns a pubkey's **read** relays (where you send things for that pubkey to receive); `FromPubkey` returns their **write** relays (their outbox, where their events live). Use `From*` to fetch events, `For*` to deliver events.
- **Default limit is 3.** Set `getLimit` in `Router.configure` or call `.limit(n)` on a scenario if you need more. `PublishEvent` unconditionally overrides to 30.
- **Scoring includes randomness.** `getUrls()` introduces `Math.random()` in the scoring formula so that lower-quality or less-popular relays get occasional selection. Results are not deterministic across calls.
- **`addNoFallbacks` is the default policy.** If no relays are found for a scenario (e.g. no relay list for a pubkey) and you haven't set a policy, `getUrls()` returns `[]`. Use `addMinimalFallbacks` or `addMaximalFallbacks` when you need a result even for unknown pubkeys.
- **Insecure `ws://` URLs are filtered by default.** Only onion addresses (`*.onion`) are exempt from the TLS requirement. Pass `.allowInsecure(true)` to a scenario if you need to support plain websocket relays (e.g. local dev).
- **`getFilterSelections` uses `addMinimalFallbacks`.** Each resulting relay group will have at least one relay *if* `getDefaultRelays` is configured and returns relays. If `getDefaultRelays` is not configured or returns an empty array, the group may still be empty.
- **`routerContext` is a shared mutable object.** `Router.configure()` mutates it in place with `Object.assign`. `new Router(options)` merges the supplied options *over* the global `routerContext` (via `mergeLeft`), so any options not provided in `options` still fall back to whatever is in `routerContext`. For true isolation (e.g. in tests), pass a complete options object or reset `routerContext` first.
- **`Quote` reads relay hints from event tags.** It looks for a tag whose second element (`t[1]`) matches the quoted event ID, then extracts a relay hint from `t[2]` and an author pubkey from `t[3]`. Standard NIP-21/NIP-10 tag format.

View file

@ -211,7 +211,7 @@ const plaintext = await signer.nip44.decrypt(theirPubkey, ciphertext)
- **`@welshman/util`** supplies `makeEvent`, `makeSecret`, `StampedEvent`, `SignedEvent`, and nostr kind constants (`NOTE`, `DIRECT_MESSAGE`, etc.) used in all examples above. - **`@welshman/util`** supplies `makeEvent`, `makeSecret`, `StampedEvent`, `SignedEvent`, and nostr kind constants (`NOTE`, `DIRECT_MESSAGE`, etc.) used in all examples above.
- **`@welshman/net`** and **`@welshman/app`** accept an `ISigner` wherever signing is needed (e.g. publishing events). Pass any concrete signer — they are interchangeable. - **`@welshman/net`** and **`@welshman/app`** accept an `ISigner` wherever signing is needed (e.g. publishing events). Pass any concrete signer — they are interchangeable.
- **`@welshman/app`** exposes a `signer` writable store (`import { signer } from '@welshman/app'`) that the rest of the app stack reads. Set it to your chosen `ISigner` after login. - **`@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.
- `Nip59` wraps events with an ephemeral `Nip01Signer` by default (per the NIP-59 spec), so callers do not need to supply a wrapper unless they want a custom one. - `Nip59` wraps events with an ephemeral `Nip01Signer` by default (per the NIP-59 spec), so callers do not need to supply a wrapper unless they want a custom one.
## Gotchas & Tips ## Gotchas & Tips

View file

@ -140,15 +140,19 @@ notes.subscribe($notes => {
```typescript ```typescript
import { Repository } from "@welshman/net" import { Repository } from "@welshman/net"
import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store" import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store"
import { readProfile, PROFILE, type PublishedProfile } from "@welshman/util" import { PROFILE } from "@welshman/util"
import { Profile, type ProfileReader } from "@welshman/domain"
const repository = new Repository() const repository = new Repository()
const profilesByPubkey = deriveItemsByKey<PublishedProfile>({ // Decoding is @welshman/domain's job — configure the kind, then use its reader as eventToItem.
const readProfile = Profile.configure({}).reader
const profilesByPubkey = deriveItemsByKey<ProfileReader>({
repository, repository,
filters: [{ kinds: [PROFILE] }], filters: [{ kinds: [PROFILE] }],
eventToItem: event => readProfile(event), eventToItem: event => readProfile(event).parse(),
getKey: profile => profile.event.pubkey, getKey: profile => profile.author(),
}) })
// All profiles as array // All profiles as array
@ -159,7 +163,7 @@ const deriveProfile = makeDeriveItem(profilesByPubkey)
const aliceProfile = deriveProfile("alice-pubkey-hex") const aliceProfile = deriveProfile("alice-pubkey-hex")
aliceProfile.subscribe($profile => { aliceProfile.subscribe($profile => {
console.log($profile?.name) console.log($profile?.display())
}) })
``` ```
@ -227,7 +231,8 @@ This `getBookmark` function is the right shape to pass as `getItem` to `makeLoad
### 6. Full reactive item chain: deriveItemsByKey → deriveItems → getter → makeLoadItem → makeDeriveItem ### 6. Full reactive item chain: deriveItemsByKey → deriveItems → getter → makeLoadItem → makeDeriveItem
This is the canonical pattern for domain objects derived from repository events with This is the canonical pattern for domain objects derived from repository events with
on-demand network loading. 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.
```typescript ```typescript
import { import {
@ -237,10 +242,9 @@ import {
makeLoadItem, makeLoadItem,
makeDeriveItem, makeDeriveItem,
} from "@welshman/store" } from "@welshman/store"
import { load } from "@welshman/net" import { Network, Router } from "@welshman/app"
import { repository } from "@welshman/app" import { outbox } from "@welshman/util"
import { Router } from "@welshman/router" import { relayTags, tagSpec, tagValue, tagValues } from "@welshman/util"
import { getTagValue, getTagValues } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util" import type { TrustedEvent } from "@welshman/util"
const BOOKMARK_KIND = 30003 const BOOKMARK_KIND = 30003
@ -254,14 +258,14 @@ type Bookmark = {
const parseBookmark = (event: TrustedEvent): Bookmark => ({ const parseBookmark = (event: TrustedEvent): Bookmark => ({
pubkey: event.pubkey, pubkey: event.pubkey,
title: getTagValue("title", event.tags) ?? "Untitled", title: tagValue(tagSpec("title"), event.tags) ?? "Untitled",
urls: getTagValues("r", event.tags), urls: tagValues(relayTags("r"), event.tags),
event, event,
}) })
// Step 1: Reactive Map<pubkey, Bookmark> — live-updates from repository // Step 1: Reactive Map<pubkey, Bookmark> — live-updates from repository
const bookmarksByPubkey = deriveItemsByKey<Bookmark>({ const bookmarksByPubkey = deriveItemsByKey<Bookmark>({
repository, repository: app.repository,
filters: [{ kinds: [BOOKMARK_KIND] }], filters: [{ kinds: [BOOKMARK_KIND] }],
getKey: b => b.pubkey, getKey: b => b.pubkey,
eventToItem: parseBookmark, eventToItem: parseBookmark,
@ -278,8 +282,10 @@ const getBookmark = (pubkey: string) => getBookmarksByPubkey().get(pubkey)
// re-fetches only after the timeout window (default: 3600 s) // re-fetches only after the timeout window (default: 3600 s)
const loadBookmark = makeLoadItem<Bookmark>( const loadBookmark = makeLoadItem<Bookmark>(
async (pubkey: string) => { async (pubkey: string) => {
await load({ const scenario = await app.use(Router).resolve([outbox(pubkey)])
relays: Router.get().ForPubkey(pubkey).getUrls(),
await app.use(Network).load({
relays: scenario.getUrls(),
filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }], filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }],
}) })
}, },
@ -298,7 +304,8 @@ aliceBookmark.subscribe($b => console.log($b?.title))
## Integration Notes ## Integration Notes
- **`@welshman/net`** — provides `Repository` and `Tracker`. `Repository` is the event cache that feeds all store primitives in this package. Events flow from the network into the repository, which triggers store updates automatically. - **`@welshman/net`** — provides `Repository` and `Tracker`. `Repository` is the event cache that feeds all store primitives in this package. Events flow from the network into the repository, which triggers store updates automatically.
- **`@welshman/util`** — provides `TrustedEvent`, `Filter`, `readProfile`, `readList`, and other event-parsing helpers that feed into `deriveItemsByKey` / `deriveEventsById`. - **`@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/app`** — the high-level app layer re-exports and composes store utilities with pre-configured repositories, loaders, and context. If you are using `@welshman/app`, many of these stores are already wired up for you. - **`@welshman/app`** — the high-level app layer re-exports and composes store utilities with pre-configured repositories, loaders, and context. If you are using `@welshman/app`, many of these stores are already wired up for you.
- Stores in this package are **framework-agnostic** at runtime (plain Svelte stores), so they work in SvelteKit SSR as well as browser-only Svelte apps. The `synced` store's `localStorageProvider` is browser-only — guard it with `if (browser)` in SvelteKit. - Stores in this package are **framework-agnostic** at runtime (plain Svelte stores), so they work in SvelteKit SSR as well as browser-only Svelte apps. The `synced` store's `localStorageProvider` is browser-only — guard it with `if (browser)` in SvelteKit.

View file

@ -32,7 +32,6 @@ yarn add @welshman/util
| `HashedEvent` | `OwnedEvent + id` | | `HashedEvent` | `OwnedEvent + id` |
| `SignedEvent` | `HashedEvent + sig` | | `SignedEvent` | `HashedEvent + sig` |
| `TrustedEvent` | `HashedEvent + optional sig` — most common in-app type | | `TrustedEvent` | `HashedEvent + optional sig` — most common in-app type |
| `DecryptedEvent` | `TrustedEvent + plaintext` (for encrypted lists/events) |
### Event Utilities ### Event Utilities
@ -49,9 +48,6 @@ yarn add @welshman/util
| `isReplaceable(event)` | True for plain or parameterized replaceable | | `isReplaceable(event)` | True for plain or parameterized replaceable |
| `isPlainReplaceable(event)` | True for kinds 10000–19999 and metadata/contacts | | `isPlainReplaceable(event)` | True for kinds 10000–19999 and metadata/contacts |
| `isParameterizedReplaceable(event)` | True for kinds 30000–39999 | | `isParameterizedReplaceable(event)` | True for kinds 30000–39999 |
| `getAncestors(event)` | Returns `{ roots, replies, mentions }` for NIP-10 events (mentions may be empty `[]` but is always present); NIP-22/COMMENT path returns `{ roots, replies }` without mentions |
| `getParentIdOrAddr(event)` | Immediate parent id or address |
| `isChildOf(child, parent)` | Check if child replies to parent |
### Type Guards ### Type Guards
@ -274,24 +270,16 @@ isDVMKind(kind) // 5000–7000
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `getTags(types, tags)` | Get all tags matching one or more type strings | | `tagSpec(keys, matchValue?, normalize?)` | Build a spec: which tag keys, plus optional validation/normalization |
| `getTag(types, tags)` | Get first matching tag | | `hexTags(keys)` | Spec for 32-byte hex values (`e`, `p`, …) |
| `getTagValues(types, tags)` | Get value (index 1) of all matching tags — types first, then the tags array | | `addressTags(keys)` | Spec for `kind:pubkey:d` addresses (`a`, `A`) |
| `getTagValue(types, tags)` | Get value of first matching tag — types first, then the tags array | | `kindTags(keys)` | Spec for kind numbers (`k`) |
| `getEventTags(tags)` | `e` tags | | `topicTags(keys)` | Spec for topics, normalized (`t`) |
| `getEventTagValues(tags)` | Values of `e` tags | | `relayTags(keys)` | Spec for relay urls (`r`, `relay`) |
| `getAddressTags(tags)` | `a` tags | | `tagValue(spec, tags)` | Value (index 1) of the first matching tag |
| `getAddressTagValues(tags)` | Values of `a` tags | | `tagValues(spec, tags)` | Values of all matching tags |
| `getPubkeyTags(tags)` | `p` tags | | `matchTag(spec, tags)` / `matchTags(spec, tags)` | The matching tag(s) themselves |
| `getPubkeyTagValues(tags)` | Values of `p` tags | | `tagMatcher(spec)` | A `(tag) => boolean` predicate, for filtering in one pass |
| `getTopicTags(tags)` / `getTopicTagValues(tags)` | `t` (hashtag) tags |
| `getRelayTags(tags)` / `getRelayTagValues(tags)` | `r` and `relay` tags |
| `getKindTags(tags)` / `getKindTagValues(tags)` | `k` tags (returns `number[]`) |
| `getGroupTags(tags)` / `getGroupTagValues(tags)` | group tags |
| `getReplyTags(tags)` | `{ roots, replies, mentions }` — NIP-10 threading |
| `getCommentTags(tags)` | `{ roots, replies }` — NIP-22 uppercase/lowercase tags |
| `uniqTags(tags)` | Remove duplicate tags |
| `tagsFromIMeta(imeta)` | Parse `imeta` tag into array of tag arrays |
### Filters ### Filters
@ -319,50 +307,12 @@ isDVMKind(kind) // 5000–7000
| `Address.from(s, relays?)` | Parse from `kind:pubkey:identifier` string | | `Address.from(s, relays?)` | Parse from `kind:pubkey:identifier` string |
| `Address.fromNaddr(naddr)` | Parse from NIP-19 naddr | | `Address.fromNaddr(naddr)` | Parse from NIP-19 naddr |
| `Address.fromEvent(event, relays?)` | Create from addressable event | | `Address.fromEvent(event, relays?)` | Create from addressable event |
| `address.toString()` | Serialize to `kind:pubkey:identifier` |
| `address.toNaddr()` | Serialize to NIP-19 naddr |
| `getAddress(event)` | Convenience: get address string from event | | `getAddress(event)` | Convenience: get address string from event |
### Profile
| Export | Description |
|--------|-------------|
| `makeProfile(partial)` | Create a profile object |
| `readProfile(event)` | Parse `PublishedProfile` from kind 0 event |
| `createProfile(profile)` | Create kind 0 `EventTemplate` |
| `editProfile(published)` | Update existing profile event |
| `displayProfile(profile?, fallback?)` | Get best display name string |
| `displayPubkey(pubkey)` | Shorten pubkey to `npub1abc...xyz` |
| `profileHasName(profile?)` | Check if profile has a name field |
Profile fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`, `nip05`, `lud06`, `lud16`, `lnurl`
### Lists (kind 10000+)
| Export | Description |
|--------|-------------|
| `makeList(params)` | Create a new list |
| `readList(event)` | Parse `PublishedList` from `DecryptedEvent` |
| `getListTags(list)` | Combined public + private tags |
| `addToListPublicly(list, ...tags)` | Returns `Encryptable` with tag added publicly |
| `addToListPrivately(list, ...tags)` | Returns `Encryptable` with tag added privately |
| `removeFromList(list, value)` | Returns `Encryptable` with tag removed |
| `removeFromListByPredicate(list, pred)` | Returns `Encryptable` with matching tags removed |
| `updateList(list, { publicTags?, privateTags? })` | Bulk update tags |
### Encryptable
| Export | Description |
|--------|-------------|
| `Encryptable<T>` | Wraps a partial event with plaintext updates; call `.reconcile(encrypt)` to produce encrypted event |
| `asDecryptedEvent(event, plaintext?)` | Attach plaintext data to a `TrustedEvent` |
### Relay ### Relay
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `RelayMode` | Enum: `Read`, `Write`, `Search`, `Blocked`, `Messaging` |
| `RelayProfile` | NIP-11 relay info type |
| `isRelayUrl(url)` | Validate relay URL | | `isRelayUrl(url)` | Validate relay URL |
| `isShareableRelayUrl(url)` | True if valid relay URL and not a local address | | `isShareableRelayUrl(url)` | True if valid relay URL and not a local address |
| `isOnionUrl(url)` | Tor address check | | `isOnionUrl(url)` | Tor address check |
@ -370,7 +320,6 @@ Profile fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`,
| `isIPAddress(url)` | IP address check | | `isIPAddress(url)` | IP address check |
| `normalizeRelayUrl(url)` | Normalize to standard wss:// format | | `normalizeRelayUrl(url)` | Normalize to standard wss:// format |
| `displayRelayUrl(url)` | Strip protocol and trailing slash | | `displayRelayUrl(url)` | Strip protocol and trailing slash |
| `displayRelayProfile(profile?, fallback?)` | Get display name for relay |
### Zaps (NIP-57) ### Zaps (NIP-57)
@ -379,9 +328,6 @@ Profile fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`,
| `getLnUrl(address)` | Convert lightning address or URL to LNURL; returns `undefined` if invalid | | `getLnUrl(address)` | Convert lightning address or URL to LNURL; returns `undefined` if invalid |
| `getInvoiceAmount(bolt11)` | Extract millisatoshi amount from BOLT11 invoice | | `getInvoiceAmount(bolt11)` | Extract millisatoshi amount from BOLT11 invoice |
| `hrpToMillisat(hrpString)` | Convert human-readable BTC amount to millisats (`bigint`) | | `hrpToMillisat(hrpString)` | Convert human-readable BTC amount to millisats (`bigint`) |
| `zapFromEvent(response, zapper)` | Validate zap receipt and return `Zap` or `undefined` |
| `Zapper` type | `{ lnurl, pubkey?, callback?, minSendable?, maxSendable?, nostrPubkey?, allowsNostr? }` |
| `Zap` type | `{ request: TrustedEvent, response: TrustedEvent, invoiceAmount: number }` |
### Wallet ### Wallet
@ -502,29 +448,14 @@ Only do this for events you persisted yourself after they were validated. Never
### Working with tags ### Working with tags
```typescript ```typescript
import { import {tagValue, tagValues, hexTags, relayTags, tagSpec, topicTags} from '@welshman/util'
getTagValue,
getTagValues,
getPubkeyTagValues,
getTopicTagValues,
getRelayTagValues,
getReplyTags,
uniqTags,
} from '@welshman/util'
// getTagValue and getTagValues: types argument FIRST, then the tags array // Specs come first, then the tags array. The spec says which keys to match and how to
const title = getTagValue('title', event.tags) // string | undefined // validate the value, so malformed tags are skipped rather than silently returned.
const urls = getTagValues('r', event.tags) // string[] const title = tagValue(tagSpec('title'), event.tags) // string | undefined
const urls = tagValues(relayTags('r'), event.tags) // string[], valid relay urls only
// Multiple types at once const ids = tagValues(hexTags(['e', 'a']), event.tags) // string[], 32-byte hex only
const ids = getTagValues(['e', 'a'], event.tags) // string[] const topics = tagValues(topicTags('t'), event.tags) // string[], normalized
const mentions = getPubkeyTagValues(event.tags) // string[]
const topics = getTopicTagValues(event.tags) // string[]
const relays = getRelayTagValues(event.tags) // string[]
// NIP-10 thread context
const { roots, replies, mentions: threadMentions } = getReplyTags(event.tags)
``` ```
### Matching and building filters ### Matching and building filters
@ -566,24 +497,11 @@ const parsed = Address.fromNaddr('naddr1...')
const addressStr = getAddress(event) // '30023:deadbeef:my-slug' const addressStr = getAddress(event) // '30023:deadbeef:my-slug'
``` ```
### Profiles
```typescript
import { readProfile, displayProfile, displayPubkey, editProfile } from '@welshman/util'
const profile = readProfile(kind0Event)
console.log(displayProfile(profile, 'Anonymous')) // name or fallback
console.log(displayPubkey(pubkey)) // 'npub1abc...xyz'
// Update profile
const updatedEvent = editProfile({ ...profile, name: 'New Name', about: 'Updated bio' })
// sign and publish updatedEvent
```
### Zap flow ### Zap flow
```typescript ```typescript
import { getLnUrl, makeEvent, ZAP_REQUEST, zapFromEvent } from '@welshman/util' import { getLnUrl, makeEvent, ZAP_REQUEST } from '@welshman/util'
import { Zappers } from '@welshman/app'
// Step 1: resolve LNURL // Step 1: resolve LNURL
const lnurl = getLnUrl('satoshi@getalby.com') const lnurl = getLnUrl('satoshi@getalby.com')
@ -604,7 +522,7 @@ const zapRequest = makeEvent(ZAP_REQUEST, {
// Step 3: sign, send to LNURL callback, pay invoice... // Step 3: sign, send to LNURL callback, pay invoice...
// Step 4: validate receipt (kind 9735) // Step 4: validate receipt (kind 9735)
const zap = zapFromEvent(zapReceipt, { nostrPubkey: zapperPubkey, allowsNostr: true, lnurl }) const zap = await app.use(Zappers).validateZapReceipt(zapReceipt, zappedEvent)
if (zap) { if (zap) {
console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content) console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content)
} }
@ -646,9 +564,9 @@ await fetch('https://api.example.com/upload', {
- **`@welshman/net`** — uses `TrustedEvent`, `Filter`, `SignedEvent` from this package as the wire types for relay connections and subscriptions. - **`@welshman/net`** — uses `TrustedEvent`, `Filter`, `SignedEvent` from this package as the wire types for relay connections and subscriptions.
- **`@welshman/store`** — provides Svelte stores over repositories built on `TrustedEvent`; relies on `isReplaceable`, `getAddress`, etc. for deduplication. - **`@welshman/store`** — provides Svelte stores over repositories built on `TrustedEvent`; relies on `isReplaceable`, `getAddress`, etc. for deduplication.
- **`@welshman/app`** — high-level application layer; wraps net/store/router and uses profile, list, zap, and handler helpers from this package. - **`@welshman/app`** — high-level application layer; wraps net/store/domain and resolves this package's `RelaySelection` DSL through `app.use(Router)`.
- **`@welshman/router`** — uses `RelayMode` and relay URL helpers when computing relay selections. - **`@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; the `Encrypt` function type used by `Encryptable` is typically provided by a signer. - **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here, and supplies the encryption used when writing encrypted list kinds.
--- ---
@ -658,11 +576,9 @@ await fetch('https://api.example.com/upload', {
- **Replaceable event identity**: Use `getIdOrAddress` rather than `event.id` when referencing events that may be addressable — the address string is stable across updates, the id is not. - **Replaceable event identity**: Use `getIdOrAddress` rather than `event.id` when referencing events that may be addressable — the address string is stable across updates, the id is not.
- **`getAncestors` handles two protocols**: Kind 1111 (comment/NIP-22) uses uppercase `E`/`A` for roots and lowercase for replies, returning `{ roots, replies }`. All other kinds use NIP-10 positional rules, returning `{ roots, replies, mentions }` where `mentions` is always present but may be an empty array. You do not need to branch on this; `getAncestors`, `getParentIdOrAddr`, and `isChildOf` handle it automatically.
- **List mutations return `Encryptable`**: `addToListPrivately`, `removeFromList`, etc. do not return an event directly. Call `.reconcile(encryptFn)` on the result to get the final `EventTemplate` ready to sign.
- **`zapFromEvent` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. - **`validateZapReceipt` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. For a reactive list of a parent's valid zaps, use `app.use(Zappers).validZapReceipts(receipts, parent)`, which re-validates as each recipient's zapper loads.
- **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else. - **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else.

View file

@ -11,14 +11,14 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http
| Package | Description | | Package | Description |
|---|---| |---|---|
| `@welshman/util` | Core nostr types, event helpers, filters, and NIP implementations | | `@welshman/util` | Core nostr types, event helpers, filters, tag specs, NIPs, and the `RelaySelection` routing DSL |
| `@welshman/lib` | General-purpose utilities: LRU cache, event emitter, deferred promises, task queue | | `@welshman/lib` | General-purpose utilities: LRU cache, event emitter, deferred promises, task queue |
| `@welshman/net` | Relay connections, request/publish lifecycle, and auth handling | | `@welshman/net` | Relay connections, request/publish lifecycle, and auth handling |
| `@welshman/router` | Relay selection strategies for reads and writes | | `@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/store` | Svelte stores and a Repository for indexing/querying nostr events client-side |
| `@welshman/signer` | Signing and login methods: NIP-01 (privkey), NIP-07 (extension), NIP-46 (bunker), NIP-55 (app), NIP-59 (gift wrap) | | `@welshman/signer` | Signing and login methods: NIP-01 (privkey), NIP-07 (extension), NIP-46 (bunker), NIP-55 (app), NIP-59 (gift wrap) |
| `@welshman/feeds` | Dynamic feed construction, filtering, and composition | | `@welshman/feeds` | Dynamic feed construction, filtering, and composition |
| `@welshman/app` | High-level Svelte stores that compose net, router, store, signer, and feeds into a full application framework | | `@welshman/app` | The `App` instance and its plugin registry, composing net, store, domain, signer, and feeds into a full application framework |
| `@welshman/content` | Parser and renderer for nostr note content (links, mentions, media, custom formatting) | | `@welshman/content` | Parser and renderer for nostr note content (links, mentions, media, custom formatting) |
| `@welshman/editor` | Batteries-included Svelte rich-text editor component with mention and embed support | | `@welshman/editor` | Batteries-included Svelte rich-text editor component with mention and embed support |
@ -27,25 +27,43 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http
Packages are layered so lower-level ones have no welshman dependencies: Packages are layered so lower-level ones have no welshman dependencies:
- **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util` - **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util`
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/router`, `@welshman/store`, `@welshman/signer` - **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/store`, `@welshman/signer`, `@welshman/domain`
- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/app` - **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/app`
- **UI-focused** (largely independent, UI rendering concerns): `@welshman/content`, `@welshman/editor` - **UI-focused** (largely independent, UI rendering concerns): `@welshman/content`, `@welshman/editor`
For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-signer`). For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-domain`).
## Getting started ## The App instance
Install only what you need: 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.
```bash ```typescript
# Full application framework (includes app, net, router, store, signer, feeds) import {createApp, Network, Profiles, User} from "@welshman/app"
npm i @welshman/app
# Or assemble manually for more control // `createApp` = `new App` plus the default policies (ingest, relay stats, gift-wrap unwrapping)
npm i @welshman/util @welshman/net @welshman/signer 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"],
},
})
app.use(Profiles).load(pubkey) // plugins are per-app singletons, constructed on demand
app.use(Network).load({relays, filters})
``` ```
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. 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.
## Key nostr concepts ## Key nostr concepts
@ -61,91 +79,67 @@ If you're building a conventional nostr web client, use `@welshman/app` for batt
| Goal | Package(s) to use | | Goal | Package(s) to use |
|---|---| |---|---|
| Fetch notes from relays | `@welshman/net` (low-level) or `@welshman/app` (high-level) | | Fetch notes from relays | `app.use(Network)`, or `@welshman/net` directly for low-level control |
| Select which relays to use | `@welshman/router` | | Select which relays to use | `RelaySelection` helpers in `@welshman/util` + `app.use(Router)` |
| Sign and publish events | `@welshman/signer` + `@welshman/net` | | Read or write a specific kind | `@welshman/domain` via `app.use(Domain)` |
| Build a feed UI | `@welshman/feeds` + `@welshman/app` | | Sign and publish events | `@welshman/signer` + `Command` from `@welshman/app` |
| Build a feed UI | `@welshman/feeds` + `app.use(Feeds)` |
| Parse note text and media | `@welshman/content` | | Parse note text and media | `@welshman/content` |
| Embed a composer / editor | `@welshman/editor` | | Embed a composer / editor | `@welshman/editor` |
| Cache nostr events client-side | `@welshman/store` | | Cache nostr events client-side | `@welshman/store` + `app.repository` |
| Core event/filter utilities | `@welshman/util` | | Core event/filter/tag utilities | `@welshman/util` |
| Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` | | Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` |
### App Example ## App example
```typescript ```typescript
import "@welshman/app" // side effects: wires pool → repository + tracker + router import {createApp, Domain, Profiles, User} from "@welshman/app"
import {Note} from "@welshman/domain"
import {Nip07Signer} from "@welshman/signer"
import { openDB } from "idb" // 1. Build an app for the signed-in user
import { batch, on } from "@welshman/lib" const signer = new Nip07Signer()
import { verifiedSymbol } from "@welshman/util" const app = createApp({
import { repository, tracker, loginWithNip07, publishThunk, userProfile, loadUserProfile } from "@welshman/app" user: await User.fromSigner(signer),
import { routerContext } from "@welshman/router" config: {
import { load } from "@welshman/net" getDefaultRelays: () => ["wss://relay.example.com"],
import type { TrustedEvent } from "@welshman/util" getIndexerRelays: () => ["wss://indexer.example.com"],
import type { RepositoryUpdate } from "@welshman/net"
// 1. Configure fallback relays
routerContext.getDefaultRelays = () => ["wss://relay.example.com", "wss://relay2.example.com"]
routerContext.getIndexerRelays = () => ["wss://indexer.example.com"]
// 2. Open IndexedDB and hydrate the repository
const db = await openDB("my-app", 1, {
upgrade(db) {
db.createObjectStore("events", { keyPath: "id" })
}, },
}) })
const stored: TrustedEvent[] = await db.getAll("events") // 2. Read the user's profile (loads from their write relays if not cached)
for (const e of stored) e[verifiedSymbol] = true const profile = await app.use(Profiles).load(app.user!.pubkey)
repository.load(stored)
// Flush new events to IndexedDB console.log("Hello,", profile?.display())
on(repository, "update", batch(3000, async (updates: RepositoryUpdate[]) => {
const tx = db.transaction("events", "readwrite")
for (const { added, removed } of updates) {
for (const e of added) tx.store.put(e)
for (const id of removed) tx.store.delete(id)
}
await tx.done
}))
// 3. Log in // 3. Publish a note — build a writer, wrap it in a command, publish it
const pk = await window.nostr.getPublicKey() const writer = app.use(Domain).writer(Note).setContent("Hello, Nostr!")
loginWithNip07(pk) const command = await app.use(Domain).command(writer)
// 4. Load user's profile reactively (triggers network fetch if not cached) await command.publish().waitForError()
await loadUserProfile()
userProfile.subscribe($profile => {
if ($profile) console.log("Hello,", $profile.name)
})
// 5. Publish a note
import { makeEvent } from "@welshman/util"
import { Router } from "@welshman/router"
const thunk = publishThunk({
event: makeEvent(1, { content: "Hello, Nostr!", tags: [] }),
relays: Router.get().FromUser().getUrls(),
})
await thunk.complete
``` ```
### Lower-level Example 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.
```typescript ```typescript
import { AbstractAdapter, ClientMessage, NetContext, isClientEvent, netContext, publish, request } from '@welshman/net' import {AbstractAdapter, isClientEvent, publish, request} from "@welshman/net"
import { call, sleep } from '@welshman/lib' import type {ClientMessage, NetContext} from "@welshman/net"
import { Nip01Signer } from '@welshman/signer' import {call, sleep} from "@welshman/lib"
import { makeEvent, NOTE } from '@welshman/util' import {Nip01Signer} from "@welshman/signer"
import {makeEvent, NOTE} from "@welshman/util"
const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */) const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */) const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
const RELAY_URL = "bogus.relay" const RELAY_URL = "bogus.relay"
// Create an adapter for our relay url which just prints the content // An adapter for our relay url which just prints the content
export class PrintAdapter extends AbstractAdapter { export class PrintAdapter extends AbstractAdapter {
get sockets() { return [] } get sockets() { return [] }
get urls() { return [] } get urls() { return [] }
@ -157,37 +151,32 @@ export class PrintAdapter extends AbstractAdapter {
} }
} }
// Configure net context to use our custom adapter // Context is passed explicitly. An `App` supplies its own via `app.netContext`; here we build one.
netContext.getAdapter = (url: string, context: NetContext) => { const context: NetContext = {
if (url === RELAY_URL) { getAdapter: (url: string) => (url === RELAY_URL ? new PrintAdapter() : undefined),
return new PrintAdapter()
}
} }
// Loop, sending off pings every so often
call(async () => { call(async () => {
while (true) { while (true) {
await sleep(1000) await sleep(1000)
const ping = await pingSigner.sign( const ping = await pingSigner.sign(makeEvent(NOTE, {content: "ping"}))
makeEvent(NOTE, {content: 'ping'})
)
await publish({event: ping, relays: [RELAY_URL]}) await publish({event: ping, relays: [RELAY_URL], context})
} }
}) })
// Meanwhile, listen for pings and quote-note with a pong
call(async () => { call(async () => {
request({ request({
relays: [RELAY_URL], relays: [RELAY_URL],
filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}], filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}],
context,
onEvent: async (ping, url) => { onEvent: async (ping, url) => {
const pong = await pongSigner.sign( const pong = await pongSigner.sign(
makeEvent(NOTE, {content: 'pong', tags: [["q", ping.id, RELAY_URL, ping.pubkey]]}) makeEvent(NOTE, {content: "pong", tags: [["q", ping.id, RELAY_URL, ping.pubkey]]}),
) )
await publish({event: pong, relays: [RELAY_URL]}) await publish({event: pong, relays: [RELAY_URL], context})
}, },
}) })
}) })

7
.env
View file

@ -9,8 +9,8 @@ VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/logo.png VITE_PLATFORM_LOGO=static/logo.png
VITE_PLATFORM_RELAYS= VITE_PLATFORM_RELAYS=
VITE_PLATFORM_ACCENT="#7161FF" VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28" VITE_THEME="clay"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities." VITE_PLATFORM_DESCRIPTION="The chat app built for self-hosted communities."
VITE_PUSH_SERVER=https://nps.flotilla.social/ VITE_PUSH_SERVER=https://nps.flotilla.social/
VITE_PUSH_BRIDGE=wss://npb.coracle.social/ VITE_PUSH_BRIDGE=wss://npb.coracle.social/
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
@ -19,7 +19,8 @@ VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
VITE_GLITCHTIP_API_KEY= VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN= GLITCHTIP_AUTH_TOKEN=
VITE_HOSTING_BACKEND_URL=https://api.hosting.coracle.social
VITE_HOSTING_RELAY_DOMAIN=spaces.flotilla.social

2
.gitignore vendored
View file

@ -67,6 +67,7 @@ local.properties
proguard/ proguard/
google-services.json google-services.json
GoogleService-Info.plist GoogleService-Info.plist
ic_stat_notify.png
# IDEs and editors # IDEs and editors
.roo .roo
@ -74,6 +75,7 @@ GoogleService-Info.plist
.vscode .vscode
.claude .claude
.local .local
CLAUDE.md
# OS generated # OS generated
.DS_Store .DS_Store

148
AGENTS.md
View file

@ -6,7 +6,7 @@ Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-
- SvelteKit 5.48+ with TypeScript 5.9+ - SvelteKit 5.48+ with TypeScript 5.9+
- Capacitor for cross-platform (Web/PWA, Android, iOS) - Capacitor for cross-platform (Web/PWA, Android, iOS)
- TailwindCSS + DaisyUI for styling - TailwindCSS for styling
- Welshman library suite for Nostr protocol - Welshman library suite for Nostr protocol
- IndexedDB for local storage - IndexedDB for local storage
- Vite for building - Vite for building
@ -26,7 +26,7 @@ routes/ (top layer - can depend on anything)
↓ ↓
app/components/ (can depend on app/* and lib/*) app/components/ (can depend on app/* and lib/*)
↓ ↓
app/core/ & app/util/ (can only depend on lib/*) app/* (can only depend on lib/*)
↓ ↓
lib/ (can only depend on external libraries) lib/ (can only depend on external libraries)
↓ ↓
@ -45,76 +45,42 @@ Example:
```typescript ```typescript
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {throttle} from "throttle-debounce" import {throttle} from "throttle-debounce"
import {Profiles} from "@welshman/app"
import {Dialog} from "$lib/components" import {Dialog} from "$lib/components"
import {repository} from "$app/core/state" import {app} from "@app/core"
``` ```
## File Structure ## Cleanup Pass
``` These are the things that most often get fixed by hand after the fact. Go through
src/ them before handing work back. The full style reference is under Development
├── lib/ # Generic reusable code Conventions below.
│ ├── components/ # 38 UI components (Button, Dialog, etc.)
│ ├── html.ts # DOM utilities - **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.
│ ├── indexeddb.ts # IndexedDB helpers - **Used once, inlined** — a derived value, helper, type, or named constant with a single use is indirection. Write `setTimeout(pollOnce, 3500)`, not a `POLL_INTERVAL` referenced twice in one file. Name something only when the name is what makes the code readable.
│ └── util.ts # Generic utilities - **Positive conditionals** — prefer `if (ready) { ... }` over `if (!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 against `null`/`undefined`, and put the truthy branch first in a ternary.
├── app/ - **Standard names** — `loading` for 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.
│ ├── core/ - **Errors** — toast a human-readable message and `console.error` anything unexpected (e.g. a non-`HostingError`). Never swallow an error you didn't anticipate.
│ │ ├── state.ts # State management, stores, constants (687 lines)
│ │ ├── commands.ts # Publishing events and other write operations (440+ lines)
│ │ ├── requests.ts # Loading data from network (191 lines)
│ │ ├── sync.ts # Data synchronization (296 lines)
│ │ └── storage.ts # IndexedDB setup
│ │
│ ├── util/
│ │ ├── notifications.ts # Push notifications (731 lines)
│ │ ├── policies.ts # Relay policies
│ │ ├── routes.ts # Routing helpers
│ │ ├── modal.ts # Modal management
│ │ ├── toast.ts # Toast notifications
│ │ ├── theme.ts # Theme switching
│ │ └── keyboard.ts # Keyboard handling
│ │
│ ├── editor/ # Rich text editor config
│ │ ├── index.ts # TipTap setup with Nostr integration
│ │ ├── EditorContent.svelte
│ │ └── MentionNodeView.ts
│ │
│ └── components/ # 188 app-specific components
│ ├── Space*.svelte # Space/relay management
│ ├── Room*.svelte # Room/channel management
│ ├── Chat*.svelte # Direct messaging
│ ├── Profile*.svelte # User profiles
│ ├── Thread*.svelte # Threaded posts
│ └── ...
│
├── routes/ # SvelteKit file-based routing
│ ├── +layout.svelte # Root layout (sync logic here)
│ ├── spaces/ # Space management
│ │ └── [relay]/ # Specific space
│ │ ├── chat/ # Space chat
│ │ ├── threads/ # Thread posts
│ │ ├── calendar/ # Events
│ │ └── [h]/ # Specific room (h = room id)
│ ├── chat/ # Direct messages
│ ├── settings/ # User settings
│ └── [bech32]/ # Bech32 entity viewer
│
├── assets/icons/ # ~1,277 SVG icons
├── app.html # HTML template
├── app.css # Global styles
└── types.d.ts # Type definitions
```
## State Management ## State Management
**Core Principles:** **Core Principles:**
- Use Svelte 4 **stores** for all state (NOT runes outside UI components) - Use Svelte 4 **stores** for all state (NOT runes outside UI components)
- Most global state flows through Welshman's `repository` (unidirectional) - Everything hangs off the single `App` instance in `app/core.ts`. There are no welshman globals —
- Query state using `deriveEventsMapped` or `deriveProfile` etc reach features with `app.use(Plugin)`, which is memoized per app and cheap to call inline.
- Update state by publishing events via `publishThunk` - `app/core.ts` also exports `pubkey`, `signer`, `user` and `session` stores (all with `.get()`),
`login`, and `deriveUserItem(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`, or `deriveEventsById` /
`deriveItemsByKey` from `@welshman/store` against `app.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:** **Thunks:**
@ -127,12 +93,12 @@ src/
**Welshman Library Suite:** **Welshman Library Suite:**
- `@welshman/app` - High-level state (pubkey, signer, repository, tracker) - `@welshman/app` - The `App` instance and its plugins (Profiles, Rooms, Thunks, Router, …)
- `@welshman/net` - Network layer (Pool, Socket, load, pull, request) - `@welshman/domain` - A typed Reader/Writer pair per event kind, plus `Relay` and `Zapper`
- `@welshman/store` - Svelte integration (deriveEventsMapped, etc.) - `@welshman/net` - Network layer (Pool, Socket, adapters, request/publish/pull)
- `@welshman/util` - Event utilities (kinds, tags, validation) - `@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/signer` - Signing abstraction (NIP-01, NIP-07, NIP-46)
- `@welshman/router` - Relay routing (inbox/outbox model)
- `@welshman/editor` - Rich text editor with Nostr - `@welshman/editor` - Rich text editor with Nostr
- `@welshman/content` - Content parsing - `@welshman/content` - Content parsing
- `@welshman/feeds` - Feed management - `@welshman/feeds` - Feed management
@ -161,24 +127,30 @@ src/
- **No `null`** - only use `undefined` - **No `null`** - only use `undefined`
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components - Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
- TailwindCSS and DaisyUI styling - TailwindCSS styling with css components customized by theme. See lib/components for examples.
- Only add comments for really weird stuff - Comments, naming, conditionals and single-use indirection are covered by the Cleanup Pass above.
- Do not call functions in components unless a parameter is reactive. Instead, use a svelte store or rune to make it reactive.
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect. - Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax. - When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss - When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates - Do not define svelte event handlers inline, instead name them and put them in the script section of templates
- Write a `{#if}`/`{:else if}` chain rather than hoisting display strings into a lookup `Record` in the script section.
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly. - Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)` - To read a tag, prefer the domain reader's getter (`note.content()`, `roomMeta.name()`) over touching tags at all. Where there's no reader, use `tagValue(spec, tags)` / `tagValues(spec, tags)` from `@welshman/util` rather than reaching into the tag array yourself — that means no `tags.find(nthEq(0, name))?.[1]`. Build the spec with the narrowest helper that fits: `hexTags("p")`, `relayTags(["r", "relay"])`, `addressTags("a")`, `kindTags("k")`, `topicTags("t")`, or plain `tagSpec("h")` when the value needs no validation. Reserve `nthEq` for cases with no spec equivalent, such as `partition(nthEq(0, "imeta"), tags)`.
- Do not render a profile's `about` directly (e.g. `profile.about`); use the `ProfileInfo` component instead. - Do not render a profile's `about` directly (e.g. `profile.about()`); use the `ProfileAbout` component instead.
- Use `type Props` instead of interface when defining props for svelte components. - Use `type Props` instead 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`/`Partial` and `export` that type from the component's `<script module>` (e.g. a `Values` type) for callers to import, instead of re-enumerating its sub-properties. - When a component's value/prop shape mirrors a subset of an existing type, derive it with `Pick`/`Partial` and `export` that type from the component's `<script module>` (e.g. a `Values` type) 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, but `x => this.impl.y()` is ok, for example.
- Use `call` from `@welshman/lib` instead of an IIFE: `const x = call(() => {...})`, not `const x = (() => {...})()`. It reads left-to-right and drops the wrapping parens and the leading-semicolon hazard. `call` ignores extra arguments, so it also works directly as a callback — `unsubscribers.forEach(call)`.
- To match on field equality, use `spec` from `@welshman/lib` rather 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, and `ms()`/`ms(int(...))` only where a browser API needs milliseconds. So `checkedAt < ago(5, MINUTE)` instead of `checkedAt < 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 prefer `now()` over `Date.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 with `let`, effects, onMount. This order may vary due to dependencies, but should generally be adhered to.
**Human-First Simplicity (Jon Staab Style):** **Human-First Simplicity:**
- Prefer direct, readable code over layered abstractions. - Prefer direct, readable code over layered abstractions.
- Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity. - Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity.
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies. - 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. - Favor linear control flow and explicit naming over clever patterns.
- Remove defensive checks that do not apply in this runtime model. - 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. - When two approaches work, pick the one that feels more human and easier to maintain.
@ -201,20 +173,23 @@ src/
### Loading Data from Network ### Loading Data from Network
1. Use utilities from `app/core/requests.ts` 1. Prefer a plugin's `one(key)` / `load(key)`, which handle outbox routing and caching
2. Or create derived stores in `app/core/state.ts` 2. For anything else use `app.use(Network).load/request` or `app.use(Sync).pull/push` — the bare
3. Use `load`, `pull`, or `request` from `@welshman/net` `load`/`request`/`publish` from `@welshman/net` throw without a context
### Publishing Events ### Publishing Events
1. Create `make*` function to build event template 1. Build a writer: `app.use(Domain).writer(Kind, reader?)`, then chain its setters
2. Create `publish*` function using `publishThunk` 2. Wrap it: `const command = await app.use(Domain).command(writer)`
3. Display thunk status to user (for cancel/error handling) 3. Publish it: `command.publish()`, `.publishToRelays(urls)`, or `.publishAsRelay(url)`
4. These go in in `app/core/commands.ts` 4. 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 ### Managing Modals/Toasts
- Import from `app/util/modal.ts` or `app/util/toast.ts` - Import from `app/modal.ts` or `app/toast.ts`
- Pass component objects with parameters - Pass component objects with parameters
- Use `$state.snapshot` if calling component might unmount - Use `$state.snapshot` if calling component might unmount
@ -244,15 +219,6 @@ pnpm run check # Type check
See `.env.template` for all options. See `.env.template` for all options.
## Important Files to Reference
- **src/app/core/state.ts** - All stores and constants
- **src/app/core/sync.ts** - Data synchronization
- **src/app/core/requests.ts** - Utilities for requesting data
- **src/app/core/commands.ts** - Publishing patterns
- **src/app/util/notifications.ts** - Notification badges and push notifications
- **src/routes/+layout.svelte** - Top-level sync logic
## Mobile Development ## Mobile Development
**Capacitor Integration:** **Capacitor Integration:**

View file

@ -1,5 +1,22 @@
# Changelog # Changelog
# 1.9.0
* Fix nav safe are inset, qr code reactivity
* Performance optimizations for chat rooms
* Add hosting platform
* Add room invite links
* Improve audio/video calls
* Bugfixes and UI refinements
* Fix push notification icon
* Swap datepicker implementations
* Fix popover clipping
* Add member directory page
* Add pinboards and content library
* Improve white-labeled support
* Add full profile page at /people/[npub]
* Add theming support, improve app design
# 1.8.2 # 1.8.2
* Fix thread board styling * Fix thread board styling

View file

@ -12,7 +12,7 @@ You can also optionally create an `.env.local` file and populate it with the fol
- `VITE_PLATFORM_URL` - The url where the app will be hosted - `VITE_PLATFORM_URL` - The url where the app will be hosted
- `VITE_PLATFORM_NAME` - The name of the app - `VITE_PLATFORM_NAME` - The name of the app
- `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file. - `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file.
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color - `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color (used only for generated manifest, for more control create a custom theme file)
- `VITE_PLATFORM_DESCRIPTION` - A description of the app - `VITE_PLATFORM_DESCRIPTION` - A description of the app
- `VITE_PLATFORM_TERMS` - URL to your terms of service page - `VITE_PLATFORM_TERMS` - URL to your terms of service page
- `VITE_PLATFORM_PRIVACY` - URL to your privacy policy page - `VITE_PLATFORM_PRIVACY` - URL to your privacy policy page
@ -22,7 +22,7 @@ You can also optionally create an `.env.local` file and populate it with the fol
**Defaults** **Defaults**
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust - `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
- `VITE_DEFAULT_SPACES` - A comma-separated list of relay urls that new users will be automatically joined to on signup - `VITE_DEFAULT_SPACES` - A comma-separated list of relay urls that new users will be automatically joined to on signup. Each one may optionally include an invite code, delimited by `|`, e.g. `my.space.com|CODE`.
- `VITE_DEFAULT_RELAYS` - A comma-separated list of relay urls used as default outbox/inbox relays - `VITE_DEFAULT_RELAYS` - A comma-separated list of relay urls used as default outbox/inbox relays
- `VITE_DEFAULT_MESSAGING_RELAYS` - A comma-separated list of relay urls used for encrypted direct messages - `VITE_DEFAULT_MESSAGING_RELAYS` - A comma-separated list of relay urls used for encrypted direct messages
- `VITE_DEFAULT_BLOSSOM_SERVERS` - A comma-separated list of blossom server urls used for file uploads - `VITE_DEFAULT_BLOSSOM_SERVERS` - A comma-separated list of blossom server urls used for file uploads
@ -33,7 +33,6 @@ You can also optionally create an `.env.local` file and populate it with the fol
- `VITE_BLOCKED_RELAYS` - A comma-separated list of relay urls that will be blocked - `VITE_BLOCKED_RELAYS` - A comma-separated list of relay urls that will be blocked
- `VITE_PUSH_SERVER` - URL of the push notification server - `VITE_PUSH_SERVER` - URL of the push notification server
- `VITE_PUSH_BRIDGE` - WebSocket URL of the push notification relay bridge - `VITE_PUSH_BRIDGE` - WebSocket URL of the push notification relay bridge
- `VITE_VAPID_PUBLIC_KEY` - VAPID public key for web push notifications
- `VITE_POMADE_SIGNERS` - A comma-separated list of Pomade signer server URLs (3+ required to enable email signup) - `VITE_POMADE_SIGNERS` - A comma-separated list of Pomade signer server URLs (3+ required to enable email signup)
- `VITE_THUMBNAIL_URL` - URL of the image thumbnail service - `VITE_THUMBNAIL_URL` - URL of the image thumbnail service

View file

@ -8,8 +8,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 49 versionCode 50
versionName "1.8.2" versionName "1.9.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -36,6 +36,19 @@
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider> </provider>
<!-- FCM uses these when it renders a notification-payload message itself
(app backgrounded/killed). Without an explicit monochrome small icon
it falls back to the full-color adaptive launcher icon, which many
Android builds render as a blank square or drop entirely. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_notify"
/>
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/default_notification_channel_id"
/>
</application> </application>
<!-- Permissions --> <!-- Permissions -->

View file

@ -1,5 +1,8 @@
package social.flotilla; package social.flotilla;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
@ -10,6 +13,28 @@ public class MainActivity extends BridgeActivity {
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
registerPlugin(AndroidPushFallbackPlugin.class); registerPlugin(AndroidPushFallbackPlugin.class);
createPushNotificationChannel();
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
} }
// Ensures the channel named by the FCM default_notification_channel_id metadata
// exists. FCM does not create it for us; if it's missing when a notification
// arrives, Android falls back to a generic "Miscellaneous" channel. Channels
// persist once created, so doing this on launch is sufficient.
private void createPushNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager manager = getSystemService(NotificationManager.class);
String id = getString(R.string.default_notification_channel_id);
if (manager != null && manager.getNotificationChannel(id) == null) {
NotificationChannel channel = new NotificationChannel(
id,
"Notifications",
NotificationManager.IMPORTANCE_HIGH
);
channel.setDescription("New messages and activity");
manager.createNotificationChannel(channel);
}
}
}
} }

View file

@ -134,8 +134,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
val session = state.optJSONObject("session") ?: return SessionInfo("anonymous", "", JSONObject()) val session = state.optJSONObject("session") ?: return SessionInfo("anonymous", "", JSONObject())
return SessionInfo( return SessionInfo(
session.optString("method", "anonymous"), session.optString("method", "anonymous"),
session.optString("pubkey", ""), state.optString("pubkey", ""),
session, session.optJSONObject("data") ?: JSONObject(),
) )
} }
@ -537,7 +537,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
private data class SessionInfo( private data class SessionInfo(
val method: String, val method: String,
val pubkey: String, val pubkey: String,
val session: JSONObject, val data: JSONObject,
) )
private data class Subscription( private data class Subscription(
@ -699,24 +699,23 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
} }
private fun tryNip01Auth(webSocket: WebSocket, challenge: String): Boolean { private fun tryNip01Auth(webSocket: WebSocket, challenge: String): Boolean {
val secret = sessionInfo.session.optString("secret", "") val secret = sessionInfo.data.optString("secret", "")
if (secret.isEmpty()) return false if (secret.isEmpty()) return false
val signed = signWithNip01Secret(secret, buildAuthEvent(challenge).toString(), sessionInfo.pubkey) val signed = signWithNip01Secret(secret, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
return sendAuthMessage(webSocket, signed) return sendAuthMessage(webSocket, signed)
} }
private fun tryNip55Auth(webSocket: WebSocket, challenge: String): Boolean { private fun tryNip55Auth(webSocket: WebSocket, challenge: String): Boolean {
val signerPackage = sessionInfo.session.optString("signer", "") val signerPackage = sessionInfo.data.optString("signer", "")
if (signerPackage.isEmpty()) return false if (signerPackage.isEmpty()) return false
val signed = signWithNip55ContentResolver(signerPackage, buildAuthEvent(challenge).toString(), sessionInfo.pubkey) val signed = signWithNip55ContentResolver(signerPackage, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
return sendAuthMessage(webSocket, signed) return sendAuthMessage(webSocket, signed)
} }
private fun tryNip46Auth(webSocket: WebSocket, challenge: String): Boolean { private fun tryNip46Auth(webSocket: WebSocket, challenge: String): Boolean {
val handler = sessionInfo.session.optJSONObject("handler") ?: return false val clientSecret = sessionInfo.data.optString("clientSecret", "")
val clientSecret = sessionInfo.session.optString("secret", "") val signerPubkey = sessionInfo.data.optString("signerPubkey", "")
val signerPubkey = handler.optString("pubkey", "") val relays = sessionInfo.data.optJSONArray("relays")
val relays = handler.optJSONArray("relays")
if (clientSecret.isEmpty() || signerPubkey.isEmpty() || relays == null || relays.length() == 0) return false if (clientSecret.isEmpty() || signerPubkey.isEmpty() || relays == null || relays.length() == 0) return false

View file

@ -4,4 +4,5 @@
<string name="title_activity_main">Flotilla</string> <string name="title_activity_main">Flotilla</string>
<string name="package_name">social.flotilla</string> <string name="package_name">social.flotilla</string>
<string name="custom_url_scheme">social.flotilla</string> <string name="custom_url_scheme">social.flotilla</string>
<string name="default_notification_channel_id">flotilla_push</string>
</resources> </resources>

View file

@ -17,6 +17,11 @@ fi
if [[ $VITE_PLATFORM_LOGO =~ ^https:// ]]; then if [[ $VITE_PLATFORM_LOGO =~ ^https:// ]]; then
curl -fSL "$VITE_PLATFORM_LOGO" -o static/logo.png curl -fSL "$VITE_PLATFORM_LOGO" -o static/logo.png
export VITE_PLATFORM_LOGO=static/logo.png export VITE_PLATFORM_LOGO=static/logo.png
# @capacitor/assets reads its source logo from assets/, not static/, so
# propagate a custom platform logo there too for the native app icon/splash
cp "$VITE_PLATFORM_LOGO" assets/logo.png
cp "$VITE_PLATFORM_LOGO" assets/logo-dark.png
fi fi
# Ensure generator uses local path (dotenv may have loaded URL from .env) # Ensure generator uses local path (dotenv may have loaded URL from .env)
@ -35,3 +40,6 @@ npx @capacitor/assets generate \
--iconBackgroundColorDark '#222222' \ --iconBackgroundColorDark '#222222' \
--splashBackgroundColor '#ffffff' \ --splashBackgroundColor '#ffffff' \
--splashBackgroundColorDark '#191E24' --splashBackgroundColorDark '#191E24'
# @capacitor/assets doesn't generate Android notification icons
node scripts/generate-notification-icon.mjs

View file

@ -26,6 +26,10 @@ export default [
parser: ts.parser, parser: ts.parser,
}, },
}, },
rules: {
// eslint doesn't see type parameters declared by <script generics="...">
"no-undef": "off",
},
}, },
{ {
ignores: ["build/", ".svelte-kit/", "dist/"], ignores: ["build/", ".svelte-kit/", "dist/"],
@ -40,6 +44,15 @@ export default [
"no-extra-semi": "off", "no-extra-semi": "off",
"no-async-promise-executor": "off", "no-async-promise-executor": "off",
"prefer-const": ["error", {destructuring: "all"}], "prefer-const": ["error", {destructuring: "all"}],
"no-eq-null": "error",
"no-restricted-syntax": [
"error",
{
selector:
"Literal[raw='null']:not(CallExpression[callee.property.name='stringify'] > Literal)",
message: "Use undefined instead of null.",
},
],
"svelte/valid-compile": "off", "svelte/valid-compile": "off",
"svelte/no-at-html-tags": "off", "svelte/no-at-html-tags": "off",
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",

View file

@ -372,7 +372,7 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40; CURRENT_PROJECT_VERSION = 41;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
@ -381,7 +381,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.8.2; MARKETING_VERSION = 1.9.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -401,7 +401,7 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40; CURRENT_PROJECT_VERSION = 41;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
@ -410,7 +410,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.8.2; MARKETING_VERSION = 1.9.0;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -1,6 +1,6 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.8.2", "version": "1.9.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@ -35,6 +35,7 @@
"postcss": "^8.5.15", "postcss": "^8.5.15",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1", "prettier-plugin-svelte": "^3.4.1",
"sharp": "^0.35.3",
"svelte": "^5.55.9", "svelte": "^5.55.9",
"svelte-check": "^4.3.5", "svelte-check": "^4.3.5",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
@ -62,8 +63,9 @@
"@getalby/sdk": "^5.1.2", "@getalby/sdk": "^5.1.2",
"@hono/node-server": "^2.0.0", "@hono/node-server": "^2.0.0",
"@noble/curves": "^1.9.7", "@noble/curves": "^1.9.7",
"@pomade/core": "^0.3.1", "@pomade/core": "^0.3.3",
"@poppanator/sveltekit-svg": "^7.0.0", "@poppanator/sveltekit-svg": "^7.0.0",
"@svelte-plugins/datepicker": "^1.0.11",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2", "@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2", "@tiptap/pm": "^2.27.2",
@ -71,20 +73,18 @@
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vite-pwa/sveltekit": "^1.1.0", "@vite-pwa/sveltekit": "^1.1.0",
"@welshman/app": "^0.8.16", "@welshman/app": "^0.9.0-pre2",
"@welshman/content": "^0.8.16", "@welshman/content": "^0.9.0-pre2",
"@welshman/editor": "^0.8.16", "@welshman/domain": "^0.9.0-pre2",
"@welshman/feeds": "^0.8.16", "@welshman/editor": "^0.9.0-pre2",
"@welshman/lib": "^0.8.16", "@welshman/feeds": "^0.9.0-pre2",
"@welshman/net": "^0.8.16", "@welshman/lib": "^0.9.0-pre2",
"@welshman/router": "^0.8.16", "@welshman/net": "^0.9.0-pre2",
"@welshman/signer": "^0.8.16", "@welshman/signer": "^0.9.0-pre2",
"@welshman/store": "^0.8.16", "@welshman/store": "^0.9.0-pre2",
"@welshman/util": "^0.8.16", "@welshman/util": "^0.9.0-pre2",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2", "compressorjs-next": "^1.1.2",
"daisyui": "^5.5.19",
"date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1", "emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0", "emoji-picker-element-data": "^1.8.0",

Binary file not shown.

View file

@ -11,9 +11,19 @@ minimumReleaseAgeExclude:
- '@welshman/feeds' - '@welshman/feeds'
- '@welshman/lib' - '@welshman/lib'
- '@welshman/net' - '@welshman/net'
- '@welshman/router' - '@welshman/domain'
- '@welshman/signer' - '@welshman/signer'
- '@welshman/store' - '@welshman/store'
- '@welshman/util' - '@welshman/util'
overrides: overrides:
sharp: 0.35.0-rc.0 '@welshman/app': link:../welshman/packages/app
'@welshman/content': link:../welshman/packages/content
'@welshman/domain': link:../welshman/packages/domain
'@welshman/editor': link:../welshman/packages/editor
'@welshman/feeds': link:../welshman/packages/feeds
'@welshman/lib': link:../welshman/packages/lib
'@welshman/net': link:../welshman/packages/net
'@welshman/signer': link:../welshman/packages/signer
'@welshman/store': link:../welshman/packages/store
'@welshman/util': link:../welshman/packages/util
sharp: ^0.35.3

View file

@ -0,0 +1,54 @@
#!/usr/bin/env node
// Generates the Android notification (status-bar) small icon from the app's
// source logo. @capacitor/assets does NOT produce notification icons, so the
// build pipeline calls this after `@capacitor/assets generate` to keep the
// notification icon in sync with a custom platform logo (VITE_PLATFORM_LOGO).
//
// A notification small icon must be a monochrome silhouette: Android renders
// only its alpha channel and tints it. We take the logo's alpha channel as the
// shape and fill it white, with a little transparent padding so it isn't
// edge-to-edge in the status bar.
import sharp from "sharp"
import {mkdir} from "node:fs/promises"
import {dirname, join} from "node:path"
const source = process.argv[2] || "assets/logo.png"
const resDir = process.argv[3] || "android/app/src/main/res"
// density bucket -> small-icon size in px
const densities = {mdpi: 24, hdpi: 36, xhdpi: 48, xxhdpi: 72, xxxhdpi: 96}
const generate = async (size, out) => {
const pad = Math.round(size * 0.14)
const inner = size - pad * 2
// The logo's alpha channel is the shape we want to render. Trim the source's
// own transparent margin first — logo.png carries ~15% baked-in padding, and
// without trimming that stacks on top of `pad` below, leaving the glyph at
// only ~half the icon's width. Trimming lets `pad` be the single source of
// padding so the shape actually fills the status-bar icon.
const alpha = await sharp(source)
.trim({threshold: 1})
.resize(inner, inner, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
.ensureAlpha()
.extractChannel(3)
.toColourspace("b-w")
.raw()
.toBuffer()
await sharp({
create: {width: inner, height: inner, channels: 3, background: {r: 255, g: 255, b: 255}},
})
.joinChannel(alpha, {raw: {width: inner, height: inner, channels: 1}})
.extend({top: pad, bottom: pad, left: pad, right: pad, background: {r: 0, g: 0, b: 0, alpha: 0}})
.png()
.toFile(out)
console.log(`wrote ${out} (${size}px)`)
}
for (const [density, size] of Object.entries(densities)) {
const out = join(resDir, `drawable-${density}`, "ic_stat_notify.png")
await mkdir(dirname(out), {recursive: true})
await generate(size, out)
}

View file

@ -1,459 +0,0 @@
@import "tailwindcss";
@config "../tailwind.config.js";
/* root */
:root {
font-family: Lato;
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
}
@utility pt-sai {
padding-top: var(--sait);
}
@utility pr-sai {
padding-right: var(--sair);
}
@utility pb-sai {
padding-bottom: var(--saib);
}
@utility pl-sai {
padding-left: var(--sail);
}
@utility px-sai {
@apply pl-sai pr-sai;
}
@utility py-sai {
@apply pt-sai pb-sai;
}
@utility p-sai {
@apply py-sai px-sai;
}
@utility mt-sai {
margin-top: var(--sait);
}
@utility mr-sai {
margin-right: var(--sair);
}
@utility mb-sai {
margin-bottom: var(--saib);
}
@utility ml-sai {
margin-left: var(--sail);
}
@utility mx-sai {
@apply ml-sai mr-sai;
}
@utility my-sai {
@apply mt-sai mb-sai;
}
@utility m-sai {
@apply my-sai mx-sai;
}
@utility top-sai {
top: var(--sait);
}
@utility right-sai {
right: var(--sair);
}
@utility bottom-sai {
bottom: var(--saib);
}
@utility left-sai {
left: var(--sail);
}
@utility card2 {
@apply rounded-box text-base-content border-base-content/20 bg-base-100 border border-solid p-4 shadow-xl/5 sm:p-6;
}
@utility card2-interactive {
@apply cursor-pointer hover:scale-101 transition-all;
}
@utility column {
@apply flex flex-col;
}
@utility center {
@apply flex items-center justify-center;
}
@utility row-2 {
@apply flex items-center gap-2;
}
@utility row-3 {
@apply flex items-center gap-3;
}
@utility row-4 {
@apply flex items-center gap-4;
}
@utility col-2 {
@apply flex flex-col gap-2;
}
@utility col-3 {
@apply flex flex-col gap-3;
}
@utility col-4 {
@apply flex flex-col gap-4;
}
@utility col-8 {
@apply flex flex-col gap-8;
}
@utility ellipsize {
@apply overflow-hidden text-ellipsis;
}
@utility content-padding-x {
@apply px-4 sm:px-8 md:px-12;
}
@utility content-padding-t {
@apply pt-4 sm:pt-8 md:pt-12;
}
@utility content-padding-b {
@apply pb-4 sm:pb-8 md:pb-12;
}
@utility content-padding-y {
@apply pt-4 pb-4 sm:pt-8 sm:pb-8 md:pt-12 md:pb-12;
}
@utility content-sizing {
@apply m-auto w-full max-w-3xl;
}
@utility content {
@apply m-auto w-full max-w-3xl px-4 pt-4 pb-4 sm:px-8 sm:pt-8 sm:pb-8 md:px-12 md:pt-12 md:pb-12;
}
@utility heading {
@apply text-center text-2xl;
}
@utility subheading {
@apply text-center text-xl;
}
@utility superheading {
@apply text-center text-4xl;
}
@utility link {
@apply text-primary cursor-pointer underline;
}
/* content visibility */
@utility cv {
content-visibility: auto;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer utilities {
/* Fonts */
@font-face {
font-family: "Satoshis";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Satoshi Symbol.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: normal;
font-weight: 400;
src:
local(""),
url("/fonts/Lato-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: bold;
font-weight: 600;
src:
local(""),
url("/fonts/Lato-Bold.ttf") format("truetype");
}
@font-face {
font-family: "Lato";
font-style: italic;
font-weight: 400;
src:
local(""),
url("/fonts/Italic.ttf") format("truetype");
}
/* root */
:root {
font-family: Lato;
text-size-adjust: 100%;
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
}
[data-theme] {
@apply bg-base-300;
}
.mobile [data-tip]::before {
display: none !important;
}
/* safe area insets */
}
/* utilities */
.bg-alt,
.bg-alt .bg-alt .bg-alt,
.hover\:bg-alt:hover,
.bg-alt .bg-alt .hover\:bg-alt:hover,
.bg-alt .bg-alt.hover\:bg-alt:hover {
@apply bg-base-100 text-base-content transition-colors;
}
.bg-alt .bg-alt,
.bg-alt .bg-alt .bg-alt .bg-alt,
.bg-alt .hover\:bg-alt:hover,
.bg-alt .bg-alt .bg-alt .hover\:bg-alt:hover,
.bg-alt.hover\:bg-alt:hover,
.bg-alt .bg-alt .bg-alt.hover\:bg-alt:hover {
@apply bg-base-300 text-base-content transition-colors;
}
.card2.card2-sm {
@apply text-base-content p-2 sm:p-4;
}
.card2 .card2,
.dialog .card2 {
@apply shadow-none;
}
[data-tip]::before {
@apply overflow-hidden text-ellipsis;
}
.input input::placeholder {
opacity: 0.5;
}
/* tiptap */
.input-editor,
.chat-editor,
.note-editor {
@apply -m-1 p-1;
}
.tiptap {
--tiptap-object-bg: var(--color-neutral);
--tiptap-object-fg: var(--color-neutral-content);
--tiptap-active-bg: var(--color-primary);
--tiptap-active-fg: var(--color-primary-content);
}
.tiptap-suggestions {
--tiptap-object-bg: var(--color-base-100);
--tiptap-object-fg: var(--color-base-content);
--tiptap-active-bg: var(--color-base-300);
--tiptap-active-fg: var(--color-base-content);
}
.tiptap-suggestions__item {
@apply border-base-100 border-l-2 border-solid;
}
.tiptap-suggestions__selected {
@apply border-primary;
}
.tiptap {
@apply max-h-[350px] min-h-10 overflow-y-auto p-2 px-4;
}
.tiptap p.is-editor-empty:first-child::before {
opacity: 40%;
}
.chat-editor .tiptap {
@apply rounded-box bg-base-300 pr-12;
}
.note-editor .tiptap {
--tiptap-object-bg: var(--color-base-200);
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
}
.input-editor .tiptap {
--tiptap-object-bg: var(--color-base-200);
@apply input block h-auto p-[.65rem];
}
/* link-content, based on tiptap */
.link-content {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 3px;
padding: 0 0.25rem;
background-color: var(--color-base-100);
color: var(--color-base-content);
}
/* content rendered by welshman/content */
.welshman-content a {
@apply link;
}
.welshman-content-error a {
@apply underline;
}
/* date input */
.picker {
--date-picker-foreground: var(--color-base-content);
--date-picker-background: var(--color-base-300);
--date-picker-highlight-border: var(--color-primary);
--date-picker-selected-color: var(--color-primary-content);
--date-picker-selected-background: var(--color-primary);
}
.date-time-field {
@apply input rounded-lg px-0;
}
.date-time-field input {
@apply h-full! w-full! rounded-lg! border-none! bg-inherit! px-4! text-inherit!;
}
/* tippy popover */
.tippy-target {
@apply z-tooltip pointer-events-none fixed inset-0;
}
.tippy-target > * {
pointer-events: auto;
}
.tippy-box {
@apply rounded-box shadow-xl;
}
/* emoji picker */
emoji-picker {
--background: var(--color-base-100);
--border-color: var(--color-base-100);
--border-radius: var(--rounded-box);
--button-active-background: var(--color-base-content);
--button-hover-background: var(--color-base-content);
--indicator-color: var(--color-base-content);
--input-border-color: var(--color-base-100);
--input-font-color: var(--color-base-content);
--outline-color: var(--color-base-100);
}
/* progress */
progress[value]::-webkit-progress-value {
transition: width 0.5s;
}
/* content width for fixed elements */
.left-content {
@apply left-sai md:left-[calc(18.5rem+var(--sail))];
}
.left-content-full {
@apply left-sai md:left-[calc(3.5rem+var(--sail))];
}
/* Keyboard open state adjustments */
body.keyboard-open {
--saib: 0px;
}
body.keyboard-open .hide-on-keyboard {
display: none;
}
body.keyboard-open .chat__compose {
margin-bottom: 0;
}
/* chat view */
.chat__compose {
@apply z-compose relative mb-14 shrink-0 md:mb-0;
}
.chat__compose .chat__compose-inner {
@apply min-w-0;
}
.chat__scroll-down {
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
}

View file

@ -24,7 +24,7 @@
<link rel="icon" href="/pwa-64x64.png" sizes="64x64" type="image/png" /> <link rel="icon" href="/pwa-64x64.png" sizes="64x64" type="image/png" />
<link rel="icon" href="/pwa-192x192.png" sizes="192x192" type="image/png" /> <link rel="icon" href="/pwa-192x192.png" sizes="192x192" type="image/png" />
<link rel="icon" href="/pwa-512x512.png" sizes="512x512" type="image/png" /> <link rel="icon" href="/pwa-512x512.png" sizes="512x512" type="image/png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon-180x180.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover" data-sveltekit-preload-code="eager"> <body data-sveltekit-preload-data="hover" data-sveltekit-preload-code="eager">

380
src/app/access.ts Normal file
View file

@ -0,0 +1,380 @@
import {derived, writable, type Readable} from "svelte/store"
import {dissoc, fromPairs, last, poll, randomId, sleep, tryCatch} from "@welshman/lib"
import {AuthStatus, SocketStatus} from "@welshman/net"
import type {Socket} from "@welshman/net"
import {
displayRelayUrl,
isRelayUrl,
makeEvent,
normalizeRelayUrl,
FOLLOWS,
MESSAGING_RELAYS,
PROFILE,
RELAY_INVITE,
RELAYS,
} from "@welshman/util"
import {RelayInvite, RelayJoin, RelayLeave, RoomJoin} from "@welshman/domain"
import {Sync, User, publish} from "@welshman/app"
import {stripPrefix} from "@lib/util"
import {app, command, network, reader, roomLists, thunks, writer} from "@app/core"
import {PLATFORM_URL} from "@app/env"
import {relaysMostlyRestricted} from "@app/policies"
import {Push} from "@app/push"
import {deriveSocket} from "@app/relays"
import {notificationSettings, setSpaceNotifications} from "@app/settings"
import {syncApplicationData} from "@app/sync"
export const ROOM_CREATE_INVITE = 9009
export type InviteData = {
url: string
claim: string
h?: string
code?: string
}
export const isNetworkAuthError = (error: string) => {
const lower = error.toLowerCase()
return lower.includes("failed") || lower.includes("timeout") || lower.includes("network")
}
export const parseInviteLink = (invite: string): InviteData | undefined => {
if (invite.length < 3 || !invite.includes(".")) {
return
}
return (
tryCatch(() => {
const params = fromPairs(Array.from(new URL(invite).searchParams))
const {r: relay = "", c: claim = "", h = "", code = ""} = params
const url = normalizeRelayUrl(relay)
if (isRelayUrl(url)) {
return {url, claim, h: h || undefined, code: code || undefined}
}
}) ||
tryCatch(() => {
const url = normalizeRelayUrl(invite)
if (isRelayUrl(url)) {
return {url, claim: ""}
}
})
)
}
export const makeInviteLink = (data: InviteData) => {
const params = new URLSearchParams({
r: displayRelayUrl(data.url),
c: data.claim,
})
if (data.h) {
params.set("h", data.h)
}
if (data.code) {
params.set("code", data.code)
}
return PLATFORM_URL + "/join?" + params.toString()
}
export const shouldIgnoreError = (error: string) => {
const isIgnored = error.startsWith("mute: ")
const isAborted = error.includes("Signing was aborted")
const isStrictNip29Relay = error.includes("missing group (`h`) tag")
return isIgnored || isAborted || isStrictNip29Relay
}
export const deriveRelayAuthError = (url: string) =>
derived([relaysMostlyRestricted, deriveSocket(url)], ([$relaysMostlyRestricted, $socket]) => {
if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) {
return stripPrefix($socket.auth.details)
}
if ($relaysMostlyRestricted[url]) {
return stripPrefix($relaysMostlyRestricted[url])
}
})
export const publishJoinRequest = (url: string, claim = "") =>
command(writer(RelayJoin).forceRelays(url).setClaim(claim)).then(publish)
export const publishLeaveRequest = (url: string) =>
command(writer(RelayLeave).forceRelays(url)).then(publish)
export const publishRoomJoinRequest = (url: string, h: string, code?: string) => {
const eventWriter = writer(RoomJoin).setRoom(url, h)
if (code) {
eventWriter.setClaim(code)
}
return command(eventWriter).then(publish)
}
export const publishRoomInvite = async (url: string, h: string) => {
const code = randomId()
const event = makeEvent(ROOM_CREATE_INVITE, {
tags: [
["h", h],
["code", code],
],
})
const error = await thunks
.get()
.publish({event, relays: [url]})
.waitForError()
if (error) {
return {error, code: undefined}
}
return {error: undefined, code}
}
const authTerminalStatuses = [
AuthStatus.None,
AuthStatus.Ok,
AuthStatus.Forbidden,
AuthStatus.DeniedSignature,
]
const waitForAuth = async (socket: Socket) => {
await poll({
signal: AbortSignal.timeout(10_000),
condition: () => authTerminalStatuses.includes(socket.auth.status),
}).catch(() => {})
}
const formatAuthError = (status: AuthStatus, details?: string) => {
if (status === AuthStatus.DeniedSignature) {
return "Failed to authenticate — check your signer"
}
if (status === AuthStatus.PendingSignature) {
return "Failed to authenticate — approve the signing request from your extension or signer"
}
const message = details || last(status.split(":"))
return `Failed to authenticate (${message})`
}
export const attemptRelayAccess = async (url: string, claim = "") => {
const socket = app.get().pool.get(url)
socket.attemptToOpen()
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
return `Failed to connect`
}
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.auth.status === AuthStatus.Requested,
})
for (let i = 0; i < 3 && !authTerminalStatuses.includes(socket.auth.status); i++) {
await socket.auth.retryAuth(User.require(app.get()).sign)
await waitForAuth(socket)
}
if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) {
return formatAuthError(socket.auth.status, socket.auth.details)
}
const thunk = await publishJoinRequest(url, claim)
const error = await thunk.waitForError()
if (shouldIgnoreError(error)) return
if (claim) {
if (error.includes("invite code")) {
return "join request rejected"
}
} else if (error.includes("invite code")) {
return
}
return stripPrefix(error)
}
export class Access {
url: string
authError: Readable<string | undefined>
loading = writable(true)
claim = writable("")
claimFailed = writable(false)
roomCode = writable<string | undefined>()
roomInviteError = writable<string | undefined>()
isExplicitAuthError: Readable<boolean>
isGenericError: Readable<boolean>
constructor(url: string) {
this.url = url
this.authError = deriveRelayAuthError(url)
this.isExplicitAuthError = derived([this.authError], ([$authError]) =>
Boolean($authError && !isNetworkAuthError($authError)),
)
this.isGenericError = derived([this.authError], ([$authError]) =>
Boolean($authError && isNetworkAuthError($authError)),
)
}
createInviteStatus(requireCode: boolean) {
return derived(
[
this.loading,
this.isGenericError,
this.isExplicitAuthError,
this.roomInviteError,
this.roomCode,
this.claimFailed,
],
([$loading, $isGeneric, $isExplicit, $roomInviteError, $roomCode, $claimFailed]) => {
if ($loading) return "loading" as const
if ($isGeneric) return "network" as const
if ($isExplicit || $roomInviteError) return "auth" as const
if (requireCode && !$roomCode) return "failed" as const
if ($claimFailed) return "noclaim" as const
return "ready" as const
},
)
}
clearRestricted() {
relaysMostlyRestricted.update(dissoc(this.url))
}
async attempt(claim = "") {
return attemptRelayAccess(this.url, claim)
}
async configureNotifications(enabled: boolean) {
if (enabled) {
if (!notificationSettings.get().push) {
await setSpaceNotifications(this.url, true)
} else {
const permissions = await Push.request()
if (permissions.startsWith("granted")) {
await setSpaceNotifications(this.url, true)
}
}
} else {
await setSpaceNotifications(this.url, false)
}
}
async completeJoin(notifications: boolean) {
await this.configureNotifications(notifications)
await roomLists.get().addRelay(this.url).then(publish)
this.clearRestricted()
syncApplicationData()
app
.get()
.use(Sync)
.push({
relays: [this.url],
filters: [
{
kinds: [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE],
authors: [User.require(app.get()).pubkey],
},
],
})
}
async joinSpace({
claim = "",
notifications,
alreadyJoined = false,
}: {
claim?: string
notifications: boolean
alreadyJoined?: boolean
}) {
if (!alreadyJoined) {
const error = await this.attempt(claim)
if (error) {
return error
}
await this.completeJoin(notifications)
}
}
async joinRoom(h: string, code?: string) {
if (!code) return
const thunk = await publishRoomJoinRequest(this.url, h, code)
const message = await thunk.waitForError()
if (message && !message.startsWith("duplicate:")) {
return message
}
}
async acceptInvite(data: InviteData, notifications: boolean) {
const spaceUrls = roomLists.get().urls(User.require(app.get()).pubkey).get()
const error = await this.joinSpace({
claim: data.claim,
notifications,
alreadyJoined: spaceUrls.includes(data.url),
})
if (error) {
return error
}
if (data.h && data.code) {
return this.joinRoom(data.h, data.code)
}
}
async prepareInvite(h?: string) {
this.loading.set(true)
// A request that times out or hits a closed socket resolves with no events, so an
// empty result is the only signal we get that the relay never answered.
const [events, roomInviteResult] = await Promise.all([
network.get().request({
relays: [this.url],
autoClose: true,
signal: AbortSignal.timeout(10000),
filters: [{kinds: [RELAY_INVITE]}],
}),
h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}),
// Keep the spinner up long enough that a fast relay doesn't make it flash
sleep(300),
])
if (events[0]) {
const eventReader = reader(RelayInvite)
const invite = await eventReader(events[0])
this.claim.set(invite.claim() ?? "")
}
this.claimFailed.set(events.length === 0)
if (h) {
this.roomCode.set(roomInviteResult.code)
this.roomInviteError.set(roomInviteResult.error)
}
this.loading.set(false)
}
}

View file

@ -1,3 +1,5 @@
import {derived} from "svelte/store"
import {first, groupBy, removeUndefined, spec} from "@welshman/lib"
import { import {
REPORT, REPORT,
ROOM_ADD_MEMBER, ROOM_ADD_MEMBER,
@ -5,28 +7,48 @@ import {
ROOM_LEAVE, ROOM_LEAVE,
ROOM_MEMBERS, ROOM_MEMBERS,
ROOM_REMOVE_MEMBER, ROOM_REMOVE_MEMBER,
getPubkeyTagValues, hexTags,
getTagValue, sortEventsAsc,
sortEventsDesc, sortEventsDesc,
tagSpec,
tagValue,
tagValues,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {first, groupBy, removeUndefined} from "@welshman/lib"
import {derived} from "svelte/store"
import {deriveEventsForUrl} from "@app/repository" import {deriveEventsForUrl} from "@app/repository"
import {getRoomMembers} from "@app/members"
// A room's membership replayed in order — each member list is a snapshot that resets
// the set, with add/remove events applied on top.
const getRoomMembers = (events: TrustedEvent[]) => {
const members = new Set<string>()
for (const event of sortEventsAsc(events)) {
const pubkeys = tagValues(hexTags("p"), event.tags)
if (event.kind === ROOM_MEMBERS) {
members.clear()
pubkeys.forEach(pubkey => members.add(pubkey))
} else if (event.kind === ROOM_ADD_MEMBER) {
pubkeys.forEach(pubkey => members.add(pubkey))
} else if (event.kind === ROOM_REMOVE_MEMBER) {
pubkeys.forEach(pubkey => members.delete(pubkey))
}
}
return members
}
// Action items (admin review queue) // Action items (admin review queue)
export const deriveSpaceActionItems = (url: string) => export const deriveSpaceActionItems = (url: string) =>
derived( derived(
deriveEventsForUrl(url, [ deriveEventsForUrl(url, [
{ {kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]},
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
},
]), ]),
$events => { $events => {
const getRoomId = (e: TrustedEvent) => const getRoomId = (e: TrustedEvent) =>
getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags) tagValue(tagSpec(e.kind === ROOM_MEMBERS ? "d" : "h"), e.tags)
const reports = $events.filter(e => e.kind === REPORT) const reports = $events.filter(spec({kind: REPORT}))
const pendingJoins: TrustedEvent[] = [] const pendingJoins: TrustedEvent[] = []
// Room-level join requests — most recent per pubkey+h // Room-level join requests — most recent per pubkey+h
@ -53,7 +75,7 @@ export const deriveSpaceActionItems = (url: string) =>
} }
} }
const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents)) const roomMembers = getRoomMembers(roomMembershipEvents)
pendingJoins.push( pendingJoins.push(
...removeUndefined( ...removeUndefined(
@ -72,7 +94,7 @@ export const deriveSpaceActionItems = (url: string) =>
return true return true
} }
return getPubkeyTagValues(event.tags).includes(pubkey) return tagValues(hexTags("p"), event.tags).includes(pubkey)
}) })
) { ) {
return false return false

View file

@ -2,6 +2,7 @@
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox * Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS. * (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
*/ */
import {MediaQuery} from "svelte/reactivity"
import { import {
DisconnectReason, DisconnectReason,
LocalParticipant, LocalParticipant,
@ -14,39 +15,60 @@ import {
supportsAudioOutputSelection, supportsAudioOutputSelection,
type AudioCaptureOptions, type AudioCaptureOptions,
} from "livekit-client" } from "livekit-client"
import {derived, get} from "svelte/store" import {derived, get, writable} from "svelte/store"
import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib" import {first, not, nthEq, reject, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, sortEventsDesc, tagSpec, tagValues} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {deriveDeduplicated} from "@welshman/store"
import {signer} from "@welshman/app" import {makeRoomKey} from "@welshman/app"
import {load} from "@welshman/net" import type {Room} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit" import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util" import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
import { import {network, user} from "@app/core"
currentVoiceRoom, import {deriveEventsForUrl} from "@app/repository"
currentVoiceSession,
voiceMicMuted,
participantFromLiveKitIdentity,
participantKey,
participantMediaState,
speakingParticipants,
VoiceState,
type ParticipantMediaState,
type VoiceParticipant,
voiceState,
} from "@app/call/stores"
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
import {deriveLatestEventForUrl} from "@app/repository"
import {deriveRoom, makeRoomId} from "@app/groups"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
export const LIVEKIT_PARTICIPANTS = 39004 export const LIVEKIT_PARTICIPANTS = 39004
export {checkRelayHasLivekit} from "$lib/livekit"
export {supportsAudioOutputSelection} export {supportsAudioOutputSelection}
const LIVEKIT_DEFAULT_DEVICE_ID = "default" const LIVEKIT_DEFAULT_DEVICE_ID = "default"
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
export type CallSession = {
url: string
h: string
livekit: LiveKitRoom
cameraOn: boolean
screenShareOn: boolean
}
/** Mic mute state is separate so toggling it does not re-render video tiles. */
export const callMicMuted = writable(true)
export type CallParticipant = {pubkey?: string; liveKitIdentity: string}
export type ParticipantMediaState = {
muted: boolean
cameraOn: boolean
}
export enum CallState {
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
}
export enum VideoCallLayout {
Chat = "chat",
Video = "video",
Split = "split",
}
export enum ViewportSize {
Desktop = "desktop",
Mobile = "mobile",
}
export enum DeviceKind { export enum DeviceKind {
AudioInput = "audioinput", AudioInput = "audioinput",
@ -54,312 +76,56 @@ export enum DeviceKind {
VideoInput = "videoinput", VideoInput = "videoinput",
} }
export const switchVoiceActiveDevice = async ( export const currentCallSession = writable<CallSession | undefined>(undefined)
kind: DeviceKind,
targetDeviceId: string,
): Promise<void> => {
const session = get(currentVoiceSession)
if (!session) return
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
try {
await session.room.switchActiveDevice(kind, id)
} catch {
let label: string
switch (kind) {
case DeviceKind.AudioInput:
label = "microphone"
break
case DeviceKind.AudioOutput:
label = "speaker"
break
case DeviceKind.VideoInput:
label = "camera"
break
}
pushToast({theme: "error", message: `Error changing ${label}`})
}
}
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({ export const callState = writable<CallState>(CallState.Disconnected)
muted: !participant.isMicrophoneEnabled,
cameraOn: participant.isCameraEnabled,
})
const deleteParticipant = (identity: string) => { export const callTargetRoom = writable<Room | undefined>(undefined)
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
}
const syncParticipantMedia = (participant: Participant) => { export const speakingParticipants = writable<CallParticipant[]>([])
const state = participantMediaFrom(participant)
participantMediaState.update(m => {
const prev = m.get(participant.identity)
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
const next = new Map(m)
next.set(participant.identity, state)
return next
})
}
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect. export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
const resyncAfterReconnect = (room: LiveKitRoom) => {
if (room !== activeRoom) return
const next = new Map<string, ParticipantMediaState>() export const mediaStateByIdentity = derived(
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) { [participantMediaState, currentCallSession, callMicMuted],
next.set(p.identity, participantMediaFrom(p)) ([$media, $session, $micMuted]) =>
} (liveKitIdentity: string) => {
participantMediaState.set(next) if ($session?.livekit.localParticipant.identity === liveKitIdentity) {
return {muted: $micMuted, cameraOn: $session.cameraOn}
const session = get(currentVoiceSession)
if (!session) return
const {localParticipant} = room
voiceMicMuted.set(!localParticipant.isMicrophoneEnabled)
currentVoiceSession.set({
...session,
cameraOn: localParticipant.isCameraEnabled,
screenShareOn: localParticipant.isScreenShareEnabled,
})
triggerVideoFeedCount()
}
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
syncParticipantMedia(participant)
}
const fetchLivekitToken = async (
url: string,
groupId: string,
signal?: AbortSignal,
): Promise<{server_url: string; participant_token: string}> => {
const endpoint = getLivekitEndpoint(url, groupId)
const $signer = signer.get()
if (!$signer) throw new Error("No signer available")
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
const template = await makeHttpAuth(endpoint, "GET")
const signedEvent = await $signer.sign(template)
const authHeader = makeHttpAuthHeader(signedEvent)
const response = await fetch(endpoint, {
headers: {Authorization: authHeader},
signal,
})
if (!response.ok) {
const text = await response.text()
throw new Error(`Token request failed (${response.status}): ${text}`)
}
return response.json()
}
export const loadVoiceParticipants = (url: string, h: string) =>
load({
relays: [url],
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
})
export const deriveVoiceParticipants = (url: string, h: string) =>
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
derived(
[
participantMediaState,
currentVoiceRoom,
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
],
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
if (inCall) {
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
} else {
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
if (!latestEvent) return []
const participants = removeUndefined(
map(
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
getTags("participant", latestEvent.tags),
),
)
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
} }
return $media.get(liveKitIdentity) ?? {muted: true, cameraOn: false}
}, },
) )
const setUpMicrophone = async ( export const isParticipantSpeaking = derived(
startMuted: boolean, speakingParticipants,
preferredMicId: string | undefined, $participants => (p: CallParticipant) =>
participant: LocalParticipant, $participants.some(sp => participantKey(sp) === participantKey(p)),
signal?: AbortSignal, )
settleSignal?: AbortSignal,
): Promise<boolean> => {
if (startMuted) {
return true
}
let muted = true export const videoTrackRevision = writable(0)
let capture: AudioCaptureOptions | undefined = undefined
if (preferredMicId) { export const triggerVideoTrackRevision = () => {
capture = {deviceId: preferredMicId} videoTrackRevision.update(n => n + 1)
}
try {
await Promise.race([
participant.setMicrophoneEnabled(true, capture),
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
whenAborted(signal),
])
muted = false
} catch (e) {
// Timeout or microphone rejection: join muted, the call is still usable. A
// genuine abort is surfaced to the caller so it can tear down the room.
if (e instanceof AbortError) throw e
if (!(e instanceof TimeoutError)) {
pushToast({theme: "error", message: "Could not access microphone"})
}
}
return muted
} }
// The room whose events are allowed to mutate shared state. Abandoned rooms export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
// (after switching calls or an engine reconnect give-up) must not clobber it.
let activeRoom: LiveKitRoom | undefined
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000] export const videoCallViewportSync = {
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined previousLayout: undefined as ViewportSize | undefined,
let reconnectAttempt = 0
const clearReconnectSchedule = () => {
if (reconnectTimeout !== undefined) {
clearTimeout(reconnectTimeout)
reconnectTimeout = undefined
}
reconnectAttempt = 0
} }
const attemptReconnect = async () => { export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
const target = get(currentVoiceRoom)
if (!target) return
try { export const videoPrimaryTileKey = writable<string | undefined>(undefined)
await joinVoiceRoom(target.url, target.h)
} catch {
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
clearReconnectSchedule()
return
}
scheduleReconnect()
}
}
const scheduleReconnect = () => { export const videoTileCount = derived(
if (reconnectTimeout !== undefined) return [currentCallSession, callState, videoTrackRevision, participantMediaState],
if (!get(currentVoiceRoom)) return ([$session, $state]) => {
if (reconnectAttempt >= RECONNECT_DELAYS.length) { if ($state !== CallState.Connected || !$session) return 0
pushToast({theme: "error", message: "Voice connection lost."}) return countLiveVisualFeeds($session)
return },
} )
const delay = RECONNECT_DELAYS[reconnectAttempt]!
reconnectAttempt++
reconnectTimeout = setTimeout(() => {
reconnectTimeout = undefined
void attemptReconnect()
}, delay)
}
const makeOnRoomReconnected = (room: LiveKitRoom) => () => {
if (room !== activeRoom) return
resyncAfterReconnect(room)
}
const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => {
// Ignore disconnects from rooms that are no longer the active session.
if (room !== activeRoom) return
activeRoom = undefined
room.removeAllListeners()
videoPrimaryTileKey.set(undefined)
voiceMicMuted.set(true)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected)
if (reason === DisconnectReason.JOIN_FAILURE) {
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
} else if (get(currentVoiceRoom)) {
clearReconnectSchedule()
scheduleReconnect()
} else {
pushToast({theme: "error", message: "Voice connection lost."})
}
}
speakingParticipants.set([])
participantMediaState.set(new Map())
}
const onTrackSubscribed = (track: Track) => {
if (track.kind === Track.Kind.Audio) {
const element = track.attach()
element.style.display = "none"
document.body.appendChild(element)
element.play().catch(() => {})
} else if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
}
}
const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) {
triggerVideoFeedCount()
}
}
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
}
const playJoinSound = () => {
const audio = new Audio("/join-voice-room.mp3")
audio.play().catch(() => {})
}
const onParticipantConnected = (participant: Participant) => {
syncParticipantMedia(participant)
playJoinSound()
}
const onParticipantDisconnected = (participant: {identity: string}) => {
deleteParticipant(participant.identity)
}
const onLocalTrackUnpublished = (
publication: LocalTrackPublication,
participant: LocalParticipant,
) => {
if (publication.source !== Track.Source.ScreenShare) return
const session = get(currentVoiceSession)
if (!session || participant.identity !== session.room.localParticipant.identity) return
if (!session.screenShareOn) return
currentVoiceSession.set({...session, screenShareOn: false})
}
let joinAbortController: AbortController | undefined
const abortJoinVoiceRoom = () => {
joinAbortController?.abort()
}
export const cancelJoinVoiceRoom = () => {
clearReconnectSchedule()
abortJoinVoiceRoom()
}
export const joinVoiceRoom = async ( export const joinVoiceRoom = async (
url: string, url: string,
@ -369,8 +135,8 @@ export const joinVoiceRoom = async (
): Promise<void> => { ): Promise<void> => {
abortJoinVoiceRoom() abortJoinVoiceRoom()
currentVoiceRoom.set(get(deriveRoom(url, h))) callTargetRoom.set({url, h, id: makeRoomKey(url, h)})
voiceState.set(VoiceState.Joining) callState.set(CallState.Joining)
const controller = new AbortController() const controller = new AbortController()
joinAbortController = controller joinAbortController = controller
@ -384,7 +150,7 @@ export const joinVoiceRoom = async (
try { try {
// Tear down any existing session before joining. Bound it so a slow leave // Tear down any existing session before joining. Bound it so a slow leave
// (camera/screenshare renegotiation can take ~15s) cannot block this join. // (camera/screenshare renegotiation can take ~15s) cannot block this join.
if (get(currentVoiceSession)) { if (get(currentCallSession)) {
await Promise.race([ await Promise.race([
leaveVoiceRoom(), leaveVoiceRoom(),
whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}), whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}),
@ -393,8 +159,8 @@ export const joinVoiceRoom = async (
if (e instanceof AbortError) throw e if (e instanceof AbortError) throw e
}) })
// leaveVoiceRoom flips voiceState to Disconnected; re-assert Joining. // leaveVoiceRoom flips callState to Disconnected; re-assert Joining.
voiceState.set(VoiceState.Joining) callState.set(CallState.Joining)
} }
if (signal.aborted) throw new AbortError() if (signal.aborted) throw new AbortError()
@ -437,9 +203,7 @@ export const joinVoiceRoom = async (
whenAborted(signal), whenAborted(signal),
]) ])
} catch (e) { } catch (e) {
if (activeRoom === liveKitRoom) activeRoom = undefined teardownRoom(liveKitRoom)
liveKitRoom.removeAllListeners()
liveKitRoom.disconnect()
throw e throw e
} }
@ -462,25 +226,23 @@ export const joinVoiceRoom = async (
// A cancel during the mic step must tear down the connected room rather // A cancel during the mic step must tear down the connected room rather
// than leaking it. // than leaking it.
if (signal.aborted) { if (signal.aborted) {
if (activeRoom === liveKitRoom) activeRoom = undefined teardownRoom(liveKitRoom)
liveKitRoom.removeAllListeners()
liveKitRoom.disconnect()
throw new AbortError() throw new AbortError()
} }
voiceMicMuted.set(muted) callMicMuted.set(muted)
currentVoiceSession.set({ currentCallSession.set({
url, url,
h, h,
room: liveKitRoom, livekit: liveKitRoom,
cameraOn: false, cameraOn: false,
screenShareOn: false, screenShareOn: false,
}) })
voiceState.set(VoiceState.Connected) callState.set(CallState.Connected)
clearReconnectSchedule() clearReconnectSchedule()
playJoinSound() playJoinSound()
} catch (e) { } catch (e) {
if (isActive()) voiceState.set(VoiceState.Disconnected) if (isActive()) callState.set(CallState.Disconnected)
if (e instanceof AbortError) { if (e instanceof AbortError) {
clearReconnectSchedule() clearReconnectSchedule()
return return
@ -494,7 +256,7 @@ export const joinVoiceRoom = async (
export const leaveVoiceRoom = async () => { export const leaveVoiceRoom = async () => {
clearReconnectSchedule() clearReconnectSchedule()
const session = get(currentVoiceSession) const session = get(currentCallSession)
if (!session) return if (!session) return
const audio = new Audio("/leave-voice-room.mp3") const audio = new Audio("/leave-voice-room.mp3")
@ -502,7 +264,7 @@ export const leaveVoiceRoom = async () => {
if (session.cameraOn) { if (session.cameraOn) {
try { try {
await session.room.localParticipant.setCameraEnabled(false) await session.livekit.localParticipant.setCameraEnabled(false)
} catch { } catch {
pushToast({theme: "error", message: "Error turning off camera."}) pushToast({theme: "error", message: "Error turning off camera."})
} }
@ -510,46 +272,438 @@ export const leaveVoiceRoom = async () => {
if (session.screenShareOn) { if (session.screenShareOn) {
try { try {
await session.room.localParticipant.setScreenShareEnabled(false) await session.livekit.localParticipant.setScreenShareEnabled(false)
} catch { } catch {
pushToast({theme: "error", message: "Error turning off screen sharing."}) pushToast({theme: "error", message: "Error turning off screen sharing."})
} }
} }
// Always tear down this room's connection and listeners. // Always tear down this room's connection and listeners.
if (activeRoom === session.room) activeRoom = undefined teardownRoom(session.livekit)
session.room.removeAllListeners()
session.room.disconnect()
// Only reset shared UI state if this session is still current. A slow leave // Only reset shared UI state if this session is still current. A slow leave
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom) // that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
// must not clobber the freshly-joined session when it finally completes. // must not clobber the freshly-joined session when it finally completes.
if (get(currentVoiceSession) === session) { if (get(currentCallSession) === session) {
voiceState.set(VoiceState.Disconnected) callState.set(CallState.Disconnected)
videoPrimaryTileKey.set(undefined) callMicMuted.set(true)
voiceMicMuted.set(true) currentCallSession.set(undefined)
currentVoiceSession.set(undefined)
resetVideoCallLayout()
speakingParticipants.set([]) speakingParticipants.set([])
participantMediaState.set(new Map()) participantMediaState.set(new Map())
} }
} }
export const cancelJoinVoiceRoom = () => {
clearReconnectSchedule()
abortJoinVoiceRoom()
}
export const toggleMute = async () => { export const toggleMute = async () => {
const session = get(currentVoiceSession) const session = get(currentCallSession)
if (!session) return if (!session) return
voiceMicMuted.update(not) callMicMuted.update(not)
if (get(voiceMicMuted)) {
// Disable and re-enable microphone to trigger permission prompt const muted = get(callMicMuted)
session.room.localParticipant.setMicrophoneEnabled(false) try {
await session.livekit.localParticipant.setMicrophoneEnabled(!muted)
} catch {
callMicMuted.set(!muted)
pushToast({
theme: "error",
message: muted ? "Could not mute microphone" : "Could not access microphone",
})
}
}
export const toggleCamera = async () => {
const session = get(currentCallSession)
if (!session) return
const cameraOn = !session.cameraOn
try {
await session.livekit.localParticipant.setCameraEnabled(cameraOn)
currentCallSession.update(s => s && {...s, cameraOn})
} catch {
pushToast({
theme: "error",
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
})
}
}
export const toggleScreenShare = async () => {
const session = get(currentCallSession)
if (!session) return
const screenShareOn = !session.screenShareOn
try {
await session.livekit.localParticipant.setScreenShareEnabled(screenShareOn)
currentCallSession.update(s => s && {...s, screenShareOn})
} catch {
pushToast({
theme: "error",
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
})
}
}
export const switchCallActiveDevice = async (
kind: DeviceKind,
targetDeviceId: string,
): Promise<void> => {
const session = get(currentCallSession)
if (!session) return
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
try {
await session.livekit.switchActiveDevice(kind, id)
} catch {
let label: string
switch (kind) {
case DeviceKind.AudioInput:
label = "microphone"
break
case DeviceKind.AudioOutput:
label = "speaker"
break
case DeviceKind.VideoInput:
label = "camera"
break
}
pushToast({theme: "error", message: `Error changing ${label}`})
}
}
export const resetVideoCallLayout = () => {
videoCallViewportSync.previousLayout = undefined
videoCallLayout.set(VideoCallLayout.Chat)
}
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
export const loadCallParticipants = (url: string, h: string) =>
network.get().load({
relays: [url],
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
})
export const deriveCallParticipants = (url: string, h: string) =>
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
derived(
[
participantMediaState,
callTargetRoom,
deriveDeduplicated(
deriveEventsForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
events => first(sortEventsDesc(events)),
),
],
([$participantMediaState, $callTargetRoom, $publishedParticipantList]) => {
const inCall = $participantMediaState.size > 0 && $callTargetRoom?.id === makeRoomKey(url, h)
const identities = inCall
? [...$participantMediaState.keys()]
: tagValues(tagSpec("participant"), $publishedParticipantList?.tags ?? [])
return uniqBy(
(p: CallParticipant) => participantKey(p),
identities.map(participantFromLiveKitIdentity),
)
},
)
export const pubkeyFromLiveKitIdentity = (liveKitIdentity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(liveKitIdentity.slice(0, 64)) ? liveKitIdentity.slice(0, 64) : undefined
export const participantFromLiveKitIdentity = (liveKitIdentity: string): CallParticipant => {
const pk = pubkeyFromLiveKitIdentity(liveKitIdentity)
return pk ? {pubkey: pk, liveKitIdentity} : {liveKitIdentity}
}
export const participantKey = (p: CallParticipant) => p.pubkey ?? p.liveKitIdentity
// The room whose events are allowed to mutate shared state. Abandoned rooms
// (after switching calls or an engine reconnect give-up) must not clobber it.
let activeRoom: LiveKitRoom | undefined
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
let reconnectAttempt = 0
let joinAbortController: AbortController | undefined
let hadCallSession = false
currentCallSession.subscribe(session => {
if (session) {
hadCallSession = true
return
}
if (!hadCallSession) return
hadCallSession = false
videoPrimaryTileKey.set(undefined)
resetVideoCallLayout()
})
const teardownRoom = (livekit: LiveKitRoom) => {
if (activeRoom === livekit) activeRoom = undefined
// Dropping the listeners keeps TrackUnsubscribed from removing the hidden
// audio elements onTrackSubscribed appended, so detach them here instead.
for (const participant of livekit.remoteParticipants.values()) {
for (const publication of participant.audioTrackPublications.values()) {
publication.track?.detach().forEach(el => el.remove())
}
}
livekit.removeAllListeners()
livekit.disconnect()
}
const countLiveVisualFeeds = (session: CallSession): number => {
const livekit = session.livekit
let n = 0
const lp = livekit.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of livekit.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track && !pub.isMuted) n += 1
}
}
return n
}
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
muted: !participant.isMicrophoneEnabled,
cameraOn: participant.isCameraEnabled,
})
const deleteParticipant = (liveKitIdentity: string) => {
participantMediaState.update(m => new Map(reject(nthEq(0, liveKitIdentity), [...m])))
}
const syncParticipantMedia = (participant: Participant) => {
const state = participantMediaFrom(participant)
participantMediaState.update(m => {
const prev = m.get(participant.identity)
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
const next = new Map(m)
next.set(participant.identity, state)
return next
})
}
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
const resyncAfterReconnect = (livekit: LiveKitRoom) => {
if (livekit !== activeRoom) return
const next = new Map<string, ParticipantMediaState>()
for (const p of [livekit.localParticipant, ...livekit.remoteParticipants.values()]) {
next.set(p.identity, participantMediaFrom(p))
}
participantMediaState.set(next)
const session = get(currentCallSession)
if (!session) return
const {localParticipant} = livekit
callMicMuted.set(!localParticipant.isMicrophoneEnabled)
currentCallSession.set({
...session,
cameraOn: localParticipant.isCameraEnabled,
screenShareOn: localParticipant.isScreenShareEnabled,
})
triggerVideoTrackRevision()
}
const fetchLivekitToken = async (
url: string,
roomId: string,
signal?: AbortSignal,
): Promise<{server_url: string; participant_token: string}> => {
const endpoint = getLivekitEndpoint(url, roomId)
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
const template = await makeHttpAuth(endpoint, "GET")
const signedEvent = await user.get().signer.sign(template)
const authHeader = makeHttpAuthHeader(signedEvent)
const response = await fetch(endpoint, {
headers: {Authorization: authHeader},
signal,
})
if (!response.ok) {
const text = await response.text()
throw new Error(`Token request failed (${response.status}): ${text}`)
}
return response.json()
}
const setUpMicrophone = async (
startMuted: boolean,
preferredMicId: string | undefined,
participant: LocalParticipant,
signal?: AbortSignal,
settleSignal?: AbortSignal,
): Promise<boolean> => {
if (startMuted) {
return true
}
let muted = true
let capture: AudioCaptureOptions | undefined = undefined
if (preferredMicId) {
capture = {deviceId: preferredMicId}
}
try {
await Promise.race([
participant.setMicrophoneEnabled(true, capture),
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
whenAborted(signal),
])
muted = false
} catch (e) {
// Timeout or microphone rejection: join muted, the call is still usable. A
// genuine abort is surfaced to the caller so it can tear down the room.
if (e instanceof AbortError) throw e
if (!(e instanceof TimeoutError)) {
pushToast({theme: "error", message: "Could not access microphone"})
}
}
return muted
}
const clearReconnectSchedule = () => {
if (reconnectTimeout !== undefined) {
clearTimeout(reconnectTimeout)
reconnectTimeout = undefined
}
reconnectAttempt = 0
}
const attemptReconnect = async () => {
const target = get(callTargetRoom)
if (!target) return
try {
await joinVoiceRoom(target.url, target.h)
} catch {
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
clearReconnectSchedule()
return
}
scheduleReconnect()
}
}
const scheduleReconnect = () => {
if (reconnectTimeout !== undefined) return
if (!get(callTargetRoom)) return
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
return return
} }
try { const delay = RECONNECT_DELAYS[reconnectAttempt]!
await session.room.localParticipant.setMicrophoneEnabled(true) reconnectAttempt++
} catch (e) { reconnectTimeout = setTimeout(() => {
voiceMicMuted.set(true) reconnectTimeout = undefined
pushToast({theme: "error", message: "Could not access microphone"}) void attemptReconnect()
}, delay)
}
const makeOnRoomReconnected = (livekit: LiveKitRoom) => () => {
if (livekit !== activeRoom) return
resyncAfterReconnect(livekit)
}
const makeOnRoomDisconnected = (livekit: LiveKitRoom) => (reason?: DisconnectReason) => {
// Ignore disconnects from rooms that are no longer the active session.
if (livekit !== activeRoom) return
// Livekit unsubscribes remote tracks before emitting Disconnected, so
// onTrackUnsubscribed has already removed their audio elements by now.
activeRoom = undefined
livekit.removeAllListeners()
callMicMuted.set(true)
currentCallSession.set(undefined)
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
callState.set(CallState.Disconnected)
if (reason === DisconnectReason.JOIN_FAILURE) {
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
} else if (get(callTargetRoom)) {
clearReconnectSchedule()
scheduleReconnect()
} else {
pushToast({theme: "error", message: "Voice connection lost."})
}
}
speakingParticipants.set([])
participantMediaState.set(new Map())
}
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
syncParticipantMedia(participant)
}
const onTrackSubscribed = (track: Track) => {
if (track.kind === Track.Kind.Audio) {
const element = track.attach()
element.style.display = "none"
document.body.appendChild(element)
element.play().catch(() => {})
} else if (track.kind === Track.Kind.Video) {
triggerVideoTrackRevision()
} }
} }
const onTrackUnsubscribed = (track: Track) => {
track.detach().forEach(el => el.remove())
if (track.kind === Track.Kind.Video) {
triggerVideoTrackRevision()
}
}
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
}
const playJoinSound = () => {
const audio = new Audio("/join-voice-room.mp3")
audio.play().catch(() => {})
}
const onParticipantConnected = (participant: Participant) => {
syncParticipantMedia(participant)
playJoinSound()
}
const onParticipantDisconnected = (participant: {identity: string}) => {
deleteParticipant(participant.identity)
}
const onLocalTrackUnpublished = (
publication: LocalTrackPublication,
participant: LocalParticipant,
) => {
if (publication.source !== Track.Source.ScreenShare) return
const session = get(currentCallSession)
if (!session || participant.identity !== session.livekit.localParticipant.identity) return
if (!session.screenShareOn) return
currentCallSession.set({...session, screenShareOn: false})
}
const abortJoinVoiceRoom = () => {
joinAbortController?.abort()
}

View file

@ -1,75 +0,0 @@
import {Room as LiveKitRoom} from "livekit-client"
import {derived, writable} from "svelte/store"
import type {Room} from "@app/groups"
export type VoiceSession = {
url: string
h: string
room: LiveKitRoom
cameraOn: boolean
screenShareOn: boolean
}
/** Mic mute state is separate so toggling it does not re-render video tiles. */
export const voiceMicMuted = writable(true)
export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export type ParticipantMediaState = {
muted: boolean
cameraOn: boolean
}
export enum VoiceState {
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
const pk = pubkeyFromLiveKitIdentity(identity)
return pk ? {pubkey: pk, identity} : {identity}
}
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([])
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
export const mediaStateByIdentity = derived(
[participantMediaState, currentVoiceSession, voiceMicMuted],
([$media, $session, $micMuted]) =>
(identity: string) => {
if ($session?.room.localParticipant.identity === identity) {
return {muted: $micMuted, cameraOn: $session.cameraOn}
}
return $media.get(identity) ?? {muted: true, cameraOn: false}
},
)
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
export const isLocalSpeaking = derived(
[currentVoiceSession, speakingParticipants],
([$session, $speaking]) => {
if (!$session?.room) return false
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
return $speaking.some(sp => participantKey(sp) === participantKey(local))
},
)

View file

@ -1,99 +0,0 @@
import {Track} from "livekit-client"
import {MediaQuery} from "svelte/reactivity"
import {derived, get, writable} from "svelte/store"
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
import {pushToast} from "@app/toast"
export enum VideoCallLayout {
Chat = "chat",
Video = "video",
Split = "split",
}
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
export enum ViewportSize {
Desktop = "desktop",
Mobile = "mobile",
}
export const videoCallViewportSync = {
previousLayout: undefined as ViewportSize | undefined,
}
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
export const resetVideoCallLayout = () => {
videoCallViewportSync.previousLayout = undefined
videoCallLayout.set(VideoCallLayout.Chat)
}
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
export const toggleVideoPrimaryTile = (key: string) => {
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
}
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
const countLiveVisualFeeds = (session: VoiceSession): number => {
const room = session.room
let n = 0
const lp = room.localParticipant
if (session.cameraOn) {
const pub = lp.getTrackPublication(Track.Source.Camera)
if (pub?.track) n += 1
}
if (session.screenShareOn) {
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
if (pub?.track) n += 1
}
for (const rp of room.remoteParticipants.values()) {
for (const source of VISUAL_SOURCES) {
const pub = rp.getTrackPublication(source)
if (pub?.isSubscribed && pub.track) n += 1
}
}
return n
}
export const triggerVideoFeedCount = () => {
currentVoiceSession.update(s => (s ? {...s} : s))
}
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
if ($state !== VoiceState.Connected || !$session) return 0
return countLiveVisualFeeds($session)
})
export const toggleCamera = async () => {
const session = get(currentVoiceSession)
if (!session) return
const cameraOn = !session.cameraOn
try {
await session.room.localParticipant.setCameraEnabled(cameraOn)
currentVoiceSession.set({...session, cameraOn})
} catch {
pushToast({
theme: "error",
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
})
}
}
export const toggleScreenShare = async () => {
const session = get(currentVoiceSession)
if (!session) return
const screenShareOn = !session.screenShareOn
try {
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
currentVoiceSession.set({...session, screenShareOn})
} catch {
pushToast({
theme: "error",
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
})
}
}

View file

@ -1,12 +1,14 @@
import {DELETE, PROFILE, getPubkeyTagValues} from "@welshman/util" import {derived, readable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util"
import {append, call, on, reject, remove, sort, sortBy, spec, uniq, uniqBy} from "@welshman/lib" import {append, call, on, reject, remove, sort, sortBy, spec, uniq, uniqBy} from "@welshman/lib"
import type {Override} from "@welshman/lib" import type {Override} from "@welshman/lib"
import {createSearch, displayProfileByPubkey, pubkey, repository} from "@welshman/app" import {DELETE, PROFILE, hexTags, tagValues} from "@welshman/util"
import {derived, readable} from "svelte/store" import type {TrustedEvent} from "@welshman/util"
import {DM_KINDS} from "@app/content"
import type {RepositoryUpdate} from "@welshman/net" import type {RepositoryUpdate} from "@welshman/net"
import {makeDeriveItem, throttled} from "@welshman/store" import {makeDeriveItem, throttled} from "@welshman/store"
import {createSearch} from "@welshman/app"
import {app, profiles, user} from "@app/core"
import {DM_KINDS} from "@app/content"
export type Chat = { export type Chat = {
id: string id: string
pubkeys: string[] pubkeys: string[]
@ -15,13 +17,13 @@ export type Chat = {
search_text: string search_text: string
} }
export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(pubkey.get()!, pubkeys))) export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(user.get().pubkey, pubkeys)))
export const getChatPubkeysFromEvent = (event: TrustedEvent) => export const getChatPubkeysFromEvent = (event: TrustedEvent) =>
getChatPubkeys(getPubkeyTagValues(event.tags).concat(event.pubkey)) getChatPubkeys(tagValues(hexTags("p"), event.tags).concat(event.pubkey))
export const makeChatId = (pubkeys: string[]) => { export const makeChatId = (pubkeys: string[]) => {
const userPubkey = pubkey.get()! const userPubkey = user.get().pubkey
const otherPubkeys = remove(userPubkey, uniq(pubkeys)) const otherPubkeys = remove(userPubkey, uniq(pubkeys))
const visiblePubkeys = otherPubkeys.length === 0 ? [userPubkey] : otherPubkeys const visiblePubkeys = otherPubkeys.length === 0 ? [userPubkey] : otherPubkeys
@ -34,11 +36,13 @@ export const chatsById = call(() => {
const chatsById = new Map<string, Chat>() const chatsById = new Map<string, Chat>()
const chatsByPubkey = new Map<string, string[]>() const chatsByPubkey = new Map<string, string[]>()
const displayProfile = (pubkey: string) => profiles.get().display(pubkey).get()
const addSearchText = (chat: Override<Chat, {search_text?: string}>) => { const addSearchText = (chat: Override<Chat, {search_text?: string}>) => {
chat.search_text = chat.search_text =
chat.pubkeys.length === 1 chat.pubkeys.length === 1
? displayProfileByPubkey(chat.pubkeys[0]) + " note to self" ? displayProfile(chat.pubkeys[0]) + " note to self"
: remove(pubkey.get()!, chat.pubkeys).map(displayProfileByPubkey).join(" ") : remove(user.get().pubkey, chat.pubkeys).map(displayProfile).join(" ")
return chat as Chat return chat as Chat
} }
@ -91,7 +95,7 @@ export const chatsById = call(() => {
let dirty = false let dirty = false
for (const id of removed) { for (const id of removed) {
const event = repository.getEvent(id) const event = app.get().repository.getEvent(id)
if (event && DM_KINDS.includes(event.kind)) { if (event && DM_KINDS.includes(event.kind)) {
for (const chatId of chatsByPubkey.get(event.pubkey) || []) { for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
@ -110,10 +114,10 @@ export const chatsById = call(() => {
} }
} }
addEvents(repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}])) addEvents(app.get().repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
const unsubscribers = [ const unsubscribers = [
on(repository, "update", ({added, removed}: RepositoryUpdate) => { on(app.get().repository, "update", ({added, removed}: RepositoryUpdate) => {
// Do this async so that profiles are populated // Do this async so that profiles are populated
setTimeout(() => { setTimeout(() => {
addEvents(added) addEvents(added)

View file

@ -1,16 +0,0 @@
import type {TrustedEvent} from "@welshman/util"
import {COMMENT, makeEvent} from "@welshman/util"
import {publishThunk, tagEventForComment} from "@welshman/app"
export type CommentParams = {
event: TrustedEvent
content: string
tags?: string[][]
url?: string
}
export const makeComment = ({url, event, content, tags = []}: CommentParams) =>
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event, url)]})
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment({url: relays[0], ...params}), relays})

View file

@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {pubkey} from "@welshman/app"
import Dialog from "@lib/components/Dialog.svelte" import Dialog from "@lib/components/Dialog.svelte"
import Landing from "@app/components/Landing.svelte" import Landing from "@app/components/Landing.svelte"
import Toast from "@app/components/Toast.svelte" import Toast from "@app/components/Toast.svelte"
import PrimaryNav from "@app/components/PrimaryNav.svelte" import PrimaryNav from "@app/components/PrimaryNav.svelte"
import {app} from "@app/core"
import {modal} from "@app/modal" import {modal} from "@app/modal"
interface Props { type Props = {
children: Snippet children: Snippet
} }
@ -15,7 +15,7 @@
</script> </script>
<div class="flex h-screen overflow-hidden"> <div class="flex h-screen overflow-hidden">
{#if $pubkey} {#if $app.user?.pubkey}
<PrimaryNav> <PrimaryNav>
{@render children?.()} {@render children?.()}
</PrimaryNav> </PrimaryNav>

View file

@ -0,0 +1,44 @@
<script lang="ts">
import {onMount} from "svelte"
import type {PinboardReader} from "@welshman/domain"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {shareEventToChat} from "@app/share"
import {pushModal} from "@app/modal"
type Props = {
url: string
board: PinboardReader
onClick: () => void
}
const {url, board, onClick}: Props = $props()
const showInfo = () => pushModal(EventInfo, {url, event: board.event})
const share = () => shareEventToChat(url, "Shelf", board.event)
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<li>
<Button onclick={showInfo}>
<Icon icon={Code2} />
Shelf details
</Button>
</li>
<li>
<Button onclick={share}>
<Icon icon={ShareCircle} />
Share to chat
</Button>
</li>
</ul>

View file

@ -34,7 +34,7 @@
<p>Bunker Link*</p> <p>Bunker Link*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-group flex w-full items-center gap-2">
<Icon icon={CpuBolt} /> <Icon icon={CpuBolt} />
<input disabled={$loading} bind:value={$bunker} class="grow" placeholder="bunker://" /> <input disabled={$loading} bind:value={$bunker} class="grow" placeholder="bunker://" />
<Button onclick={toggleScanner}> <Button onclick={toggleScanner}>
@ -45,7 +45,8 @@
{#snippet info()} {#snippet info()}
<p> <p>
A login link provided by a nostr signing app. A login link provided by a nostr signing app.
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button> <Button class="button button-link" onclick={() => pushModal(InfoBunker)}
>What is a bunker link?</Button>
</p> </p>
{/snippet} {/snippet}
</Field> </Field>

View file

@ -1,20 +1,18 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue, getAddress} from "@welshman/util" import {getAddress, tagSpec, tagValue} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Pen2 from "@assets/icons/pen-2.svg?dataurl" import Pen2 from "@assets/icons/pen-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte" import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import {publishDelete} from "@app/deletes" import {user} from "@app/core"
import {publishReaction} from "@app/reactions"
import {canEnforceNip70} from "@app/relays"
import {makeCalendarPath, makeSpacePath} from "@app/routes" import {makeCalendarPath, makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@ -27,33 +25,30 @@
const {url, event, showRoom, showActivity}: Props = $props() const {url, event, showRoom, showActivity}: Props = $props()
const h = getTagValue("h", event.tags) const h = tagValue(tagSpec("h"), event.tags)
const path = makeCalendarPath(url, getAddress(event)) const path = makeCalendarPath(url, getAddress(event))
const shouldProtect = canEnforceNip70(url)
const editEvent = () => pushModal(CalendarEventEdit, {url, event}) const editEvent = () => pushModal(CalendarEventEdit, {url, event})
const deleteReaction = async (event: TrustedEvent) => const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) => const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script> </script>
<div class="flex grow flex-wrap justify-end gap-2"> <div class="flex grow flex-wrap justify-end gap-2">
{#if h && showRoom} {#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full"> <Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} /> Posted in #<RoomName {h} {url} />
</Link> </Link>
{/if} {/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip tip-left" />
<ThunkStatusOrDeleted {event} /> <ThunkStatusOrDeleted {event} />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
{/if} {/if}
<EventActions {url} {event} noun="Event"> <EventActions {url} {event} noun="Event">
{#snippet customActions()} {#snippet customActions()}
{#if event.pubkey === $pubkey} {#if event.pubkey === $user.pubkey}
<li> <li>
<Button onclick={editEvent}> <Button onclick={editEvent}>
<Icon size={4} icon={Pen2} /> <Icon size={4} icon={Pen2} />

View file

@ -17,7 +17,7 @@
{#snippet header()} {#snippet header()}
<ModalHeader> <ModalHeader>
<ModalTitle>Create an Event</ModalTitle> <ModalTitle>Create an Event</ModalTitle>
<ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle> <ModalSubtitle>Invite other room members to events online or in real life.</ModalSubtitle>
</ModalHeader> </ModalHeader>
{/snippet} {/snippet}
</CalendarEventForm> </CalendarEventForm>

View file

@ -1,20 +1,24 @@
<script lang="ts"> <script lang="ts">
import {fromPairs, LOCALE, secondsToDate} from "@welshman/lib" import {LOCALE, secondsToDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {TimeEvent} from "@welshman/domain"
import {reader} from "@app/core"
type Props = { type Props = {
event: TrustedEvent event: TrustedEvent
} }
const {event}: Props = $props() const {event}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const start = $derived(parseInt(meta.start)) const timeEvent = reader(TimeEvent)(event)
const start = timeEvent.start()
</script> </script>
{#if !isNaN(start)} {#if start}
{@const startDate = secondsToDate(start)} {@const startDate = secondsToDate(start)}
<div <div
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex"> class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-2xl bg-surface-more p-2 sm:flex">
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong> <strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span> <span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
<span class="text-xs opacity-75" <span class="text-xs opacity-75"

View file

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util" import {TimeEvent} from "@welshman/domain"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import {reader} from "@app/core"
import CalendarEventForm from "@app/components/CalendarEventForm.svelte" import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
type Props = { type Props = {
@ -13,21 +14,27 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const initialValues = { const timeEvent = reader(TimeEvent)(event)
d: getTagValue("d", event.tags)!,
title: getTagValue("title", event.tags)!, const initialValues = $derived(
location: getTagValue("location", event.tags)!, timeEvent && {
start: parseInt(getTagValue("start", event.tags)!), d: timeEvent.identifier() ?? "",
end: parseInt(getTagValue("end", event.tags)!), title: timeEvent.title() ?? "",
content: event.content, location: timeEvent.location() ?? "",
} start: timeEvent.start(),
end: timeEvent.end(),
content: timeEvent.content(),
},
)
</script> </script>
<CalendarEventForm {url} {initialValues}> {#if initialValues}
{#snippet header()} <CalendarEventForm {url} {initialValues}>
<ModalHeader> {#snippet header()}
<ModalTitle>Edit this Event</ModalTitle> <ModalHeader>
<ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle> <ModalTitle>Edit this Event</ModalTitle>
</ModalHeader> <ModalSubtitle>Invite other room members to events online or in real life.</ModalSubtitle>
{/snippet} </ModalHeader>
</CalendarEventForm> {/snippet}
</CalendarEventForm>
{/if}

View file

@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib" import {randomId} from "@welshman/lib"
import {makeEvent, EVENT_TIME} from "@welshman/util" import {publish} from "@welshman/app"
import {publishThunk, waitForThunkError} from "@welshman/app" import {TimeEvent} from "@welshman/domain"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {daysBetween} from "@lib/util"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl" import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import MapPoint from "@assets/icons/map-point.svg?dataurl" import MapPoint from "@assets/icons/map-point.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@ -16,13 +15,13 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte" import DateTimeRangeInput from "@lib/components/DateTimeRangeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED, publishRoomQuote} from "@app/groups" import {publishRoomQuote} from "@app/rooms"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts" import {DraftKey} from "@app/drafts"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {canEnforceNip70} from "@app/relays" import {command, relays, writer} from "@app/core"
type Values = { type Values = {
d: string d: string
@ -49,7 +48,7 @@
initialValues = draftKey.get() initialValues = draftKey.get()
} }
const shouldProtect = canEnforceNip70(url) const shouldProtect = $relays.hasNip(url, 70)
const uploading = writable(false) const uploading = writable(false)
@ -83,32 +82,29 @@
const ed = await editor const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim() const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = [
["d", d],
["title", title],
["location", location],
["start", start.toString()],
["end", end.toString()],
...daysBetween(start, end).map(D => ["D", D.toString()]),
...ed.storage.nostr.getEditorTags(),
]
loading = true loading = true
try { try {
const protect = await shouldProtect const protect = await shouldProtect
const eventWriter = writer(TimeEvent)
if (protect) { .setIdentifier(d)
tags.push(PROTECTED) .setTitle(title)
} .setLocation(location)
.setStart(start)
.setEnd(end)
.setContent(content)
.setProtected(protect)
.addTags(...ed.storage.nostr.getEditorTags())
if (h) { if (h) {
tags.push(["h", h]) eventWriter.setRoom(url, h)
} else {
eventWriter.forceRelays(url)
} }
const event = makeEvent(EVENT_TIME, {content, tags}) const calendarThunk = await command(eventWriter).then(publish)
const calendarThunk = publishThunk({event, relays: [url]}) const error = await calendarThunk.waitForError()
const error = await waitForThunkError(calendarThunk)
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
@ -134,7 +130,6 @@
let location = $state(initialValues?.location ?? "") let location = $state(initialValues?.location ?? "")
let start: number | undefined = $state(initialValues?.start) let start: number | undefined = $state(initialValues?.start)
let end: number | undefined = $state(initialValues?.end) let end: number | undefined = $state(initialValues?.end)
let endDirty = $state(Boolean(initialValues?.end))
let content = $state(initialValues?.content ?? "") let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => { const onChange = (json: object) => {
@ -146,14 +141,6 @@
$effect(() => { $effect(() => {
draftKey.set({d, title, location, start, end, content}) draftKey.set({d, title, location, start, end, content})
}) })
$effect(() => {
if (!endDirty && start) {
end = start + HOUR
} else if (end) {
endDirty = true
}
})
</script> </script>
<Modal tag="form" novalidate onsubmit={preventDefault(submit)}> <Modal tag="form" novalidate onsubmit={preventDefault(submit)}>
@ -164,7 +151,7 @@
<p>Title*</p> <p>Title*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input flex w-full items-center gap-2">
<input bind:value={title} class="grow" type="text" /> <input bind:value={title} class="grow" type="text" />
</label> </label>
{/snippet} {/snippet}
@ -174,18 +161,17 @@
<p>Summary</p> <p>Summary</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div <div class="relative z-feature flex gap-2">
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor grow overflow-hidden"> <div class="input-editor grow overflow-hidden">
<EditorContent {editor} /> <EditorContent {editor} />
</div> </div>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="center btn tooltip" class="button button-neutral button-input tip"
onclick={selectFiles} onclick={selectFiles}
disabled={loading}> disabled={loading}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <Spinner size="xs" />
{:else} {:else}
<Icon icon={GallerySend} /> <Icon icon={GallerySend} />
{/if} {/if}
@ -195,18 +181,10 @@
</Field> </Field>
<Field> <Field>
{#snippet label()} {#snippet label()}
Start* <p>Date and time*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<DateTimeInput bind:value={start} /> <DateTimeRangeInput bind:start bind:end />
{/snippet}
</Field>
<Field>
{#snippet label()}
End*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={end} />
{/snippet} {/snippet}
</Field> </Field>
<Field> <Field>
@ -214,7 +192,7 @@
<p>Location (optional)</p> <p>Location (optional)</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input flex w-full items-center gap-2">
<Icon icon={MapPoint} /> <Icon icon={MapPoint} />
<input bind:value={location} class="grow" type="text" /> <input bind:value={location} class="grow" type="text" />
</label> </label>
@ -222,11 +200,11 @@
</Field> </Field>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}> <Button class="button button-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}> <Button class="button button-primary" type="submit" disabled={$uploading || loading}>
<Spinner loading={$uploading || loading}>Save Event</Spinner> <Spinner loading={$uploading || loading}>Save Event</Spinner>
</Button> </Button>
</ModalFooter> </ModalFooter>

View file

@ -1,30 +1,30 @@
<script lang="ts"> <script lang="ts">
import { import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/lib"
fromPairs,
formatTimestamp,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import {TimeEvent} from "@welshman/domain"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl" import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {reader} from "@app/core"
type Props = { type Props = {
event: TrustedEvent event: TrustedEvent
} }
const {event}: Props = $props() const {event}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const start = $derived(parseInt(meta.start)) const timeEvent = reader(TimeEvent)(event)
const end = $derived(parseInt(meta.end))
// NIP-52 settled on `title`, but events from before that still carry `name`.
const title = $derived(timeEvent.title() ?? tagValue(tagSpec("name"), event.tags))
const start = timeEvent.start()
const end = timeEvent.end()
</script> </script>
<div class="flex flex-col justify-between gap-1"> <div class="flex flex-col justify-between gap-1">
<p class="text-lg">{meta.title || meta.name}</p> <p class="text-lg">{title}</p>
{#if !isNaN(start) && !isNaN(end)} {#if start && end}
{@const startDateDisplay = formatTimestampAsDate(start)} {@const isSingleDay = formatTimestampAsDate(start) === formatTimestampAsDate(end)}
{@const endDateDisplay = formatTimestampAsDate(end)}
{@const isSingleDay = startDateDisplay === endDateDisplay}
<div class="flex flex-wrap gap-2 text-xs"> <div class="flex flex-wrap gap-2 text-xs">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon icon={ClockCircle} size={4} /> <Icon icon={ClockCircle} size={4} />

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getTagValue, getAddress} from "@welshman/util" import {getAddress, tagValue, tagSpec} from "@welshman/util"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte" import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte" import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
@ -15,10 +15,12 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const h = getTagValue("h", event.tags) const h = tagValue(tagSpec("h"), event.tags)
</script> </script>
<Link class="cv col-3 card2 w-full cursor-pointer" href={makeCalendarPath(url, getAddress(event))}> <Link
class="cv flex flex-col gap-3 card card-interactive w-full"
href={makeCalendarPath(url, getAddress(event))}>
<CalendarEventHeader {event} /> <CalendarEventHeader {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75"> <span class="whitespace-nowrap py-1 text-sm opacity-75">

View file

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import {fromPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {TimeEvent} from "@welshman/domain"
import UserCircle from "@assets/icons/user-circle.svg?dataurl" import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import MapPoint from "@assets/icons/map-point.svg?dataurl" import MapPoint from "@assets/icons/map-point.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {reader} from "@app/core"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
type Props = { type Props = {
@ -12,7 +13,10 @@
} }
const {event, url}: Props = $props() const {event, url}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const timeEvent = reader(TimeEvent)(event)
const location = timeEvent.location()
</script> </script>
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75"> <div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
@ -20,10 +24,10 @@
<Icon icon={UserCircle} size={4} /> <Icon icon={UserCircle} size={4} />
Posted by <ProfileLink pubkey={event.pubkey} {url} /> Posted by <ProfileLink pubkey={event.pubkey} {url} />
</span> </span>
{#if meta.location} {#if location}
<span class="flex items-start gap-1"> <span class="flex items-start gap-1">
<Icon icon={MapPoint} class="mt-[2px]" size={4} /> <Icon icon={MapPoint} class="mt-[2px]" size={4} />
<span class="wrap-break-word">{meta.location}</span> <span class="wrap-break-word">{location}</span>
</span> </span>
{/if} {/if}
</div> </div>

View file

@ -18,28 +18,14 @@
formatTimestampAsDate, formatTimestampAsDate,
} from "@welshman/lib" } from "@welshman/lib"
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util" import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
import {makeEvent, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util"
import {parse, isLink} from "@welshman/content" import {parse, isLink} from "@welshman/content"
import { import {MessagingRelayLists, Thunks} from "@welshman/app"
makeEvent,
tagsFromIMeta,
getTags,
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
} from "@welshman/util"
import {
pubkey,
tagPubkey,
sendWrapped,
mergeThunks,
loadMessagingRelayList,
messagingRelayListsByPubkey,
} from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl" import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl" import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
@ -53,12 +39,12 @@
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte" import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte" import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import {app, deletes, user, wraps} from "@app/core"
import {userSettingsValues} from "@app/settings" import {userSettingsValues} from "@app/settings"
import {deriveChat, makeChatId} from "@app/chats" import {deriveChat, makeChatId} from "@app/chats"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {DraftKey} from "@app/drafts" import {DraftKey} from "@app/drafts"
import {makeDelete} from "@app/deletes" import {prependParent} from "@app/rooms"
import {prependParent} from "@app/groups"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
type Props = { type Props = {
@ -71,8 +57,9 @@
const chatId = makeChatId(pubkeys) const chatId = makeChatId(pubkeys)
const chat = deriveChat(chatId) const chat = deriveChat(chatId)
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`) const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`)
const others = remove($pubkey!, pubkeys) const others = remove($user.pubkey, pubkeys)
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk))) const messagingRelayLists = $app.use(MessagingRelayLists).index.$
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk)))
const showMembers = () => const showMembers = () =>
others.length === 1 others.length === 1
@ -96,28 +83,26 @@
const onSubmit = async (params: EventContent) => { const onSubmit = async (params: EventContent) => {
try { try {
const ptags = remove($pubkey!, pubkeys).map(tagPubkey) const ptags = others.map(pk => ["p", pk])
// Remove p tags since they result in forking the conversation // Remove p tags since they result in forking the conversation
params.tags = params.tags.filter(nthNe(0, "p")) params.tags = params.tags.filter(nthNe(0, "p"))
// Add our reply quote to content // Add our reply quote to content
params = prependParent(parent, params) params = await prependParent(parent, params)
if (eventToEdit) { if (eventToEdit) {
if (eventToEdit.content === params.content) { if (eventToEdit.content === params.content) {
return return
} }
await sendWrapped({ const command = await $deletes.deleteEvent(eventToEdit)
event: makeDelete({event: eventToEdit, protect: false}),
recipients: pubkeys, await $wraps.publish({event: command.event, recipients: pubkeys, pow: 16})
pow: 16,
})
} }
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags) const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta) const imetas = imetaTags.map(tag => tag.slice(1).map(entry => entry.split(" ")))
const templates: EventTemplate[] = [] const templates: EventTemplate[] = []
const buffer = [] const buffer = []
@ -142,11 +127,7 @@
if (isLink(p) && imeta) { if (isLink(p) && imeta) {
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags) addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
addTemplate( addTemplate(DIRECT_MESSAGE_FILE, p.value.url.toString(), imeta.filter(nthNe(0, "url")))
DIRECT_MESSAGE_FILE,
p.value.url.toString(),
imeta.slice(1).filter(nthNe(0, "url")),
)
} else { } else {
buffer.push(p.raw) buffer.push(p.raw)
} }
@ -158,7 +139,7 @@
// Sleep 1 second between each one to make sure timestamps are distinct // Sleep 1 second between each one to make sure timestamps are distinct
const thunks = await Promise.all( const thunks = await Promise.all(
Array.from(enumerate(templates)).map(([i, event]) => Array.from(enumerate(templates)).map(([i, event]) =>
sendWrapped({ $wraps.publish({
event, event,
recipients: pubkeys, recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i), delay: $userSettingsValues.send_delay + ms(i),
@ -171,7 +152,7 @@
timeout: 30_000, timeout: 30_000,
children: { children: {
component: ThunkToast, component: ThunkToast,
props: {thunk: mergeThunks(thunks)}, props: {thunk: $app.use(Thunks).merge(thunks)},
}, },
}) })
} finally { } finally {
@ -186,7 +167,7 @@
} }
const canEditEvent = (event: TrustedEvent) => const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $pubkey && event.pubkey === $user.pubkey &&
event.kind === DIRECT_MESSAGE && event.kind === DIRECT_MESSAGE &&
event.created_at >= ago(500, MINUTE) event.created_at >= ago(500, MINUTE)
@ -234,7 +215,7 @@
onMount(() => { onMount(() => {
for (const pubkey of others) { for (const pubkey of others) {
loadMessagingRelayList(pubkey) $app.use(MessagingRelayLists).load(pubkey)
} }
}) })
@ -249,15 +230,15 @@
<Icon icon={ArrowLeft} size={7} /> <Icon icon={ArrowLeft} size={7} />
</Button> </Button>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="ellipsize flex items-center gap-4 whitespace-nowrap"> <div class="truncate min-w-0 flex items-center gap-4 whitespace-nowrap">
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}> <Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
{#if others.length === 0} {#if others.length === 0}
<div class="row-2"> <div class="flex gap-2">
<ProfileCircle pubkey={$pubkey!} size={5} /> <ProfileCircle pubkey={$user.pubkey} size={5} />
<ProfileName pubkey={$pubkey!} /> <ProfileName pubkey={$user.pubkey} />
</div> </div>
{:else if others.length === 1} {:else if others.length === 1}
<div class="row-2"> <div class="flex gap-2">
<ProfileCircle pubkey={others[0]} size={5} /> <ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} /> <ProfileName pubkey={others[0]} />
</div> </div>
@ -282,11 +263,11 @@
</div> </div>
</PageBar> </PageBar>
<PageContent class="flex flex-col-reverse gap-2 py-2 !mb-0"> <div class="flex flex-col-reverse gap-4 py-2 bg-surface scroll-container h-screen overflow-y-auto">
{#if missingRelayLists.length > 0} {#if missingRelayLists.length > 0}
<div class="py-12"> <div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center"> <div class="card flex flex-col gap-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error"> <p class="flex gap-2 text-lg text-error">
<Icon icon={Danger} /> <Icon icon={Danger} />
Direct messages are not enabled Direct messages are not enabled
</p> </p>
@ -295,7 +276,7 @@
{#each missingRelayLists as pubkey (pubkey)} {#each missingRelayLists as pubkey (pubkey)}
<ProfileLink {pubkey} /> <ProfileLink {pubkey} />
{/each} {/each}
to enable direct messaging by opening this conversation in their app. to enable direct messaging by opening this conversation in their $app.
</p> </p>
</div> </div>
</div> </div>
@ -324,9 +305,9 @@
{@render info?.()} {@render info?.()}
</p> </p>
<div class="h-screen"></div> <div class="h-screen"></div>
</PageContent> </div>
<div class="chat__compose bg-base-200"> <div class="room__compose bg-surface">
<div> <div>
{#if parent} {#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" /> <ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />

View file

@ -8,6 +8,7 @@
import Plane from "@assets/icons/plane-2.svg?dataurl" import Plane from "@assets/icons/plane-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {type DraftKey} from "@app/drafts" import {type DraftKey} from "@app/drafts"
@ -113,11 +114,11 @@
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}> <form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200" class="button button-neutral button-square tip tip-right h-10 w-10 min-w-10 rounded-2xl transition-colors"
disabled={$uploading || disabled} disabled={$uploading || disabled}
onclick={uploadFiles}> onclick={uploadFiles}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <Spinner size="xs" />
{:else} {:else}
<Icon icon={GallerySend} /> <Icon icon={GallerySend} />
{/if} {/if}
@ -127,7 +128,7 @@
</div> </div>
<Button <Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send" data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full" class="button button-primary button-circle tip tip-left h-10 w-10 min-w-10"
disabled={$uploading || disabled} disabled={$uploading || disabled}
onclick={submit}> onclick={submit}>
<Icon icon={Plane} /> <Icon icon={Plane} />

View file

@ -12,7 +12,8 @@
</script> </script>
<div <div
class="relative flex h-8 items-center justify-between border-l-2 border-solid border-primary bg-base-300 px-2 pr-7 text-xs" class="bg-surface-more relative flex h-8 items-center justify-between border-l-2 border-solid px-2 pr-7 text-xs"
style="border-color: var(--primary)"
transition:slide> transition:slide>
<p class="text-primary">Editing message</p> <p class="text-primary">Editing message</p>
<Button onclick={clear} class="flex items-center"> <Button onclick={clear} class="flex items-center">

View file

@ -1,27 +1,28 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {displayProfileByPubkey} from "@welshman/app"
import {slide} from "@lib/transition" import {slide} from "@lib/transition"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte" import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import {profiles} from "@app/core"
const { type Props = {
verb,
event,
clear,
}: {
verb: string verb: string
event: TrustedEvent event: TrustedEvent
clear: () => void clear: () => void
} = $props() }
const {verb, event, clear}: Props = $props()
const display = $profiles.display(event.pubkey).$
</script> </script>
<div <div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8" class="bg-surface-more relative border-l-2 border-solid px-2 py-1 pr-8"
style="border-color: var(--primary)"
transition:slide> transition:slide>
<p class="text-xs text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p> <p class="text-primary text-xs">{verb} @{$display}</p>
{#key event.id} {#key event.id}
<NoteContentMinimal trimParent {event} /> <NoteContentMinimal trimParent {event} />
{/key} {/key}

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import {getRelaysFromList} from "@welshman/util"
import {waitForThunkError, setMessagingRelays, userRelayList, setRelays} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {RelayLists} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl" import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@ -12,6 +11,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {deriveUserItem, messagingRelayLists, relayLists} from "@app/core"
import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/env" import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/env"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@ -21,7 +21,7 @@
const {next}: Props = $props() const {next}: Props = $props()
let loading = $state(false) const userRelayList = deriveUserItem(RelayLists)
const back = () => history.back() const back = () => history.back()
@ -29,8 +29,14 @@
loading = true loading = true
try { try {
if (getRelaysFromList($userRelayList).length === 0) { const relayUrls = $userRelayList?.urls() ?? []
const error = await waitForThunkError(await setRelays(DEFAULT_RELAYS.map(r => ["r", r])))
if (relayUrls.length === 0) {
const command = await $relayLists.update(writer =>
writer.setReadUrls(DEFAULT_RELAYS).setWriteUrls(DEFAULT_RELAYS),
)
const error = await command.publish().waitForError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
@ -38,7 +44,8 @@
} }
} }
const error = await waitForThunkError(await setMessagingRelays(DEFAULT_MESSAGING_RELAYS)) const command = await $messagingRelayLists.setUrls(DEFAULT_MESSAGING_RELAYS)
const error = await command.publish().waitForError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
@ -50,6 +57,8 @@
loading = false loading = false
} }
} }
let loading = $state(false)
</script> </script>
<Modal tag="form" onsubmit={preventDefault(enable)}> <Modal tag="form" onsubmit={preventDefault(enable)}>
@ -60,11 +69,11 @@
<p>Direct messaging isn't currently enabled. Would you like to turn it on?</p> <p>Direct messaging isn't currently enabled. Would you like to turn it on?</p>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={loading}> <Button type="submit" class="button button-primary" disabled={loading}>
<Spinner {loading}>Enable direct messaging</Spinner> <Spinner {loading}>Enable direct messaging</Spinner>
<Icon icon={AltArrowRight} /> <Icon icon={AltArrowRight} />
</Button> </Button>

View file

@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import cx from "classnames"
import {page} from "$app/stores" import {page} from "$app/stores"
import {remove, uniq, formatTimestamp} from "@welshman/lib" import {remove, uniq, formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {pubkey, loadMessagingRelayList} from "@welshman/app"
import {fade} from "@lib/transition" import {fade} from "@lib/transition"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
@ -11,6 +11,7 @@
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {makeChatPath, goToChat} from "@app/routes" import {makeChatPath, goToChat} from "@app/routes"
import {notifications} from "@app/notifications" import {notifications} from "@app/notifications"
import {messagingRelayLists, user} from "@app/core"
interface Props { interface Props {
id: string id: string
@ -21,27 +22,31 @@
const {...props}: Props = $props() const {...props}: Props = $props()
const others = uniq(remove($pubkey!, props.pubkeys)) const others = uniq(remove($user.pubkey, props.pubkeys))
const active = $derived($page.params.chat === props.id) const active = $derived($page.params.chat === props.id)
const path = makeChatPath(props.pubkeys) const path = makeChatPath(props.pubkeys)
const openChat = () => goToChat(props.pubkeys) const openChat = () => goToChat(props.pubkeys)
onMount(() => { onMount(() => {
for (const pk of others) { for (const pk of others) {
loadMessagingRelayList(pk) $messagingRelayLists.load(pk)
} }
}) })
</script> </script>
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}> <Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
<div <div
class="cursor-pointer border-t border-solid border-base-100 px-3 py-2 transition-colors hover:bg-base-100 {props.class}" class={cx(
class:bg-base-100={active}> "cursor-pointer border-t border-solid px-3 py-2 transition-colors hover:bg-surface",
props.class,
{"bg-surface": active},
)}
style="border-color: var(--line)">
<div class="flex flex-col justify-start gap-1"> <div class="flex flex-col justify-start gap-1">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
{#if others.length === 0} {#if others.length === 0}
<ProfileCircle pubkey={$pubkey!} size={5} /> <ProfileCircle pubkey={$user.pubkey} size={5} />
Note to self Note to self
{:else if others.length === 1} {:else if others.length === 1}
<ProfileCircle pubkey={others[0]} size={5} /> <ProfileCircle pubkey={others[0]} size={5} />
@ -56,12 +61,12 @@
{/if} {/if}
</div> </div>
{#if !active && $notifications.has(path)} {#if !active && $notifications.has(path)}
<div class="h-2 w-2 rounded-full bg-primary" transition:fade></div> <div class="h-2 w-2 rounded-full bg-primary text-primary-content" transition:fade></div>
{/if} {/if}
</div> </div>
<p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm"> <p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
<span class="opacity-70"> <span class="opacity-70">
{#if props.messages[0].pubkey === $pubkey} {#if props.messages[0].pubkey === $user.pubkey}
You: You:
{/if} {/if}
</span> </span>

View file

@ -7,11 +7,13 @@
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
interface Props { type Props = {
pubkeys: string[] pubkeys: string[]
} }
const {pubkeys}: Props = $props() const {pubkeys}: Props = $props()
const back = () => history.back()
</script> </script>
<Modal> <Modal>
@ -21,13 +23,13 @@
</ModalHeader> </ModalHeader>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each pubkeys as pubkey (pubkey)} {#each pubkeys as pubkey (pubkey)}
<div class="card2 bg-alt"> <div class="card">
<Profile {pubkey} /> <Profile {pubkey} />
</div> </div>
{/each} {/each}
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button> <Button class="button button-primary" onclick={back}>Got it</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View file

@ -23,17 +23,17 @@
<Modal> <Modal>
<ModalBody> <ModalBody>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Button class="btn btn-neutral" onclick={markAsRead}> <Button class="button button-neutral" onclick={markAsRead}>
<Icon size={5} icon={Check} /> <Icon size={5} icon={Check} />
Mark all read Mark all read
</Button> </Button>
{#if $notificationSettings.messages} {#if $notificationSettings.messages}
<Button class="btn btn-neutral" onclick={disableAlerts}> <Button class="button button-neutral" onclick={disableAlerts}>
<Icon size={4} icon={BellOff} /> <Icon size={4} icon={BellOff} />
Disable alerts Disable alerts
</Button> </Button>
{:else} {:else}
<Button class="btn btn-neutral" onclick={enableAlerts}> <Button class="button button-neutral" onclick={enableAlerts}>
<Icon size={4} icon={Bell} /> <Icon size={4} icon={Bell} />
Enable alerts Enable alerts
</Button> </Button>

View file

@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import {type Instance} from "tippy.js" import {type Instance} from "tippy.js"
import {hash, formatTimestampAsTime} from "@welshman/lib" import {hash, formatTimestampAsTime} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, mergeThunks, pubkey, deriveProfileDisplay, sendWrapped} from "@welshman/app" import {Thunks} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl" import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@ -10,15 +11,15 @@
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import TapTarget from "@lib/components/TapTarget.svelte" import TapTarget from "@lib/components/TapTarget.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import {publishWrappedReaction, retractWrappedReaction} from "@app/reactions"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte" import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte" import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte" import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {app, profiles, user} from "@app/core"
import {colors} from "@app/theme" import {colors} from "@app/theme"
import {makeDelete} from "@app/deletes"
import {makeReaction} from "@app/reactions"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
interface Props { interface Props {
@ -32,23 +33,18 @@
const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props() const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props()
const isOwn = event.pubkey === $pubkey const isOwn = event.pubkey === $user.pubkey
const profileDisplay = deriveProfileDisplay(event.pubkey) const profileDisplay = $profiles.display(event.pubkey).$
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id)) const thunks = $app.use(Thunks).history
const thunk = $app.use(Thunks).merge($thunks.filter(t => t.event.id === event.id))
const [_, colorValue] = colors[hash(event.pubkey) % colors.length] const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
const reply = () => replyTo(event) const reply = () => replyTo(event)
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
const deleteReaction = (event: TrustedEvent) => const deleteReaction = (reaction: TrustedEvent) => retractWrappedReaction(reaction, pubkeys)
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys, pow: 16})
const createReaction = (template: EventContent) => const createReaction = (values: EventContent) => publishWrappedReaction(event, values, pubkeys)
sendWrapped({
event: makeReaction({event, protect: false, ...template}),
recipients: pubkeys,
pow: 16,
})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
@ -66,15 +62,10 @@
let popoverIsVisible = $state(false) let popoverIsVisible = $state(false)
</script> </script>
{#if thunk} <ThunkFailure showToastOnRetry {thunk} class="mt-1" />
<ThunkFailure showToastOnRetry {thunk} class="mt-1" />
{/if}
<div <div
data-event={event.id} data-event={event.id}
class="group chat flex items-center justify-end gap-1 px-2" class={cx("group flex items-center justify-end gap-1 px-2", {"flex-row-reverse": !isOwn})}>
class:chat-start={!isOwn}
class:flex-row-reverse={!isOwn}
class:chat-end={isOwn}>
{#if !isMobile} {#if !isMobile}
<Tippy <Tippy
bind:popover bind:popover
@ -100,16 +91,15 @@
</Tippy> </Tippy>
{/if} {/if}
<div class="flex min-w-0 flex-col" class:items-end={isOwn}> <div class="flex min-w-0 flex-col" class:items-end={isOwn}>
<TapTarget <TapTarget class={cx("chat-bubble", {"chat-bubble--user": isOwn})} onTap={showMobileMenu}>
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl min-w-[100px]"
onTap={showMobileMenu}>
{#if showPubkey} {#if showPubkey}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if !isOwn} {#if !isOwn}
<Button onclick={openProfile} class="flex items-center gap-1"> <Button onclick={openProfile} class="flex items-center gap-1">
<ProfileCircle <ProfileCircle
pubkey={event.pubkey} pubkey={event.pubkey}
class="border border-solid border-base-content" class="border border-solid"
style="border-color: var(--line)"
size={4} /> size={4} />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}"> <Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
@ -126,7 +116,7 @@
<Content showEntire {event} /> <Content showEntire {event} />
</div> </div>
</TapTarget> </TapTarget>
<div class="row-2 z-feature -mt-4 ml-4"> <div class="flex gap-2 z-feature -mt-4 ml-4">
<ReactionSummary {event} {deleteReaction} {createReaction} noTooltip /> <ReactionSummary {event} {deleteReaction} {createReaction} noTooltip />
</div> </div>
</div> </div>

View file

@ -1,27 +1,25 @@
<script lang="ts"> <script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import {makeReaction} from "@app/reactions" import {reactions, wraps} from "@app/core"
interface Props { type Props = {
event: TrustedEvent event: TrustedEvent
pubkeys: string[] pubkeys: string[]
} }
const {event, pubkeys}: Props = $props() const {event, pubkeys}: Props = $props()
const onEmoji = (emoji: NativeEmoji) => const onEmoji = async (emoji: NativeEmoji) => {
sendWrapped({ const reaction = await $reactions.react(event, emoji.unicode)
event: makeReaction({event, content: emoji.unicode, protect: false}),
recipients: pubkeys, return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
pow: 16, }
})
</script> </script>
<EmojiButton {onEmoji} class="btn join-item btn-xs"> <EmojiButton {onEmoji} class="button button-neutral button-xs join-item">
<Icon icon={SmileCircle} size={4} /> <Icon icon={SmileCircle} size={4} />
</EmojiButton> </EmojiButton>

View file

@ -19,19 +19,19 @@
} }
</script> </script>
<div class="join border border-solid border-neutral text-xs"> <div class="join text-xs">
<ChatMessageEmojiButton {event} {pubkeys} /> <ChatMessageEmojiButton {event} {pubkeys} />
{#if replyTo} {#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}> <Button class="button button-neutral button-xs join-item" onclick={reply}>
<Icon size={4} icon={Reply} /> <Icon size={4} icon={Reply} />
</Button> </Button>
{/if} {/if}
{#if edit} {#if edit}
<Button class="btn join-item btn-xs" onclick={onEdit}> <Button class="button button-neutral button-xs join-item" onclick={onEdit}>
<Icon size={4} icon={Pen} /> <Icon size={4} icon={Pen} />
</Button> </Button>
{/if} {/if}
<Button class="btn join-item btn-xs" onclick={showInfo}> <Button class="button button-neutral button-xs join-item" onclick={showInfo}>
<Icon size={4} icon={Code2} /> <Icon size={4} icon={Code2} />
</Button> </Button>
</div> </div>

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {sendWrapped} from "@welshman/app"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl" import Pen from "@assets/icons/pen.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl"
@ -13,7 +12,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte" import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import {makeReaction} from "@app/reactions" import {reactions, wraps} from "@app/core"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {clip} from "@app/toast" import {clip} from "@app/toast"
@ -26,14 +25,13 @@
const {event, pubkeys, reply, edit}: Props = $props() const {event, pubkeys, reply, edit}: Props = $props()
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => { const onEmoji = async (emoji: NativeEmoji) => {
history.back() history.back()
sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}), const reaction = await $reactions.react(event, emoji.unicode)
recipients: pubkeys,
pow: 16, return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
}) }
}).bind(undefined, event, pubkeys)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true}) const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
@ -58,25 +56,25 @@
<Modal> <Modal>
<ModalBody> <ModalBody>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Button class="btn btn-neutral" onclick={showInfo}> <Button class="button button-neutral" onclick={showInfo}>
<Icon size={4} icon={Code2} /> <Icon size={4} icon={Code2} />
Message Info Message Info
</Button> </Button>
<Button class="btn btn-neutral w-full" onclick={copyText}> <Button class="button button-neutral w-full" onclick={copyText}>
<Icon size={4} icon={Copy} /> <Icon size={4} icon={Copy} />
Copy Text Copy Text
</Button> </Button>
<Button class="btn btn-neutral w-full" onclick={sendReply}> <Button class="button button-neutral w-full" onclick={sendReply}>
<Icon size={4} icon={Reply} /> <Icon size={4} icon={Reply} />
Send Reply Send Reply
</Button> </Button>
{#if edit} {#if edit}
<Button class="btn btn-neutral w-full" onclick={sendEdit}> <Button class="button button-neutral w-full" onclick={sendEdit}>
<Icon size={4} icon={Pen} /> <Icon size={4} icon={Pen} />
Edit Message Edit Message
</Button> </Button>
{/if} {/if}
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}> <Button class="button button-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} /> <Icon size={4} icon={SmileCircle} />
Send Reaction Send Reaction
</Button> </Button>

View file

@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {onMount} from "svelte" import {onMount} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {tryCatch, uniq} from "@welshman/lib" import {uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util" import {decodePubkey} from "@lib/util"
import {loadMessagingRelayList} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@ -19,6 +17,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte" import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {goToChat} from "@app/routes" import {goToChat} from "@app/routes"
import {messagingRelayLists} from "@app/core"
const back = () => history.back() const back = () => history.back()
@ -34,27 +33,15 @@
let pubkeys: string[] = $state([]) let pubkeys: string[] = $state([])
$effect(() => { $effect(() => {
pubkeys.forEach(pubkey => loadMessagingRelayList(pubkey)) pubkeys.forEach(pubkey => $messagingRelayLists.load(pubkey))
}) })
onMount(() => { onMount(() => {
return term.subscribe(t => { return term.subscribe(t => {
if (t.match(/^[0-9a-f]{64}$/)) { const pubkey = decodePubkey(t)
addPubkey(t)
}
if (t.match(/^(nostr:)?(npub1|nprofile1)/)) { if (pubkey) {
tryCatch(() => { addPubkey(pubkey)
const {type, data} = nip19.decode(fromNostrURI(t))
if (type === "npub") {
addPubkey(data)
}
if (type === "nprofile") {
addPubkey(data.pubkey)
}
})
} }
}) })
}) })
@ -73,11 +60,11 @@
</Field> </Field>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={pubkeys.length === 0}> <Button type="submit" class="button button-primary" disabled={pubkeys.length === 0}>
Create Chat Create Chat
<Icon icon={AltArrowRight} /> <Icon icon={AltArrowRight} />
</Button> </Button>

View file

@ -1,27 +1,26 @@
<script lang="ts"> <script lang="ts">
import {uniq} from "@welshman/lib" import {uniq} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue, getTagValues, getAddress} from "@welshman/util" import {getAddress} from "@welshman/util"
import {pubkey} from "@welshman/app" import {Classified} from "@welshman/domain"
import Pen2 from "@assets/icons/pen-2.svg?dataurl" import Pen2 from "@assets/icons/pen-2.svg?dataurl"
import {normalizeTopic} from "@lib/util" import {normalizeTopic} from "@lib/util"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ClassifiedStatus from "@app/components/ClassifiedStatus.svelte" import ClassifiedStatus from "@app/components/ClassifiedStatus.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte" import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
import {publishDelete} from "@app/deletes" import {reader, user} from "@app/core"
import {publishReaction} from "@app/reactions"
import {canEnforceNip70} from "@app/relays"
import {makeClassifiedPath, makeSpacePath} from "@app/routes" import {makeClassifiedPath, makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
interface Props { type Props = {
url: string url: string
event: TrustedEvent event: TrustedEvent
showRoom?: boolean showRoom?: boolean
@ -30,34 +29,33 @@
const {url, event, showRoom, showActivity}: Props = $props() const {url, event, showRoom, showActivity}: Props = $props()
const h = getTagValue("h", event.tags) const classified = reader(Classified)(event)
const topics = getTagValues("t", event.tags)
const h = classified.room()
const topics = classified.topics() ?? []
const path = makeClassifiedPath(url, getAddress(event)) const path = makeClassifiedPath(url, getAddress(event))
const shouldProtect = canEnforceNip70(url)
const editClassified = () => pushModal(ClassifiedEdit, {url, event}) const editClassified = () => pushModal(ClassifiedEdit, {url, event})
const deleteReaction = async (event: TrustedEvent) => const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) => const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script> </script>
<div class="flex grow flex-wrap justify-end gap-2"> <div class="flex grow flex-wrap justify-end gap-2">
{#if h && showRoom} {#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full"> <Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} /> Posted in #<RoomName {h} {url} />
</Link> </Link>
{/if} {/if}
<div class="flex min-w-0 flex-wrap gap-2"> <div class="flex min-w-0 flex-wrap gap-2">
{#each uniq(topics) as topic (topic)} {#each uniq(topics) as topic (topic)}
<button type="button" class="btn btn-xs rounded-full font-normal"> <button type="button" class="button button-xs rounded-full font-normal">
#{normalizeTopic(topic)} #{normalizeTopic(topic)}
</button> </button>
{/each} {/each}
</div> </div>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event}> <ThunkStatusOrDeleted {event}>
<ClassifiedStatus {event} /> <ClassifiedStatus {event} />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>
@ -66,7 +64,7 @@
{/if} {/if}
<EventActions {url} {event} noun="Listing"> <EventActions {url} {event} noun="Listing">
{#snippet customActions()} {#snippet customActions()}
{#if event.pubkey === $pubkey} {#if event.pubkey === $user.pubkey}
<li> <li>
<Button onclick={editClassified}> <Button onclick={editClassified}>
<Icon size={4} icon={Pen2} /> <Icon size={4} icon={Pen2} />

View file

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import {fromPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getTag, getTagValues} from "@welshman/util" import {Classified} from "@welshman/domain"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import {reader} from "@app/core"
import ClassifiedForm from "@app/components/ClassifiedForm.svelte" import ClassifiedForm from "@app/components/ClassifiedForm.svelte"
type Props = { type Props = {
@ -14,19 +14,29 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const {content} = event const classified = reader(Classified)(event)
const {d, title, status} = fromPairs(event.tags)
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || [] const initialValues = $derived(
const images = getTagValues("image", event.tags) classified && {
const topics = getTagValues("t", event.tags) d: classified.identifier() ?? "",
const initialValues = {d, title, status, content, price: Number(price), currency, images, topics} title: classified.title() ?? "",
status: classified.status() ?? "",
content: classified.content(),
price: classified.price()?.amount ?? 0,
currency: classified.price()?.currency ?? "SAT",
images: classified.images(),
topics: classified.topics(),
},
)
</script> </script>
<ClassifiedForm {url} {initialValues}> {#if initialValues}
{#snippet header()} <ClassifiedForm {url} {initialValues}>
<ModalHeader> {#snippet header()}
<ModalTitle>Edit this Listing</ModalTitle> <ModalHeader>
<ModalSubtitle>Advertise a job, sale, or need.</ModalSubtitle> <ModalTitle>Edit this Listing</ModalTitle>
</ModalHeader> <ModalSubtitle>Advertise a job, sale, or need.</ModalSubtitle>
{/snippet} </ModalHeader>
</ClassifiedForm> {/snippet}
</ClassifiedForm>
{/if}

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {removeUndefined, randomId, uniq} from "@welshman/lib" import {removeUndefined, randomId, uniq} from "@welshman/lib"
import {makeEvent, CLASSIFIED} from "@welshman/util" import {publish} from "@welshman/app"
import {publishThunk, waitForThunkError} from "@welshman/app" import {Classified} from "@welshman/domain"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import {normalizeTopic} from "@lib/util" import {normalizeTopic} from "@lib/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@ -17,12 +17,12 @@
import CurrencyInput from "@app/components/CurrencyInput.svelte" import CurrencyInput from "@app/components/CurrencyInput.svelte"
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte" import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {command, relays, writer} from "@app/core"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {PROTECTED, publishRoomQuote} from "@app/groups" import {publishRoomQuote} from "@app/rooms"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts" import {DraftKey} from "@app/drafts"
import {canEnforceNip70} from "@app/relays" import {compressFileForUpload, uploadFile} from "@app/uploads"
import {uploadFile} from "@app/uploads"
type Values = { type Values = {
d: string d: string
@ -51,8 +51,6 @@
initialValues = draftKey.get() initialValues = draftKey.get()
} }
const shouldProtect = canEnforceNip70(url)
const back = () => history.back() const back = () => history.back()
const submit = async () => { const submit = async () => {
@ -76,34 +74,13 @@
}) })
} }
const tags = [ const imageUrls: string[] = []
["d", d],
["title", title],
["summary", content],
["price", String(price), currency],
["status", status],
...ed.storage.nostr.getEditorTags(),
]
for (const topic of topics) {
tags.push(["t", topic])
}
const protect = await shouldProtect
if (protect) {
tags.push(PROTECTED)
}
if (h) {
tags.push(["h", h])
}
for (const image of images) { for (const image of images) {
if (typeof image === "string") { if (typeof image === "string") {
tags.push(["image", image]) imageUrls.push(image)
} else { } else {
const {result, error} = await uploadFile(image, {url}) const {result, error} = await uploadFile(await compressFileForUpload(image), {url})
if (error) { if (error) {
return pushToast({ return pushToast({
@ -113,17 +90,33 @@
} }
if (result) { if (result) {
tags.push(["image", result.url]) imageUrls.push(result.url)
} }
} }
} }
const classifiedThunk = publishThunk({ const protect = await $relays.hasNip(url, 70)
relays: [url],
event: makeEvent(CLASSIFIED, {content, tags}),
})
const error = await waitForThunkError(classifiedThunk) const eventWriter = writer(Classified)
.setIdentifier(d)
.setContent(content)
.setTitle(title)
.setSummary(content)
.setPrice(price, currency)
.setStatus(status)
.setTopics(topics)
.setImages(imageUrls)
.setProtected(protect)
.addTags(...ed.storage.nostr.getEditorTags())
if (h) {
eventWriter.setRoom(url, h)
} else {
eventWriter.forceRelays(url)
}
const classifiedThunk = await command(eventWriter).then(publish)
const error = await classifiedThunk.waitForError()
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
@ -164,13 +157,13 @@
<Modal tag="form" onsubmit={preventDefault(submit)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody> <ModalBody>
{@render header?.()} {@render header?.()}
<div class="col-8 relative"> <div class="flex flex-col gap-8 relative">
<Field> <Field>
{#snippet label()} {#snippet label()}
<p>Title*</p> <p>Title*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y_autofocus -->
<input <input
autofocus={!isMobile} autofocus={!isMobile}
@ -205,10 +198,10 @@
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="join grid grid-cols-2"> <div class="join grid grid-cols-2">
<label class="join-item input input-bordered flex w-full items-center gap-2"> <label class="input flex w-full items-center gap-2">
<input bind:value={price} class="grow w-32" type="number" /> <input bind:value={price} class="grow w-32" type="number" />
</label> </label>
<CurrencyInput class="join-item" bind:value={currency} /> <CurrencyInput bind:value={currency} />
</div> </div>
{/snippet} {/snippet}
</Field> </Field>
@ -218,7 +211,7 @@
<p>Status*</p> <p>Status*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<select class="select select-bordered w-full" bind:value={status}> <select class="select input w-full" bind:value={status}>
<option value="active">Active</option> <option value="active">Active</option>
<option value="sold">Sold</option> <option value="sold">Sold</option>
</select> </select>
@ -236,11 +229,11 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}> <Button onclick={back} disabled={loading} class="button button-link">
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={loading}> <Button type="submit" disabled={loading} class="button button-primary">
<Spinner {loading}>Save Listing</Spinner> <Spinner {loading}>Save Listing</Spinner>
</Button> </Button>
</ModalFooter> </ModalFooter>

View file

@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import {formatTimestamp} from "@welshman/lib" import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getTag, getAddress, getTagValue, getTagValues} from "@welshman/util" import {getAddress} from "@welshman/util"
import {Classified} from "@welshman/domain"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import CurrencySymbol from "@lib/components/CurrencySymbol.svelte" import CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
import {reader} from "@app/core"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte" import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
@ -18,20 +20,22 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const title = getTagValue("title", event.tags) const classified = reader(Classified)(event)
const h = getTagValue("h", event.tags)
const images = new Set(getTagValues("image", event.tags)) const title = classified.title()
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || [] const h = classified.room()
const images = new Set(classified.images())
const price = classified.price()
</script> </script>
<Link <Link
class="cv col-2 card2 w-full cursor-pointer" class="cv flex flex-col gap-2 card card-interactive w-full"
href={makeClassifiedPath(url, getAddress(event))}> href={makeClassifiedPath(url, getAddress(event))}>
{#if title} {#if title}
<div class="flex w-full items-center justify-between gap-2"> <div class="flex w-full items-center justify-between gap-2">
<p class="text-xl"> <p class="text-xl">
{title} — {title} —
<CurrencySymbol code={currency} />{price} <CurrencySymbol code={price?.currency ?? "SAT"} />{price?.amount ?? 0}
</p> </p>
<p class="text-sm opacity-75"> <p class="text-sm opacity-75">
{formatTimestamp(event.created_at)} {formatTimestamp(event.created_at)}

View file

@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util" import {Classified} from "@welshman/domain"
import {ucFirst} from "@lib/util" import {ucFirst} from "@lib/util"
import Badge from "@lib/components/Badge.svelte"
import {reader} from "@app/core"
type Props = { type Props = {
event: TrustedEvent event: TrustedEvent
@ -10,11 +11,13 @@
const {event}: Props = $props() const {event}: Props = $props()
const status = getTagValue("status", event.tags) const classified = reader(Classified)(event)
const status = classified.status()
</script> </script>
{#if status} {#if status}
<div class={cx("btn btn-xs rounded-full", {"btn-primary": status !== "active"})}> <Badge variant={status !== "active" ? "primary" : "neutral"} class="rounded-full">
{ucFirst(status)} {ucFirst(status)}
</div> </Badge>
{/if} {/if}

View file

@ -1,15 +1,14 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import {publishDelete} from "@app/deletes"
import {publishReaction} from "@app/reactions"
import {canEnforceNip70} from "@app/relays"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
interface Props { type Props = {
url: string url: string
event: TrustedEvent event: TrustedEvent
segment: string segment: string
@ -18,20 +17,18 @@
const {url, event, segment, showActivity = false}: Props = $props() const {url, event, segment, showActivity = false}: Props = $props()
const shouldProtect = canEnforceNip70(url) const h = tagValue(tagSpec("h"), event.tags)
const path = makeSpacePath(url, segment, event.id) const path = makeSpacePath(url, segment, event.id)
const deleteReaction = async (event: TrustedEvent) => const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) => const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script> </script>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex grow flex-wrap justify-end gap-2"> <div class="flex grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} /> <ThunkStatusOrDeleted {event} />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />

View file

@ -39,7 +39,7 @@
}) })
</script> </script>
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md" bind:this={ul}> <ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<li> <li>
<Button onclick={createGoal}> <Button onclick={createGoal}>
<Icon size={4} icon={StarFallMinimalistic} /> <Icon size={4} icon={StarFallMinimalistic} />

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import {fromNostrURI} from "@welshman/util" import {fromNostrURI, tagSpec, tagValue} from "@welshman/util"
import {nthEq} from "@welshman/lib"
import { import {
parse, parse,
truncate, truncate,
@ -25,6 +24,7 @@
import Danger from "@assets/icons/danger-triangle.svg?dataurl" import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Card from "@lib/components/Card.svelte"
import ContentToken from "@app/components/ContentToken.svelte" import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte" import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentEmail from "@app/components/ContentEmail.svelte" import ContentEmail from "@app/components/ContentEmail.svelte"
@ -38,7 +38,7 @@
import {entityLink} from "@app/env" import {entityLink} from "@app/env"
import {userSettingsValues} from "@app/settings" import {userSettingsValues} from "@app/settings"
interface Props { type Props = {
event: any event: any
minLength?: number minLength?: number
maxLength?: number maxLength?: number
@ -58,7 +58,7 @@
url, url,
}: Props = $props() }: Props = $props()
const fullContent = parse(event) const fullContent = $derived(parse(event))
const expand = () => { const expand = () => {
showEntire = true showEntire = true
@ -97,12 +97,16 @@
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p) const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
let warningDismissed = $state(false)
const ignoreWarning = () => { const ignoreWarning = () => {
warning = null warningDismissed = true
} }
let warning = $state( const warning = $derived(
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1], warningDismissed
? undefined
: $userSettingsValues.hide_sensitive && tagValue(tagSpec("content-warning"), event.tags),
) )
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => { const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
@ -144,13 +148,13 @@
<div class="relative"> <div class="relative">
{#if warning} {#if warning}
<div class="card2 card2-sm shadow-none row-2"> <Card sm class="shadow-none flex gap-2">
<Icon icon={Danger} /> <Icon icon={Danger} />
<p> <p>
This note has been flagged by the author as "{warning}".<br /> This note has been flagged by the author as "{warning}".<br />
<Button class="link" onclick={ignoreWarning}>Show anyway</Button> <Button onclick={ignoreWarning} class="button button-link">Show anyway</Button>
</p> </p>
</div> </Card>
{:else} {:else}
<div <div
class="overflow-hidden text-ellipsis wrap-break-word" class="overflow-hidden text-ellipsis wrap-break-word"
@ -204,12 +208,9 @@
</div> </div>
{#if expandBlock} {#if expandBlock}
<div class="relative z-feature -mt-6 flex justify-center py-2"> <div class="relative z-feature -mt-6 flex justify-center py-2">
<button <Button onclick={stopPropagation(preventDefault(expand))} class="button button-neutral">
type="button"
class="btn btn-neutral"
onclick={stopPropagation(preventDefault(expand))}>
See more See more
</button> </Button>
</div> </div>
{/if} {/if}
{/if} {/if}

View file

@ -3,7 +3,7 @@
</script> </script>
<code <code
class="w-full overflow-auto whitespace-pre rounded bg-neutral px-1 text-neutral-content text-sm" class="bg-surface-more text-content w-full overflow-auto whitespace-pre rounded px-1 text-sm"
class:block={isBlock}> class:block={isBlock}>
{value.trim()} {value.trim()}
</code> </code>

View file

@ -1,16 +1,33 @@
<script module lang="ts">
import {postJson, simpleCache} from "@welshman/lib"
import {dufflepud} from "@app/env"
// Cache previews by url so the same link isn't re-fetched across renders/instances.
const loadPreview = simpleCache(async ([url]: [string]) => {
const json = await postJson(dufflepud("link/preview"), {url})
if (!json?.title && !json?.image) {
throw new Error("Failed to load link preview")
}
return json
})
</script>
<script lang="ts"> <script lang="ts">
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib" import {call, ellipsize, displayUrl} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util" import {isRelayUrl, tagSpec, tagValue} from "@welshman/util"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import {preventDefault, stopPropagation} from "@lib/html" import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte" import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte" import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte" import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {dufflepud, PLATFORM_URL, THUMBNAIL_URL} from "@app/env" import {PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
import {IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/content" import {IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/content"
import {isRoomId} from "@app/groups" import {isRoomId} from "@app/rooms"
const {value, event} = $props() const {value, event} = $props()
@ -24,7 +41,7 @@
return [url, true] return [url, true]
}) })
const fileType = getTagValue("file-type", event.tags) || "" const fileType = tagValue(tagSpec("file-type"), event.tags) || ""
const getVideoPoster = (videoUrl: string): string | undefined => { const getVideoPoster = (videoUrl: string): string | undefined => {
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) { if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
@ -34,16 +51,6 @@
return undefined return undefined
} }
const loadPreview = async () => {
const json = await postJson(dufflepud("link/preview"), {url})
if (!json?.title && !json?.image) {
throw new Error("Failed to load link preview")
}
return json
}
const onError = () => { const onError = () => {
hideImage = true hideImage = true
} }
@ -52,52 +59,51 @@
</script> </script>
{#if isRoomOrRelay} {#if isRoomOrRelay}
<div> <ContentLinkUrl {url} class="link-content whitespace-nowrap" />
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
</div>
{:else} {:else}
<Link {external} {href} class="my-2 block"> <Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box"> {#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)} <video
<video controls
controls src={url}
src={url} poster={getVideoPoster(url)}
poster={getVideoPoster(url)} preload="metadata"
preload="metadata" class="max-h-96 rounded-2xl object-contain object-center">
class="max-h-96 rounded-box object-contain object-center"> <track kind="captions" />
<track kind="captions" /> </video>
</video> {:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)} <button type="button" onclick={stopPropagation(preventDefault(expand))}>
<button type="button" onclick={stopPropagation(preventDefault(expand))}> <ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-2xl" />
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" /> </button>
</button> {:else}
{:else} {#await loadPreview(url)}
{#await loadPreview()} <div class="flex justify-center items-center my-12 w-full">
<div class="center my-12 w-full"> <Spinner />
<span class="loading loading-spinner"></span> </div>
{:then preview}
<div
class="border border-solid flex max-w-xl flex-col overflow-hidden leading-normal rounded-2xl"
style="border-color: var(--line)">
{#if preview.image && !hideImage}
<img
alt=""
onerror={onError}
src={preview.image}
class="bg-surface max-h-72 object-contain object-center" />
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div> </div>
{:then preview} </div>
<div {:catch}
class="border border-solid border-base-content/20 flex max-w-xl flex-col leading-normal rounded-box"> <p
{#if preview.image && !hideImage} class="border border-solid p-12 text-center leading-normal rounded-2xl"
<img style="border-color: var(--line)">
alt="" Unable to load a preview for {url}
onerror={onError} </p>
src={preview.image} {/await}
class="bg-alt max-h-72 rounded-t-box object-contain object-center" /> {/if}
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="border border-solid border-base-content/20 p-12 text-center leading-normal">
Unable to load a preview for {url}
</p>
{/await}
{/if}
</div>
</Link> </Link>
{/if} {/if}

View file

@ -2,34 +2,35 @@
import {onMount, onDestroy} from "svelte" import {onMount, onDestroy} from "svelte"
import {displayUrl, once} from "@welshman/lib" import {displayUrl, once} from "@welshman/lib"
import { import {
getTags,
getBlob, getBlob,
decryptFile, decryptFile,
getTagValue,
tagsFromIMeta,
makeBlossomAuthEvent, makeBlossomAuthEvent,
matchTags,
tagSpec,
tagValue,
} from "@welshman/util" } from "@welshman/util"
import {signer} from "@welshman/app"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {user} from "@app/core"
const {value, event, ...props} = $props() const {value, event, ...props} = $props()
const url = value.url.toString() const url = value.url.toString()
// An imeta tag packs its own tags into space-separated values, so unpack the one for this url.
const meta = const meta =
getTags("imeta", event.tags) matchTags(tagSpec("imeta"), event.tags)
.map(tagsFromIMeta) .map(([, ...values]: string[]) => values.map(value => value.split(" ")))
.find(meta => getTagValue("url", meta) === url) || event.tags .find(meta => tagValue(tagSpec("url"), meta) === url) || event.tags
// Fallback to filename if hash was omitted from the message for interoperability // Fallback to filename if hash was omitted from the message for interoperability
const hash = getTagValue("x", meta) || url.split(/[\/\.]/).slice(-2)[0] const hash = tagValue(tagSpec("x"), meta) || url.split(/[\/\.]/).slice(-2)[0]
const key = getTagValue("decryption-key", meta) const key = tagValue(tagSpec("decryption-key"), meta)
const nonce = getTagValue("decryption-nonce", meta) const nonce = tagValue(tagSpec("decryption-nonce"), meta)
const algorithm = getTagValue("encryption-algorithm", meta) const algorithm = tagValue(tagSpec("encryption-algorithm"), meta)
const mime = getTagValue("m", meta) const mime = tagValue(tagSpec("m"), meta)
const fileName = const fileName =
getTagValue("filename", meta) || tagValue(tagSpec("filename"), meta) ||
getTagValue("name", meta) || tagValue(tagSpec("name"), meta) ||
decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).at(-1) || "image") decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).at(-1) || "image")
const revokeSrc = () => { const revokeSrc = () => {
@ -45,10 +46,10 @@
const onError = once(async () => { const onError = once(async () => {
// If the image failed to load, try authenticating // If the image failed to load, try authenticating
if (hash && $signer) { if (hash) {
const server = new URL(url).origin const server = new URL(url).origin
const template = makeBlossomAuthEvent({action: "get", server, hashes: [hash]}) const template = makeBlossomAuthEvent({action: "get", server, hashes: [hash]})
const authEvent = await $signer.sign(template) const authEvent = await $user.signer.sign(template)
const res = await getBlob(server, hash, {authEvent}) const res = await getBlob(server, hash, {authEvent})
if (res.status === 200) { if (res.status === 200) {

View file

@ -8,5 +8,5 @@
</script> </script>
<Button class="m-auto h-screen w-screen cursor-pointer p-4" onclick={back}> <Button class="m-auto h-screen w-screen cursor-pointer p-4" onclick={back}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-full max-w-full rounded-box" /> <ContentLinkBlockImage {value} {event} class="m-auto max-h-full max-w-full rounded-2xl" />
</Button> </Button>

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {displayUrl} from "@welshman/lib" import {displayUrl} from "@welshman/lib"
import {getTagValue} from "@welshman/util" import {tagSpec, tagValue} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html" import {preventDefault, stopPropagation} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@ -12,7 +12,7 @@
const {value, event} = $props() const {value, event} = $props()
const url = value.url.toString() const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || "" const fileType = tagValue(tagSpec("file-type"), event.tags) || ""
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true}) const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script> </script>

View file

@ -1,27 +1,28 @@
<script lang="ts"> <script lang="ts">
import {call, displayUrl} from "@welshman/lib" import {call, displayUrl} from "@welshman/lib"
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util" import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {splitRoomKey} from "@welshman/app"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {PLATFORM_URL} from "@app/env" import {PLATFORM_URL} from "@app/env"
import {displayRoom, isRoomId, splitRoomId} from "@app/groups" import {isRoomId} from "@app/rooms"
import {makeRoomPath, makeSpacePath} from "@app/routes" import {makeRoomPath, makeSpacePath} from "@app/routes"
const { type Props = {
url,
class: className = "",
}: {
url: string url: string
class?: string class?: string
} = $props() }
const {url, class: className = ""}: Props = $props()
const roomReference = call(() => { const roomReference = call(() => {
if (!isRoomId(url)) { if (!isRoomId(url)) {
return undefined return undefined
} }
const [roomUrl, h] = splitRoomId(url) const [roomUrl, h] = splitRoomKey(url)
if (!roomUrl || !h || !isRelayUrl(roomUrl)) { if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
return undefined return undefined
@ -49,8 +50,7 @@
<Link {external} {href} class={className}> <Link {external} {href} class={className}>
{#if roomReference} {#if roomReference}
~<span class="text-primary">{displayRelayUrl(roomReference.url)}</span> / #<RoomName {...roomReference} />
{displayRoom(roomReference.url, roomReference.h)}
{:else if relayReference} {:else if relayReference}
<span class="text-primary">{displayRelayUrl(relayReference)}</span> <span class="text-primary">{displayRelayUrl(relayReference)}</span>
{:else} {:else}

View file

@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import {removeUndefined} from "@welshman/lib"
import type {ProfilePointer} from "@welshman/content" import type {ProfilePointer} from "@welshman/content"
import {deriveProfileDisplay} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {profiles} from "@app/core"
type Props = { type Props = {
value: ProfilePointer value: ProfilePointer
@ -13,7 +12,7 @@
const {value, url}: Props = $props() const {value, url}: Props = $props()
const display = deriveProfileDisplay(value.pubkey, removeUndefined([url])) const display = $profiles.display(value.pubkey).$
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url}) const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
</script> </script>

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {fromNostrURI} from "@welshman/util" import {fromNostrURI, tagValue, tagSpec} from "@welshman/util"
import {nthEq} from "@welshman/lib"
import { import {
parse, parse,
truncate, truncate,
@ -44,7 +43,7 @@
const {event, trimParent = false, singleLine = false, url}: Props = $props() const {event, trimParent = false, singleLine = false, url}: Props = $props()
const fullContent = parse(event) const fullContent = $derived(parse(event))
const isBoundary = (i: number) => { const isBoundary = (i: number) => {
const parsed = fullContent[i] const parsed = fullContent[i]
@ -63,12 +62,16 @@
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p) const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
let warningDismissed = $state(false)
const ignoreWarning = () => { const ignoreWarning = () => {
warning = null warningDismissed = true
} }
let warning = $state( const warning = $derived(
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1], warningDismissed
? undefined
: $userSettingsValues.hide_sensitive && tagValue(tagSpec("content-warning"), event.tags),
) )
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => { const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
@ -98,11 +101,11 @@
<div class="relative"> <div class="relative">
{#if warning} {#if warning}
<div class="card2 card2-sm shadow-none row-2"> <div class="card card-sm shadow-none flex gap-2">
<Icon icon={Danger} /> <Icon icon={Danger} />
<p> <p>
This note has been flagged by the author as "{warning}".<br /> This note has been flagged by the author as "{warning}".<br />
<Button class="link" onclick={ignoreWarning}>Show anyway</Button> <Button onclick={ignoreWarning} class="button button-link">Show anyway</Button>
</p> </p>
</div> </div>
{:else} {:else}

View file

@ -1,13 +1,22 @@
<script lang="ts"> <script lang="ts">
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {Router} from "@welshman/router" import {derived, writable} from "svelte/store"
import {removeUndefined} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {Address, MESSAGE} from "@welshman/util" import {
Address,
MESSAGE,
eventOutbox,
getIdFilters,
relays as relaySelections,
seen,
} from "@welshman/util"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte" import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import {network, router} from "@app/core"
import {deriveEvent} from "@app/repository" import {deriveEvent} from "@app/repository"
import {entityLink} from "@app/env" import {entityLink} from "@app/env"
import {goToEvent} from "@app/routes" import {goToEvent} from "@app/routes"
@ -22,22 +31,43 @@
const {id, identifier, kind, pubkey, relays = []} = value const {id, identifier, kind, pubkey, relays = []} = value
const idOrAddress = id || new Address(kind, pubkey, identifier).toString() const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
const mergedRelays = Router.get().Quote(event, idOrAddress, relays).getUrls() const ref = {id, pubkey, kind, identifier, relays}
const hints = removeUndefined([...relays, url])
if (url) { const mergedRelays = writable(hints)
mergedRelays.push(url)
}
const quote = deriveEvent(idOrAddress, mergedRelays) const quote = deriveEvent(idOrAddress, hints)
const entity = id
? nip19.neventEncode({id, relays: mergedRelays}) // Start with the hints we were handed, then widen to everything the router can work out:
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr() // where the quoted event has been seen, its author's outbox, and — since whoever quoted it
// must have seen it — the relays the quoting event came from. Resolving that may take a
// round trip for the author's relay list, so only load again if the hints came up empty.
$router.resolver
.relays([
...relaySelections(removeUndefined([url])),
seen(ref),
eventOutbox(ref),
seen(event, 0.5),
])
.then(urls => {
mergedRelays.set(urls)
if (!$quote) {
$network.load({filters: getIdFilters([idOrAddress]), relays: urls})
}
})
const entity = derived(mergedRelays, $mergedRelays =>
id
? nip19.neventEncode({id, relays: $mergedRelays})
: new Address(kind, pubkey, identifier, $mergedRelays).toNaddr(),
)
const onclick = () => { const onclick = () => {
if ($quote) { if ($quote) {
goToEvent($quote) goToEvent($quote)
} else { } else {
window.open(entityLink(entity)) window.open(entityLink($entity))
} }
} }
</script> </script>
@ -48,20 +78,21 @@
<NoteContent {url} event={$quote} /> <NoteContent {url} event={$quote} />
{:else if $quote.kind === MESSAGE} {:else if $quote.kind === MESSAGE}
<div <div
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90" class="border-l-2 border-solid py-1 pl-2 opacity-90"
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);"> style="border-left-color: var(--primary); background-color: color-mix(in srgb, var(--primary) 10%, var(--surface-more) 90%);">
<NoteContentMinimal trimParent {url} event={$quote} /> <NoteContentMinimal trimParent {url} event={$quote} />
</div> </div>
{:else} {:else}
<NoteCard <NoteCard
event={$quote} event={$quote}
{url} {url}
class="border border-solid border-base-content/20 rounded-box p-4"> class="border border-solid rounded-2xl p-4"
style="border-color: var(--line)">
<NoteContentMinimal {url} event={$quote} /> <NoteContentMinimal {url} event={$quote} />
</NoteCard> </NoteCard>
{/if} {/if}
{:else} {:else}
<div class="rounded-box p-4"> <div class="rounded-2xl p-4">
<Spinner loading>Loading event...</Spinner> <Spinner loading>Loading event...</Spinner>
</div> </div>
{/if} {/if}

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {spec} from "@welshman/lib"
import cx from "classnames" import cx from "classnames"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import type {Writable} from "svelte/store" import type {Writable} from "svelte/store"
@ -45,12 +46,12 @@
} }
const onKeyDown = (e: Event) => { const onKeyDown = (e: Event) => {
if (instance.onKeyDown(e)) { if (instance?.onKeyDown(e)) {
e.preventDefault() e.preventDefault()
} }
} }
const currency = $derived(currencyOptions.find(c => c.code === value)) const currency = $derived(currencyOptions.find(spec({code: value})))
let wrapper: Element | undefined = $state() let wrapper: Element | undefined = $state()
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
@ -66,17 +67,13 @@
</script> </script>
<button <button
class={cx( class={cx(props.class, {"bg-surface": currency}, "input flex items-center gap-2 cursor-pointer")}
props.class,
{"bg-base-200": currency},
"input input-bordered flex items-center gap-2 cursor-pointer",
)}
bind:this={wrapper} bind:this={wrapper}
onfocus={preventDefault(clearAndFocus)} onfocus={preventDefault(clearAndFocus)}
onclick={preventDefault(clearAndFocus)}> onclick={preventDefault(clearAndFocus)}>
<Icon icon={AltArrowDown} /> <Icon icon={AltArrowDown} />
{#if currency} {#if currency}
<span class="text-sm ellipsize whitespace-nowrap"> <span class="min-w-0 truncate text-sm whitespace-nowrap">
{displayCurrency(currency)} {displayCurrency(currency)}
</span> </span>
{:else} {:else}

View file

@ -31,7 +31,8 @@
loading = true loading = true
try { try {
const error = await setFeaturedContent(url, content) const thunk = await setFeaturedContent(url, content)
const error = await thunk.waitForError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
@ -39,6 +40,9 @@
pushToast({message: "Featured content updated!"}) pushToast({message: "Featured content updated!"})
back() back()
} }
} catch (e) {
console.error(e)
pushToast({theme: "error", message: "Failed to update featured content."})
} finally { } finally {
loading = false loading = false
} }
@ -68,11 +72,11 @@
</Field> </Field>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button class="btn btn-primary" onclick={submit} disabled={loading}> <Button class="button button-primary" onclick={submit} disabled={loading}>
<Spinner {loading}>Save changes</Spinner> <Spinner {loading}>Save changes</Spinner>
</Button> </Button>
</ModalFooter> </ModalFooter>

View file

@ -3,18 +3,18 @@
import type {Instance} from "tippy.js" import type {Instance} from "tippy.js"
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl" import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EventMenu from "@app/components/EventMenu.svelte" import EventMenu from "@app/components/EventMenu.svelte"
import {reactions, relays} from "@app/core"
import {ENABLE_ZAPS} from "@app/env" import {ENABLE_ZAPS} from "@app/env"
import {publishReaction} from "@app/reactions"
import {canEnforceNip70} from "@app/relays"
type Props = { type Props = {
url: string url: string
@ -26,40 +26,46 @@
const {url, noun, event, hideZap, customActions}: Props = $props() const {url, noun, event, hideZap, customActions}: Props = $props()
const shouldProtect = canEnforceNip70(url) const shouldProtect = $relays.hasNip(url, 70)
const showPopover = () => popover?.show() const showPopover = () => popover?.show()
const hidePopover = () => popover?.hide() const hidePopover = () => popover?.hide()
const onEmoji = async (emoji: NativeEmoji) => const onEmoji = async (emoji: NativeEmoji) => {
publishReaction({ const protect = await shouldProtect
event, const room = tagValue(tagSpec("h"), event.tags)
content: emoji.unicode,
relays: [url], const command = await $reactions.react(event, emoji.unicode, writer => {
protect: await shouldProtect, writer.setProtected(protect)
if (room) {
writer.setRoom(url, room)
}
}) })
return command.publishToRelays([url])
}
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
</script> </script>
<div class="join items-center rounded-full"> <div class="items-center join">
{#if ENABLE_ZAPS && !hideZap} {#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs"> <ZapButton {url} {event} class="button button-neutral button-xs join-item">
<Icon icon={Bolt} size={4} /> <Icon icon={Bolt} size={4} />
</ZapButton> </ZapButton>
{/if} {/if}
<EmojiButton {onEmoji} class="btn join-item btn-neutral btn-xs"> <EmojiButton {onEmoji} class="button button-neutral button-xs join-item">
<Icon icon={SmileCircle} size={4} /> <Icon icon={SmileCircle} size={4} />
</EmojiButton> </EmojiButton>
<Tippy <Button onclick={showPopover} class="flex join-item button button-neutral button-xs">
class="flex" <Tippy
bind:popover bind:popover
component={EventMenu} component={EventMenu}
props={{url, noun, event, customActions, onClick: hidePopover}} props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}> params={{trigger: "manual", interactive: true}}>
<Button class="btn join-item btn-neutral btn-xs" onclick={showPopover}>
<Icon icon={MenuDots} size={4} /> <Icon icon={MenuDots} size={4} />
</Button> </Tippy>
</Tippy> </Button>
</div> </div>

View file

@ -2,33 +2,32 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import {max, gt, formatTimestampRelative} from "@welshman/lib" import {max, gt, formatTimestampRelative} from "@welshman/lib"
import {COMMENT} from "@welshman/util" import {COMMENT} from "@welshman/util"
import {load} from "@welshman/net"
import {deriveArray, deriveEventsById} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app"
import {deriveChecked} from "@app/notifications"
import Reply from "@assets/icons/reply-2.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {deriveEvents} from "@app/repository"
import {network} from "@app/core"
import {deriveChecked} from "@app/notifications"
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props() const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props()
const checked = deriveChecked(path) const checked = deriveChecked(path)
const filters = [{kinds: [COMMENT], "#E": [event.id]}] const filters = [{kinds: [COMMENT], "#E": [event.id]}]
const replies = deriveArray(deriveEventsById({repository, filters})) const replies = deriveEvents(filters)
const lastActive = $derived(max([...$replies, event].map(e => e.created_at))) const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
onMount(() => { onMount(() => {
load({relays: [url], filters}) $network.load({relays: [url], filters})
}) })
</script> </script>
<div class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full"> <div class="flex-inline button button-neutral button-xs gap-1 rounded-full">
<Icon icon={Reply} /> <Icon icon={Reply} />
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span> <span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
</div> </div>
<div class="btn btn-neutral btn-xs relative rounded-full"> <div class="button button-neutral button-xs relative rounded-full">
{#if gt(lastActive, $checked)} {#if gt(lastActive, $checked)}
<div class="h-2 w-2 rounded-full bg-primary"></div> <div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
{/if} {/if}
Active {formatTimestampRelative(lastActive)} Active {formatTimestampRelative(lastActive)}
</div> </div>

View file

@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import Confirm from "@lib/components/Confirm.svelte" import Confirm from "@lib/components/Confirm.svelte"
import {publishDelete} from "@app/deletes" import {deletes, relays} from "@app/core"
import {canEnforceNip70} from "@app/relays"
import {clearModals} from "@app/modal" import {clearModals} from "@app/modal"
type Props = { type Props = {
@ -12,10 +11,13 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const shouldProtect = canEnforceNip70(url) const shouldProtect = $relays.hasNip(url, 70)
const confirm = async () => { const confirm = async () => {
await publishDelete({event, relays: [url], protect: await shouldProtect}) const protect = await shouldProtect
const command = await $deletes.deleteEvent(event, writer => writer.setProtected(protect))
command.publishToRelays([url])
clearModals() clearModals()
} }

View file

@ -1,22 +1,23 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {Router} from "@welshman/router"
import {LOCALE, secondsToDate} from "@welshman/lib" import {LOCALE, secondsToDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl, seen, toNostrURI} from "@welshman/util"
import {tracker} from "@welshman/app"
import FileText from "@assets/icons/file-text.svg?dataurl" import FileText from "@assets/icons/file-text.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl" import Copy from "@assets/icons/copy.svg?dataurl"
import UserCircle from "@assets/icons/user-circle.svg?dataurl" import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Badge from "@lib/components/Badge.svelte"
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import {app, router} from "@app/core"
import {clip} from "@app/toast" import {clip} from "@app/toast"
type Props = { type Props = {
@ -26,19 +27,26 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const relays = url ? [url] : Router.get().Event(event).getUrls() const seenOn = $app.tracker.getRelays(event.id)
const nevent1 = nip19.neventEncode({...event, relays})
const seenOn = tracker.getRelays(event.id)
const npub1 = nip19.npubEncode(event.pubkey) const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2) const json = JSON.stringify(event, null, 2)
const copyLink = () => clip(nevent1) const copyLink = () => clip(nevent1)
const copyPubkey = () => clip(npub1) const copyPubkey = () => clip(npub1)
const copyJson = () => clip(json) const copyJson = () => clip(json)
const back = () => history.back()
const formatter = new Intl.DateTimeFormat(LOCALE, { const formatter = new Intl.DateTimeFormat(LOCALE, {
dateStyle: "long", dateStyle: "long",
timeStyle: "long", timeStyle: "long",
}) })
let nevent1 = $state("")
onMount(async () => {
const relays = url ? [url] : await $router.resolver.relays([seen(event)])
nevent1 = toNostrURI(nip19.neventEncode({...event, relays}))
})
</script> </script>
<Modal> <Modal>
@ -60,9 +68,9 @@
<p>Event Link</p> <p>Event Link</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input flex w-full items-center gap-2">
<Icon icon={FileText} /> <Icon icon={FileText} />
<input type="text" class="ellipsize min-w-0 grow" value={nevent1} /> <input type="text" class="truncate min-w-0 grow" value={nevent1} />
<Button onclick={copyLink} class="flex items-center"> <Button onclick={copyLink} class="flex items-center">
<Icon icon={Copy} /> <Icon icon={Copy} />
</Button> </Button>
@ -74,9 +82,9 @@
<p>Author Pubkey</p> <p>Author Pubkey</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input flex w-full items-center gap-2">
<Icon icon={UserCircle} /> <Icon icon={UserCircle} />
<input type="text" class="ellipsize min-w-0 grow" value={npub1} /> <input type="text" class="truncate min-w-0 grow" value={npub1} />
<Button onclick={copyPubkey} class="flex items-center"> <Button onclick={copyPubkey} class="flex items-center">
<Icon icon={Copy} /> <Icon icon={Copy} />
</Button> </Button>
@ -91,24 +99,24 @@
{#snippet input()} {#snippet input()}
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each seenOn as url, i (url)} {#each seenOn as url, i (url)}
<span class="bg-alt badge flex gap-1"> <Badge class="bg-surface flex gap-1">
{displayRelayUrl(url)} {displayRelayUrl(url)}
</span> </Badge>
{/each} {/each}
</div> </div>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
{/if} {/if}
<div class="relative"> <div class="relative">
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre> <pre class="card card-sm overflow-auto text-xs"><code>{json}</code></pre>
<p class="absolute right-2 top-2 flex grow items-center justify-between"> <p class="absolute right-2 top-2 flex grow items-center justify-between">
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center"> <Button onclick={copyJson} class="button button-neutral button-sm flex items-center">
<Icon icon={Copy} /> Copy <Icon icon={Copy} /> Copy
</Button> </Button>
</p> </p>
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-primary grow" onclick={() => history.back()}>Got it</Button> <Button class="button button-primary grow" onclick={back}>Got it</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View file

@ -1,27 +1,25 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {COMMENT, ManagementMethod} from "@welshman/util" import {COMMENT} from "@welshman/util"
import {pubkey, repository, relaysByUrl, manageRelay} from "@welshman/app" import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl" import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl" import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl" import Danger from "@assets/icons/danger.svg?dataurl"
import {setKey} from "@lib/implicit"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte" import Confirm from "@lib/components/Confirm.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import Report from "@app/components/Report.svelte" import Report from "@app/components/Report.svelte"
import EventShare from "@app/components/EventShare.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {hasNip29} from "@app/relays" import PinboardSelect from "@app/components/PinboardSelect.svelte"
import {deriveUserIsSpaceAdmin} from "@app/members" import {shareEventToChat} from "@app/share"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {makeSpaceChatPath} from "@app/routes" import {app, relayManagement, user} from "@app/core"
type Props = { type Props = {
url: string url: string
@ -40,14 +38,9 @@
const showInfo = () => pushModal(EventInfo, {url, event}) const showInfo = () => pushModal(EventInfo, {url, event})
const share = async () => { const addToLibrary = () => pushModal(PinboardSelect, {url, event})
if (hasNip29($relaysByUrl.get(url))) {
pushModal(EventShare, {url, event}) const share = () => shareEventToChat(url, noun, event)
} else {
setKey("share", event)
goto(makeSpaceChatPath(url))
}
}
const showDelete = () => pushModal(EventDeleteConfirm, {url, event}) const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
@ -56,16 +49,13 @@
title: `Delete ${noun}`, title: `Delete ${noun}`,
message: `Are you sure you want to delete this ${noun.toLowerCase()} from the space?`, message: `Are you sure you want to delete this ${noun.toLowerCase()} from the space?`,
confirm: async () => { confirm: async () => {
const {error} = await manageRelay(url, { const {error} = await $relayManagement.forUrl(url).banEvent(event.id)
method: ManagementMethod.BanEvent,
params: [event.id],
})
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
} else { } else {
pushToast({message: "Event has successfully been deleted!"}) pushToast({message: "Event has successfully been deleted!"})
repository.removeEvent(event.id) $app.repository.removeEvent(event.id)
history.back() history.back()
} }
}, },
@ -78,7 +68,13 @@
}) })
</script> </script>
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md" bind:this={ul}> <ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<li>
<Button onclick={showInfo}>
<Icon size={4} icon={Code2} />
{noun} Details
</Button>
</li>
{#if isRoot} {#if isRoot}
<li> <li>
<Button onclick={share}> <Button onclick={share}>
@ -88,13 +84,13 @@
</li> </li>
{/if} {/if}
<li> <li>
<Button onclick={showInfo}> <Button onclick={addToLibrary}>
<Icon size={4} icon={Code2} /> <Icon size={4} icon={GalleryWide} />
{noun} Details Add to Library
</Button> </Button>
</li> </li>
{@render customActions?.()} {@render customActions?.()}
{#if event.pubkey === $pubkey} {#if event.pubkey === $user.pubkey}
<li> <li>
<Button onclick={showDelete} class="text-error"> <Button onclick={showDelete} class="text-error">
<Icon size={4} icon={TrashBin2} /> <Icon size={4} icon={TrashBin2} />

View file

@ -2,19 +2,21 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {publishToRelays} from "@welshman/app"
import {Comment} from "@welshman/domain"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl" import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte" import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import {publishComment} from "@app/comments" import {prependParent} from "@app/rooms"
import {canEnforceNip70} from "@app/relays"
import {PROTECTED, prependParent} from "@app/groups"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts" import {DraftKey} from "@app/drafts"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {command, relays, writer} from "@app/core"
type Values = { type Values = {
content?: string | object content?: string | object
@ -32,7 +34,6 @@
const {url, event, parent, onClose, onClearParent, onSubmit}: Props = $props() const {url, event, parent, onClose, onClearParent, onSubmit}: Props = $props()
const draftKey = new DraftKey<Values>(`reply:${event.id}:${parent?.id || ""}`) const draftKey = new DraftKey<Values>(`reply:${event.id}:${parent?.id || ""}`)
const initialValues = draftKey.get() const initialValues = draftKey.get()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false) const uploading = writable(false)
const autofocus = !isMobile const autofocus = !isMobile
@ -45,10 +46,6 @@
let content = ed.getText({blockSeparator: "\n"}).trim() let content = ed.getText({blockSeparator: "\n"}).trim()
let tags = ed.storage.nostr.getEditorTags() let tags = ed.storage.nostr.getEditorTags()
if (await shouldProtect) {
tags.push(PROTECTED)
}
if (!content) { if (!content) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
@ -57,11 +54,20 @@
} }
if (parent) { if (parent) {
;({content, tags} = prependParent(parent, {content, tags}, url)) ;({content, tags} = await prependParent(parent, {content, tags}, url))
} }
const eventWriter = writer(Comment)
.setContent(content)
.addTags(...tags)
.setRootFromEvent(event)
.setParentFromEvent(event)
.setProtected(await $relays.hasNip(url, 70))
const thunk = await command(eventWriter).then(publishToRelays([url]))
draftKey.clear() draftKey.clear()
onSubmit(publishComment({event, content, tags, relays: [url]})) onSubmit(thunk)
} }
let form: HTMLElement let form: HTMLElement
@ -101,7 +107,7 @@
bind:this={form} bind:this={form}
onsubmit={preventDefault(submit)} onsubmit={preventDefault(submit)}
class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2"> class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
<div class="card2 mx-2 my-2 bg-alt shadow-md"> <div class="card mx-2 my-2 shadow-md">
{#if parent} {#if parent}
<ChatComposeParent event={parent} clear={() => onClearParent?.()} verb="Replying to" /> <ChatComposeParent event={parent} clear={() => onClearParent?.()} verb="Replying to" />
{/if} {/if}
@ -111,18 +117,18 @@
</div> </div>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2" class="tip tip-left absolute bottom-1 right-2"
onclick={selectFiles}> onclick={selectFiles}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <Spinner size="xs" />
{:else} {:else}
<Icon icon={Paperclip} size={3} /> <Icon icon={Paperclip} size={3} />
{/if} {/if}
</Button> </Button>
</div> </div>
<div class="flex justify-between pt-3"> <div class="flex justify-between pt-3">
<Button class="btn btn-link" onclick={onClose}>Cancel</Button> <Button class="button button-link" onclick={onClose}>Cancel</Button>
<Button type="submit" class="btn btn-primary">Post Reply</Button> <Button type="submit" class="button button-primary">Post Reply</Button>
</div> </div>
</div> </div>
</form> </form>

View file

@ -13,11 +13,19 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {roomsByUrl} from "@app/groups"
import {makeRoomPath} from "@app/routes" import {makeRoomPath} from "@app/routes"
import {rooms} from "@app/core"
const {url, noun, event}: {url: string; noun: string; event: TrustedEvent} = $props() type Props = {
url: string
noun: string
event: TrustedEvent
}
const {url, noun, event}: Props = $props()
const spaceRooms = $rooms.forUrl(url).$
const back = () => history.back() const back = () => history.back()
@ -39,25 +47,25 @@
<ModalTitle>Share {noun}</ModalTitle> <ModalTitle>Share {noun}</ModalTitle>
<ModalSubtitle>Which room would you like to share this event to?</ModalSubtitle> <ModalSubtitle>Which room would you like to share this event to?</ModalSubtitle>
</ModalHeader> </ModalHeader>
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-2 gap-2">
{#each $roomsByUrl.get(url) || [] as room (room.h)} {#each $spaceRooms as room (room.h)}
<button <Button
type="button" type="button"
class="btn" class="flex justify-center button {selection === room.h
class:btn-neutral={selection !== room.h} ? 'button-primary'
class:btn-primary={selection === room.h} : 'button-neutral'}"
onclick={() => toggleRoom(room.h)}> onclick={() => toggleRoom(room.h)}>
#<RoomName {...room} /> <RoomNameWithImage {url} h={room.h} />
</button> </Button>
{/each} {/each}
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={!selection}> <Button type="submit" class="button button-primary" disabled={!selection}>
Share {noun} Share {noun}
<Icon icon={AltArrowRight} /> <Icon icon={AltArrowRight} />
</Button> </Button>

View file

@ -1,15 +1,14 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue} from "@welshman/util" import {ZapGoal} from "@welshman/domain"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import {reader} from "@app/core"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte" import EventActions from "@app/components/EventActions.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import {publishDelete} from "@app/deletes"
import {publishReaction} from "@app/reactions"
import {canEnforceNip70} from "@app/relays"
import {makeGoalPath, makeSpacePath} from "@app/routes" import {makeGoalPath, makeSpacePath} from "@app/routes"
interface Props { interface Props {
@ -22,23 +21,22 @@
const {url, event, showRoom, showActivity}: Props = $props() const {url, event, showRoom, showActivity}: Props = $props()
const path = makeGoalPath(url, event.id) const path = makeGoalPath(url, event.id)
const h = getTagValue("h", event.tags) const goal = reader(ZapGoal)(event)
const shouldProtect = canEnforceNip70(url)
const deleteReaction = async (event: TrustedEvent) => const h = goal.room()
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) => const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url})
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
const createReaction = (values: EventContent) => publishReaction(event, values, {url})
</script> </script>
<div class="flex grow flex-wrap justify-end gap-2"> <div class="flex grow flex-wrap justify-end gap-2">
{#if h && showRoom} {#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full"> <Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} /> Posted in #<RoomName {h} {url} />
</Link> </Link>
{/if} {/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} /> <ThunkStatusOrDeleted {event} />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {makeEvent, ZAP_GOAL} from "@welshman/util" import {ZapGoal} from "@welshman/domain"
import {publishThunk, waitForThunkError} from "@welshman/app" import {publish} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html" import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl" import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
@ -18,11 +18,11 @@
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {command, relays, writer} from "@app/core"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {PROTECTED, publishRoomQuote} from "@app/groups" import {publishRoomQuote} from "@app/rooms"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts" import {DraftKey} from "@app/drafts"
import {canEnforceNip70} from "@app/relays"
type Values = { type Values = {
title: string title: string
@ -45,7 +45,7 @@
initialValues = draftKey.get() initialValues = draftKey.get()
} }
const shouldProtect = canEnforceNip70(url) const shouldProtect = $relays.hasNip(url, 70)
const uploading = writable(false) const uploading = writable(false)
@ -73,32 +73,25 @@
}) })
} }
const tags = [
...ed.storage.nostr.getEditorTags(),
["summary", content],
["amount", String(amount)],
["relays", url],
]
loading = true loading = true
try { try {
const protect = await shouldProtect const protect = await shouldProtect
const eventWriter = writer(ZapGoal)
if (protect) { .setTitle(title)
tags.push(PROTECTED) .setSummary(content)
} .setAmount(amount)
.setUrls([url])
.setProtected(protect)
.addTags(...ed.storage.nostr.getEditorTags())
.forceRelays(url)
if (h) { if (h) {
tags.push(["h", h]) eventWriter.setRoom(url, h)
} }
const goalThunk = publishThunk({ const goalThunk = await command(eventWriter).then(publish)
relays: [url], const error = await goalThunk.waitForError()
event: makeEvent(ZAP_GOAL, {content: title, tags}),
})
const error = await waitForThunkError(goalThunk)
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
@ -145,13 +138,13 @@
<ModalTitle>Create a Funding Goal</ModalTitle> <ModalTitle>Create a Funding Goal</ModalTitle>
<ModalSubtitle>Request contributions for your fundraiser.</ModalSubtitle> <ModalSubtitle>Request contributions for your fundraiser.</ModalSubtitle>
</ModalHeader> </ModalHeader>
<div class="col-8 relative"> <div class="flex flex-col gap-8 relative">
<Field> <Field>
{#snippet label()} {#snippet label()}
<p>Title*</p> <p>Title*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y_autofocus -->
<input <input
autofocus={!isMobile} autofocus={!isMobile}
@ -175,24 +168,24 @@
</Field> </Field>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2" class="tip tip-left absolute bottom-1 right-2"
onclick={selectFiles} onclick={selectFiles}
disabled={loading}> disabled={loading}>
{#if $uploading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <Spinner size="xs" />
{:else} {:else}
<Icon icon={Paperclip} size={3} /> <Icon icon={Paperclip} size={3} />
{/if} {/if}
</Button> </Button>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-3">
<FieldInline> <FieldInline>
{#snippet label()} {#snippet label()}
Goal Amount (sats)* Goal Amount (sats)*
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="flex grow justify-end"> <div class="flex grow justify-end">
<label class="input input-bordered flex w-auto items-center gap-2"> <label class="input flex w-auto items-center gap-2">
<Icon icon={Bolt} /> <Icon icon={Bolt} />
<input bind:value={amount} type="number" class="w-28 grow" /> <input bind:value={amount} type="number" class="w-28 grow" />
<p class="shrink-0 opacity-50">sats</p> <p class="shrink-0 opacity-50">sats</p>
@ -201,7 +194,7 @@
{/snippet} {/snippet}
</FieldInline> </FieldInline>
<input <input
class="range range-primary -mt-2 w-full" class="range w-full"
type="range" type="range"
min="1000" min="1000"
max="100000" max="100000"
@ -211,11 +204,11 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back} disabled={loading}> <Button class="button button-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}> <Button type="submit" class="button button-primary" disabled={$uploading || loading}>
<Spinner {loading}>Create Goal</Spinner> <Spinner {loading}>Create Goal</Spinner>
</Button> </Button>
</ModalFooter> </ModalFooter>

View file

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util" import {ZapGoal} from "@welshman/domain"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import {reader} from "@app/core"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import GoalActions from "@app/components/GoalActions.svelte" import GoalActions from "@app/components/GoalActions.svelte"
@ -16,12 +17,17 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const summary = getTagValue("summary", event.tags) const goal = reader(ZapGoal)(event)
const h = getTagValue("h", event.tags)
const title = goal.title()
const summary = goal.summary()
const h = goal.room()
</script> </script>
<Link class="cv col-2 card2 w-full cursor-pointer" href={makeGoalPath(url, event.id)}> <Link
<p class="text-2xl">{event.content}</p> class="cv flex flex-col gap-2 card card-interactive w-full"
href={makeGoalPath(url, event.id)}>
<p class="text-2xl">{title}</p>
<Content <Content
event={{content: summary, tags: event.tags}} event={{content: summary, tags: event.tags}}
{url} {url}

View file

@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
import {derived} from "svelte/store"
import {now, DAY, uniq, sum} from "@welshman/lib" import {now, DAY, uniq, sum} from "@welshman/lib"
import type {Zap, TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util" import {fromMsats, ZAP_RECEIPT} from "@welshman/util"
import {deriveItemsByKey, deriveArray} from "@welshman/store" import {ZapGoal} from "@welshman/domain"
import {repository, getValidZap} from "@welshman/app" import type {Zap} from "@welshman/domain"
import {Zappers} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {deriveEvents} from "@app/repository"
import ZapButton from "@app/components/ZapButton.svelte" import ZapButton from "@app/components/ZapButton.svelte"
import {app, reader} from "@app/core"
type Props = { type Props = {
url?: string url?: string
@ -16,18 +20,23 @@
const {url, event, ...props}: Props = $props() const {url, event, ...props}: Props = $props()
const zaps = deriveArray( const receipts = deriveEvents([{kinds: [ZAP_RECEIPT], "#e": [event.id]}])
deriveItemsByKey<Zap>({
repository, const zaps = derived<typeof receipts, Zap[]>(
getKey: zap => zap.response.id, receipts,
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}], ($receipts, set) => $app.use(Zappers).validZapReceipts($receipts, event).$.subscribe(set),
eventToItem: (response: TrustedEvent) => getValidZap(response, event), [],
}),
) )
const goalAmount = parseInt(getTagValue("amount", event.tags) || "0") const goal = reader(ZapGoal)(event)
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
const contributorsCount = $derived(uniq($zaps.map(zap => zap.request.pubkey)).length) const goalAmount = goal.amount() ?? 0
const closedAt = goal.closedAt()
// NIP-75: receipts published after closed_at don't count toward the goal.
const counted = $derived($zaps.filter(zap => !closedAt || zap.response.created_at <= closedAt))
const zapAmount = $derived(fromMsats(sum(counted.map(zap => zap.invoiceAmount))))
const contributorsCount = $derived(uniq(counted.map(zap => zap.request.pubkey)).length)
const daysOld = Math.ceil((now() - event.created_at) / DAY) const daysOld = Math.ceil((now() - event.created_at) / DAY)
</script> </script>
@ -46,8 +55,8 @@
<p class="text-sm opacity-75">{daysOld === 1 ? "day" : "days"} old</p> <p class="text-sm opacity-75">{daysOld === 1 ? "day" : "days"} old</p>
</div> </div>
</div> </div>
<progress class="progress progress-primary" value={zapAmount} max={goalAmount}></progress> <progress class="progress" value={zapAmount} max={goalAmount}></progress>
<ZapButton {url} {event} class="btn btn-primary lg:m-auto lg:px-20"> <ZapButton {url} {event} class="button button-primary lg:m-auto lg:px-20">
<Icon icon={Bolt} /> <Icon icon={Bolt} />
Contribute to this goal Contribute to this goal
</ZapButton> </ZapButton>

View file

@ -43,7 +43,7 @@
} }
</script> </script>
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input flex w-full items-center gap-2">
<Icon icon={Magnifier} /> <Icon icon={Magnifier} />
<input bind:value={searchTerm} class="grow" type="text" placeholder="Search icons..." /> <input bind:value={searchTerm} class="grow" type="text" placeholder="Search icons..." />
</label> </label>
@ -53,7 +53,7 @@
<button <button
type="button" type="button"
title={icon.name} title={icon.name}
class="flex aspect-square items-center justify-center rounded-box transition-colors hover:bg-primary hover:text-primary-content" class="flex aspect-square items-center justify-center rounded-2xl transition-colors hover:bg-primary hover:text-primary-content"
onclick={() => handleSelect(icon.url)}> onclick={() => handleSelect(icon.url)}>
<Icon icon={icon.url} class="h-6 w-6" /> <Icon icon={icon.url} class="h-6 w-6" />
</button> </button>

View file

@ -8,6 +8,6 @@
const {onSelect}: Props = $props() const {onSelect}: Props = $props()
</script> </script>
<div class="w-96 rounded-box bg-base-100 p-4 shadow-2xl"> <div class="w-96 rounded-2xl bg-surface p-4 shadow-2xl">
<IconPicker {onSelect} /> <IconPicker {onSelect} />
</div> </div>

View file

@ -6,6 +6,8 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import {PLATFORM_NAME} from "@app/env" import {PLATFORM_NAME} from "@app/env"
const back = () => history.back()
</script> </script>
<Modal <Modal
@ -37,5 +39,5 @@
href="https://nostrapps.com#signers">nostrapps.com</Link href="https://nostrapps.com#signers">nostrapps.com</Link
>. >.
</p> </p>
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button> <Button class="button button-primary" onclick={back}>Got it</Button>
</ModalBody></Modal> </ModalBody></Modal>

View file

@ -6,6 +6,8 @@
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import {PLATFORM_NAME} from "@app/env" import {PLATFORM_NAME} from "@app/env"
const back = () => history.back()
</script> </script>
<Modal <Modal
@ -27,5 +29,5 @@
class="underline">nostr.how</Link class="underline">nostr.how</Link
>. >.
</p> </p>
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button> <Button class="button button-primary" onclick={back}>Got it</Button>
</ModalBody></Modal> </ModalBody></Modal>

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import {session} from "@welshman/app"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl" import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
@ -11,8 +10,10 @@
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import KeyRecoveryRequest from "@app/components/KeyRecoveryRequest.svelte" import KeyRecoveryRequest from "@app/components/KeyRecoveryRequest.svelte"
import {isPomadeSession} from "@app/pomade"
import {PLATFORM_NAME} from "@app/env" import {PLATFORM_NAME} from "@app/env"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {session} from "@app/core"
const back = () => history.back() const back = () => history.back()
@ -36,7 +37,7 @@
<strong>public key</strong>, which acts as your user ID, and a <strong>public key</strong>, which acts as your user ID, and a
<strong>private key</strong> which allows you to prove your identity. <strong>private key</strong> which allows you to prove your identity.
</p> </p>
{#if $session?.email} {#if isPomadeSession($session)}
<p> <p>
It's very important to keep private keys safe, but this can sometimes be tricky, which is It's very important to keep private keys safe, but this can sometimes be tricky, which is
why {PLATFORM_NAME} why {PLATFORM_NAME}
@ -44,16 +45,16 @@
</p> </p>
<p>If you'd like to switch to self-custody, please click below to get started.</p> <p>If you'd like to switch to self-custody, please click below to get started.</p>
{:else} {:else}
<Button class="btn btn-primary" onclick={back}>Got it</Button> <Button class="button button-primary" onclick={back}>Got it</Button>
{/if} {/if}
</ModalBody> </ModalBody>
{#if $session?.email} {#if isPomadeSession($session)}
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button class="btn btn-primary" onclick={startRecoveryRequest}> <Button class="button button-primary" onclick={startRecoveryRequest}>
<Icon icon={CheckCircle} /> <Icon icon={CheckCircle} />
I want to hold my own keys I want to hold my own keys
</Button> </Button>

View file

@ -5,6 +5,8 @@
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
const back = () => history.back()
</script> </script>
<Modal <Modal
@ -28,5 +30,5 @@
To learn more about how to manage your keys, or to set up an account, try To learn more about how to manage your keys, or to set up an account, try
<Link external class="link" href="https://nosta.me/">nosta.me</Link>. <Link external class="link" href="https://nosta.me/">nosta.me</Link>.
</p> </p>
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button> <Button class="button button-primary" onclick={back}>Got it</Button>
</ModalBody></Modal> </ModalBody></Modal>

View file

@ -7,6 +7,8 @@
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import {PLATFORM_NAME} from "@app/env" import {PLATFORM_NAME} from "@app/env"
const back = () => history.back()
</script> </script>
<Modal> <Modal>
@ -27,6 +29,6 @@
</p> </p>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button> <Button class="button button-primary" onclick={back}>Got it</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View file

@ -30,5 +30,5 @@
information flowing through the network — instead, your client software can prove that a given information flowing through the network — instead, your client software can prove that a given
piece of data is authentic. piece of data is authentic.
</p> </p>
<Button class="btn btn-primary" onclick={back}>Got it</Button> <Button class="button button-primary" onclick={back}>Got it</Button>
</ModalBody></Modal> </ModalBody></Modal>

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {deriveZapperForPubkey} from "@welshman/app" import {Zappers} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@ -9,10 +9,15 @@
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import {app} from "@app/core"
const {pubkey} = $props() type Props = {
pubkey: string
}
const zapper = deriveZapperForPubkey(pubkey) const {pubkey}: Props = $props()
const zapper = $app.use(Zappers).forPubkey(pubkey).$
const back = () => history.back() const back = () => history.back()
</script> </script>
@ -32,7 +37,7 @@
</p> </p>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="btn btn-link" onclick={back}> <Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>

View file

@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import {randomId, call} from "@welshman/lib" import {randomId} from "@welshman/lib"
import {preventDefault, stopPropagation, compressFile} from "@lib/html" import {preventDefault, stopPropagation} from "@lib/html"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl" import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {uploadFile} from "@app/uploads" import Spinner from "@lib/components/Spinner.svelte"
import {compressFileForUpload, uploadFileOrFallback} from "@app/uploads"
interface Props { interface Props {
file?: File | undefined file?: File | undefined
@ -16,6 +17,10 @@
const id = randomId() const id = randomId()
// Profile pictures are shown small (avatar-sized), so compress hard: this keeps
// the base64 fallback below small enough to embed directly in the profile event.
const compressOptions = {maxWidth: 128, maxHeight: 128, quality: 0.6, mimeType: "image/jpeg"}
const onDragEnter = () => { const onDragEnter = () => {
active = true active = true
} }
@ -31,11 +36,27 @@
const onDrop = async (e: any) => { const onDrop = async (e: any) => {
active = false active = false
file = await compressFile(e.dataTransfer.files[0]) submit(e.dataTransfer.files[0])
} }
const onChange = async (e: any) => { const onChange = async (e: any) => {
file = await compressFile(e.target.files[0]) submit(e.target.files[0])
}
const submit = async (newFile: File) => {
if (!newFile) {
url = initialUrl
return
}
loading = true
try {
file = await compressFileForUpload(newFile, compressOptions)
url = (await uploadFileOrFallback(file)).url
} finally {
loading = false
}
} }
const onClear = () => { const onClear = () => {
@ -45,65 +66,42 @@
} }
let active = $state(false) let active = $state(false)
let loading = $state(false)
let initialUrl = $state(url) let initialUrl = $state(url)
$effect(() => {
call(async () => {
if (file) {
const {result} = await uploadFile(file)
if (result?.url) {
url = result.url
} else {
const reader = new FileReader()
reader.addEventListener(
"load",
() => {
url = reader.result as string
},
false,
)
reader.readAsDataURL(file)
}
} else {
url = initialUrl
}
})
})
</script> </script>
<form> <form>
<input {id} type="file" accept="image/*" onchange={onChange} class="hidden" /> <input
{id}
type="file"
accept="image/jpeg,image/png,image/webp"
onchange={onChange}
class="hidden" />
<label <label
for={id} for={id}
aria-label="Drag and drop files here." aria-label="Drag and drop files here."
style="background-image: url({url});" style="background-image: url({url}); border-color: {active ? 'var(--primary)' : 'var(--line)'};"
class="relative flex h-24 w-24 shrink-0 cursor-pointer items-center justify-center rounded-full border-2 border-solid border-base-content bg-base-300 bg-cover bg-center transition-all" class="bg-surface-more relative flex h-24 w-24 shrink-0 cursor-pointer items-center justify-center rounded-full border-2 border-solid bg-cover bg-center transition-all"
class:transparent={!url} class:transparent={!url}
class:border-primary={active}
ondragenter={stopPropagation(preventDefault(onDragEnter))} ondragenter={stopPropagation(preventDefault(onDragEnter))}
ondragover={stopPropagation(preventDefault(onDragOver))} ondragover={stopPropagation(preventDefault(onDragOver))}
ondragleave={stopPropagation(preventDefault(onDragLeave))} ondragleave={stopPropagation(preventDefault(onDragLeave))}
ondrop={stopPropagation(preventDefault(onDrop))}> ondrop={stopPropagation(preventDefault(onDrop))}>
<div <button
class="absolute right-0 top-0 h-5 w-5 overflow-hidden rounded-full bg-primary" onclick={url ? stopPropagation(onClear) : undefined}
class:bg-error={url} class="absolute right-0 top-0 h-5 w-5 overflow-hidden rounded-full flex items-center justify-center"
class:bg-primary={!url}> style="background: {url ? 'var(--error)' : 'var(--primary)'};">
{#if url} {#if url}
<span <Icon icon={CloseCircle} class="scale-150 bg-surface-more!" />
role="button"
tabindex="-1"
onmousedown={stopPropagation(onClear)}
ontouchstart={stopPropagation(onClear)}>
<Icon icon={CloseCircle} class="scale-150 bg-base-300!" />
</span>
{:else} {:else}
<Icon icon={AddCircle} class="scale-150 bg-base-300!" /> <Icon icon={AddCircle} class="scale-150 bg-surface-more!" />
{/if} {/if}
</div> </button>
{#if !url} {#if loading}
<div class="absolute inset-0 flex items-center justify-center rounded-full bg-black/40">
<Spinner />
</div>
{:else if !url}
<Icon icon={GallerySend} size={7} /> <Icon icon={GallerySend} size={7} />
{/if} {/if}
</label> </label>

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