Compare commits

..

No commits in common. "91a8995a9886a40df07732425f5a8aaf56d649df" and "9955a50add7b2ab288f9f00835578681a62d9d80" have entirely different histories.

475 changed files with 10979 additions and 17405 deletions

View file

@ -1,279 +1,634 @@
--- ---
name: welshman-app name: welshman-app
description: "Use this skill when working with @welshman/app: the App instance and its plugins, sessions and login, publishing via Commands and Thunks, app policies, WoT, feeds, sync, or relay selection at the app layer." description: "Use this skill when working with @welshman/app: 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."
--- ---
# welshman/app — The App Instance and its Plugins # welshman/app — Application Layer Stores
`@welshman/app` composes `net`, `store`, `domain`, `signer`, and `feeds` into an application `@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.
framework built around a single `App` object.
## Installation ## Installation
```bash ```bash
npm i @welshman/app npm install @welshman/app
# or
pnpm add @welshman/app
yarn add @welshman/app
``` ```
## The App ## Key Exports
```typescript ### Core Singletons
import {App, createApp, User} from "@welshman/app"
const app = createApp({ | Export | Description |
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 |
|---|---| |---|---|
| `app.user` | the signed-in `User`, or `undefined` | | `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.config` | the `AppConfig` above | | `tracker` | Singleton `Tracker`; maps event IDs to the relays they were seen on |
| `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 |
`createApp` is `new App` plus `defaultAppPolicies`. Use `new App({policies: [...]})` for a bare app. ### Session Management
**An app is scoped to one identity.** To log in, build a *new* app and `cleanup()` the old one — | Export | Description |
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 |
|---|---| |---|---|
| `MapPlugin<T>` | a plain keyed map of non-event data (relay stats, NIP-11 info) | | `pubkey` | `Writable<string \| undefined>` — active session's pubkey |
| `LoadableMapPlugin<T>` | a `MapPlugin` that knows how to `fetch(key)` from the network | | `session` | `Readable<Session \| undefined>` — derived from `pubkey` + `sessions` |
| `DerivedPlugin<T>` | a keyed collection **derived from the repository** — the repository is the source of truth, never a duplicated map | | `sessions` | `Writable<Record<string, Session>>` — all loaded sessions |
| `RelayScopedDerivedPlugin<T>` | keyed by `getKey(item, url)` per relay, for data that only means something relative to a relay | | `signer` | `Readable<ISigner \| undefined>` — signer for the active session |
| `RelaySignedDerivedPlugin<T>` | the same, but only accepts events authored by the relay's NIP-11 `self` pubkey (NIP-29 room state, relay membership/roles) | | `signerLog` | `WritableWithGetter<SignerLogEntry[]>` — writable store that the session layer appends signer-operation entries to (useful for UI feedback during remote signing) |
| `SessionMethod` | Enum: `Nip01`, `Nip07`, `Nip46`, `Nip55`, `Pomade`, `Pubkey`, `Anonymous` |
Derived plugins expose: **Login functions** (all call `addSession` internally):
- `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
import {User, createApp, nip07, toSession} from "@welshman/app" loginWithNip01(secret: string): void
loginWithNip07(pubkey: string): void
const session = toSession(nip07, {pubkey}) loginWithNip46(pubkey, clientSecret, signerPubkey, relays): void
const user = await User.fromSession(session) // undefined if the handler can't build a signer loginWithNip55(pubkey: string, signerPackageName: string): void
loginWithPomade(pubkey, email, clientOptions): void
const app = createApp({user}) loginWithPubkey(pubkey: string): void // read-only
``` ```
`User` wraps a signer and pubkey: **Session utilities**:
- `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
import {Domain, publish} from "@welshman/app" addSession(session: Session): void // add and activate
import {Note} from "@welshman/domain" dropSession(pubkey: string): void // remove and clean up signer
getSession(pubkey: string): Session | undefined
const writer = app.use(Domain).writer(Note).setContent("hello") updateSession(pubkey, fn): void
const command = await app.use(Domain).command(writer) clearSessions(): void
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)
``` ```
Plugin mutators already return a `Command`, so `.then(publish)` is the common shape: **Gift wrap (NIP-59)**:
```typescript ```typescript
await app.use(FollowLists).follow(["p", pubkey]).then(publish) shouldUnwrap: Writable<boolean> // must be true to process incoming wraps
await app.use(RelayLists).addWriteUrl(url).then(publish) wrapManager: WrapManager // tracks wrap↔rumor mappings
unwrapAndStore(wrap: SignedEvent): Promise<void>
``` ```
Free-function forms exist for pipelines: `publish`, `publishToRelays(urls)`, ### Publishing — Thunks
`publishAsRelay(url)`, `signAsRelay(url)`.
### Thunks | Export | Description |
|---|---|
| `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` |
`app.use(Thunks).publish({event, relays, delay})` publishes optimistically: the event lands in the **Thunk status helpers**:
local repository immediately, so the UI updates before the network settles. The returned `Thunk`
is a store you can render:
```typescript ```typescript
const thunk = app.use(Thunks).publish({event, relays}) thunkIsComplete(thunk): boolean
getThunkError(thunk): string | undefined
thunk.getUrlsWithStatus(PublishStatus.Success) getThunkUrlsWithStatus(statuses, thunk): string[]
thunk.getFailedUrls() getCompleteThunkUrls(thunk): string[]
thunk.isComplete() getFailedThunkUrls(thunk): string[]
await thunk.waitForError() // "" when everything succeeded waitForThunkError(thunk): Promise<string>
await thunk.waitForCompletion() waitForThunkCompletion(thunk): Promise<void>
``` ```
`app.use(Thunks).history` is a writable of every thunk this app has published — useful for a `ThunkOptions`:
"sending" indicator or deciding which relays the user has actually written to.
## Requests
```typescript ```typescript
const network = app.use(Network) type ThunkOptions = {
event: EventTemplate // unsigned — will be signed lazily
network.load({relays, filters}) // batched, deduped, shared loader relays: string[]
network.request({relays, filters, onEvent}) recipient?: string // if set, event is NIP-59 gift-wrapped
network.publish({event, relays}) delay?: number // ms to wait before sending (abort window)
network.loadUsingOutbox(pubkey, filter) // newest matching event from the author's write relays pow?: number // proof-of-work difficulty target
network.loadAllUsingOutbox(pubkey, filter) // every matching event timeout?: number // ms per relay before marking as timed out
``` // 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
} }
``` ```
**Ordering gotcha:** policies run in the `App` constructor. If a policy module imports something ### Commands (Higher-level Thunk Factories)
that transitively imports your app module, construct the app lazily (on first access) so every
policy has registered by the time it's built.
## Web of trust 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.
| 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
const wot = app.use(Wot) profilesByPubkey: Readable<Map<string, Profile>>
profiles: Readable<Profile[]>
wot.follows(pubkey).get() getProfile(pubkey: string): Profile | undefined
wot.followers(pubkey).get() loadProfile(pubkey, relayHints?): Promise<void>
wot.network(pubkey).$ // follows-of-follows forceLoadProfile(pubkey, relayHints?): Promise<void>
wot.followsWhoFollow(pubkey, target).$ deriveProfile(pubkey: string): Readable<Profile | undefined> // auto-loads
wot.wotScore(pubkey, target).$ deriveProfileDisplay(pubkey: string): Readable<string> // display name with fallback
displayProfileByPubkey(pubkey: string): string // synchronous
``` ```
## Feeds and sync ### Follow / Mute / Pin Lists
Each list type follows the same pattern (`follow` shown, `mute` and `pin` are identical):
```typescript ```typescript
app.use(Feeds).makeFeedController({feed, onEvent, ...}) followListsByPubkey: Readable<Map<string, List>>
app.use(Feeds).getPubkeysForScope(scope) followLists: Readable<List[]>
app.use(Feeds).forAuthor(pubkey).$ getFollowList(pubkey): List | undefined
loadFollowList(pubkey, relayHints?): Promise<void>
app.use(Sync).pull({relays, filters}) // negentropy: fetch what we're missing forceLoadFollowList(pubkey, relayHints?): Promise<void>
app.use(Sync).push({relays, filters}) // publish what the relay is missing deriveFollowList(pubkey): Readable<List | undefined>
``` ```
## Using welshman stores outside Svelte ### Relay Lists
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
// React // NIP-65 relay lists
const useStore = <T>(store: Readable<T>): T => { relayListsByPubkey / relayLists / getRelayList / loadRelayList / deriveRelayList
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]
}
``` ```
For a `Projection`, subscribe to `.$` and read `.get()` for a synchronous snapshot. Usage:
## Related skills ```tsx
import {userProfile, pubkey} from '@welshman/app'
- `welshman-domain` — the readers/writers every plugin decodes events with function ProfileHeader() {
- `welshman-net` — sockets, adapters, request/publish lifecycle, auth const profile = useReadable(userProfile)
- `welshman-store` — the repository and the derive helpers plugins are built on const [currentPubkey, setPubkey] = useWritable(pubkey)
- `welshman-signer` — signer implementations behind `User`
- `welshman-util` — kinds, filters, tag specs, and the `RelaySelection` DSL return <div>{profile?.name ?? currentPubkey}</div>
}
```
### 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

@ -1,197 +0,0 @@
---
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 {Profiles, Router, createSearch} from "@welshman/app" import {Router} from "@welshman/router"
import {outbox} from "@welshman/util" import {createSearch, profiles, searchProfiles, deriveProfileDisplay} from "@welshman/app"
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`** — `app.use(Profiles).profileSearch` and `app.use(Profiles).display(pubkey)` are the typical sources for mention autocomplete data and display names. - **`@welshman/app`** — `profileSearch` and `deriveProfileDisplay` are the typical sources for mention autocomplete data and display names.
- **`@welshman/app`** — relay hints for nprofile bech32 strings come from `app.use(Router).resolve([outbox(pubkey)])`. - **`@welshman/router`** — `Router.get().FromPubkeys([pubkey]).getUrls()` provides the relay hints encoded into nprofile bech32 strings.
- **`@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'
// Each App owns its repository. const unsub = on(repository, 'update', updates => {
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 owns an `App` instance and its plugins) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities. `@welshman/net` is the core networking layer for welshman-based nostr apps. It manages WebSocket relay connections, subscriptions, event publishing, NIP-42 auth, and NIP-77 negentropy sync. It sits below `@welshman/app` (which provides higher-level reactive stores and routing) 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` | `{ pool?, repository?, getAdapter? }` | | `netContext` | Global `NetContext` config object |
| `NetContext` | `{ pool, repository, isEventValid, isEventDeleted, getAdapter? }` |
Pass `context` to each `request`/`publish`/`load` call. An `App` from `@welshman/app` owns one per Mutate `netContext` fields directly to change global defaults; pass `context` to individual calls to override per-request.
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
// `app.tracker` — wired to that app's pool and repository import {tracker} from '@welshman/app' // singleton wired to the 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'
// `app.repository`, or `new Repository()` when using @welshman/net standalone import {repository} from '@welshman/app' // singleton; or Repository.get() 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` 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`. - **`@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.
- **`NetContext`** — passed explicitly per call. Each `App` owns its own pool and repository, which keeps one identity's data out of another's. - **`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.
--- ---

View file

@ -0,0 +1,291 @@
---
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`** wraps a signer in a `User` (`User.fromSigner(signer)` or `User.fromSession(session)`), passed to the app at construction: `createApp({user})`. Reach it again via `app.user?.signer`, or `User.require(app).signer` where a login is required. - **`@welshman/app`** 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.
- `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,19 +140,15 @@ 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 { PROFILE } from "@welshman/util" import { readProfile, PROFILE, type PublishedProfile } from "@welshman/util"
import { Profile, type ProfileReader } from "@welshman/domain"
const repository = new Repository() const repository = new Repository()
// Decoding is @welshman/domain's job — configure the kind, then use its reader as eventToItem. const profilesByPubkey = deriveItemsByKey<PublishedProfile>({
const readProfile = Profile.configure({}).reader
const profilesByPubkey = deriveItemsByKey<ProfileReader>({
repository, repository,
filters: [{ kinds: [PROFILE] }], filters: [{ kinds: [PROFILE] }],
eventToItem: event => readProfile(event).parse(), eventToItem: event => readProfile(event),
getKey: profile => profile.author(), getKey: profile => profile.event.pubkey,
}) })
// All profiles as array // All profiles as array
@ -163,7 +159,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?.display()) console.log($profile?.name)
}) })
``` ```
@ -231,8 +227,7 @@ 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. `@welshman/app` packages it as `DerivedPlugin` — prefer subclassing on-demand network loading.
that over hand-rolling the chain unless you need something it doesn't cover.
```typescript ```typescript
import { import {
@ -242,9 +237,10 @@ import {
makeLoadItem, makeLoadItem,
makeDeriveItem, makeDeriveItem,
} from "@welshman/store" } from "@welshman/store"
import { Network, Router } from "@welshman/app" import { load } from "@welshman/net"
import { outbox } from "@welshman/util" import { repository } from "@welshman/app"
import { relayTags, tagSpec, tagValue, tagValues } from "@welshman/util" import { Router } from "@welshman/router"
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
@ -258,14 +254,14 @@ type Bookmark = {
const parseBookmark = (event: TrustedEvent): Bookmark => ({ const parseBookmark = (event: TrustedEvent): Bookmark => ({
pubkey: event.pubkey, pubkey: event.pubkey,
title: tagValue(tagSpec("title"), event.tags) ?? "Untitled", title: getTagValue("title", event.tags) ?? "Untitled",
urls: tagValues(relayTags("r"), event.tags), urls: getTagValues("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: app.repository, repository,
filters: [{ kinds: [BOOKMARK_KIND] }], filters: [{ kinds: [BOOKMARK_KIND] }],
getKey: b => b.pubkey, getKey: b => b.pubkey,
eventToItem: parseBookmark, eventToItem: parseBookmark,
@ -282,10 +278,8 @@ 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) => {
const scenario = await app.use(Router).resolve([outbox(pubkey)]) await load({
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 }],
}) })
}, },
@ -304,8 +298,7 @@ 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`, and the tag specs (`tagValue`, `hexTags`, …) used when decoding events. - **`@welshman/util`** — provides `TrustedEvent`, `Filter`, `readProfile`, `readList`, and other event-parsing helpers that feed into `deriveItemsByKey` / `deriveEventsById`.
- **`@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,6 +32,7 @@ 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
@ -48,6 +49,9 @@ 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
@ -270,16 +274,24 @@ isDVMKind(kind) // 5000–7000
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `tagSpec(keys, matchValue?, normalize?)` | Build a spec: which tag keys, plus optional validation/normalization | | `getTags(types, tags)` | Get all tags matching one or more type strings |
| `hexTags(keys)` | Spec for 32-byte hex values (`e`, `p`, …) | | `getTag(types, tags)` | Get first matching tag |
| `addressTags(keys)` | Spec for `kind:pubkey:d` addresses (`a`, `A`) | | `getTagValues(types, tags)` | Get value (index 1) of all matching tags — types first, then the tags array |
| `kindTags(keys)` | Spec for kind numbers (`k`) | | `getTagValue(types, tags)` | Get value of first matching tag — types first, then the tags array |
| `topicTags(keys)` | Spec for topics, normalized (`t`) | | `getEventTags(tags)` | `e` tags |
| `relayTags(keys)` | Spec for relay urls (`r`, `relay`) | | `getEventTagValues(tags)` | Values of `e` tags |
| `tagValue(spec, tags)` | Value (index 1) of the first matching tag | | `getAddressTags(tags)` | `a` tags |
| `tagValues(spec, tags)` | Values of all matching tags | | `getAddressTagValues(tags)` | Values of `a` tags |
| `matchTag(spec, tags)` / `matchTags(spec, tags)` | The matching tag(s) themselves | | `getPubkeyTags(tags)` | `p` tags |
| `tagMatcher(spec)` | A `(tag) => boolean` predicate, for filtering in one pass | | `getPubkeyTagValues(tags)` | Values of `p` tags |
| `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
@ -307,12 +319,50 @@ 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 |
@ -320,6 +370,7 @@ isDVMKind(kind) // 5000–7000
| `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)
@ -328,6 +379,9 @@ isDVMKind(kind) // 5000–7000
| `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
@ -448,14 +502,29 @@ Only do this for events you persisted yourself after they were validated. Never
### Working with tags ### Working with tags
```typescript ```typescript
import {tagValue, tagValues, hexTags, relayTags, tagSpec, topicTags} from '@welshman/util' import {
getTagValue,
getTagValues,
getPubkeyTagValues,
getTopicTagValues,
getRelayTagValues,
getReplyTags,
uniqTags,
} from '@welshman/util'
// Specs come first, then the tags array. The spec says which keys to match and how to // getTagValue and getTagValues: types argument FIRST, then the tags array
// validate the value, so malformed tags are skipped rather than silently returned. const title = getTagValue('title', event.tags) // string | undefined
const title = tagValue(tagSpec('title'), event.tags) // string | undefined const urls = getTagValues('r', event.tags) // string[]
const urls = tagValues(relayTags('r'), event.tags) // string[], valid relay urls only
const ids = tagValues(hexTags(['e', 'a']), event.tags) // string[], 32-byte hex only // Multiple types at once
const topics = tagValues(topicTags('t'), event.tags) // string[], normalized const ids = getTagValues(['e', 'a'], event.tags) // string[]
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
@ -497,11 +566,24 @@ 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 } from '@welshman/util' import { getLnUrl, makeEvent, ZAP_REQUEST, zapFromEvent } 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')
@ -522,7 +604,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 = await app.use(Zappers).validateZapReceipt(zapReceipt, zappedEvent) const zap = zapFromEvent(zapReceipt, { nostrPubkey: zapperPubkey, allowsNostr: true, lnurl })
if (zap) { if (zap) {
console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content) console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content)
} }
@ -564,9 +646,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/domain and resolves this package's `RelaySelection` DSL through `app.use(Router)`. - **`@welshman/app`** — high-level application layer; wraps net/store/router and uses profile, list, zap, and handler helpers from this package.
- **`@welshman/domain`** — builds its typed readers on the tag specs (`tagValue`, `hexTags`, `addressTags`) defined here. - **`@welshman/router`** — uses `RelayMode` and relay URL helpers when computing relay selections.
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here, and supplies the encryption used when writing encrypted list kinds. - **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here; the `Encrypt` function type used by `Encryptable` is typically provided by a signer.
--- ---
@ -576,9 +658,11 @@ 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.
- **`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. - **`zapFromEvent` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result.
- **`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, tag specs, NIPs, and the `RelaySelection` routing DSL | | `@welshman/util` | Core nostr types, event helpers, filters, and NIP implementations |
| `@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/domain` | A typed Reader/Writer pair per event kind, so you never hand-parse tags | | `@welshman/router` | Relay selection strategies for reads and writes |
| `@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` | The `App` instance and its plugin registry, composing net, store, domain, signer, and feeds into a full application framework | | `@welshman/app` | High-level Svelte stores that compose net, router, store, 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,43 +27,25 @@ 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/store`, `@welshman/signer`, `@welshman/domain` - **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/router`, `@welshman/store`, `@welshman/signer`
- **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-domain`). For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-signer`).
## The App instance ## Getting started
Everything in the framework hangs off one `App`. An app owns the primitives a single identity Install only what you need:
needs — repository, socket pool, tracker, wrap manager — so data never bleeds across sessions.
```typescript ```bash
import {createApp, Network, Profiles, User} from "@welshman/app" # Full application framework (includes app, net, router, store, signer, feeds)
npm i @welshman/app
// `createApp` = `new App` plus the default policies (ingest, relay stats, gift-wrap unwrapping) # Or assemble manually for more control
const app = createApp({ npm i @welshman/util @welshman/net @welshman/signer
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})
``` ```
Three rules follow from this design: 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.
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
@ -79,67 +61,91 @@ instance. That binding layer is app-specific and deliberately not part of welshm
| Goal | Package(s) to use | | Goal | Package(s) to use |
|---|---| |---|---|
| Fetch notes from relays | `app.use(Network)`, or `@welshman/net` directly for low-level control | | Fetch notes from relays | `@welshman/net` (low-level) or `@welshman/app` (high-level) |
| Select which relays to use | `RelaySelection` helpers in `@welshman/util` + `app.use(Router)` | | Select which relays to use | `@welshman/router` |
| Read or write a specific kind | `@welshman/domain` via `app.use(Domain)` | | Sign and publish events | `@welshman/signer` + `@welshman/net` |
| Sign and publish events | `@welshman/signer` + `Command` from `@welshman/app` | | Build a feed UI | `@welshman/feeds` + `@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` + `app.repository` | | Cache nostr events client-side | `@welshman/store` |
| Core event/filter/tag utilities | `@welshman/util` | | Core event/filter 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 {createApp, Domain, Profiles, User} from "@welshman/app" import "@welshman/app" // side effects: wires pool → repository + tracker + router
import {Note} from "@welshman/domain"
import {Nip07Signer} from "@welshman/signer"
// 1. Build an app for the signed-in user import { openDB } from "idb"
const signer = new Nip07Signer() import { batch, on } from "@welshman/lib"
const app = createApp({ import { verifiedSymbol } from "@welshman/util"
user: await User.fromSigner(signer), import { repository, tracker, loginWithNip07, publishThunk, userProfile, loadUserProfile } from "@welshman/app"
config: { import { routerContext } from "@welshman/router"
getDefaultRelays: () => ["wss://relay.example.com"], import { load } from "@welshman/net"
getIndexerRelays: () => ["wss://indexer.example.com"], import type { TrustedEvent } from "@welshman/util"
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" })
}, },
}) })
// 2. Read the user's profile (loads from their write relays if not cached) const stored: TrustedEvent[] = await db.getAll("events")
const profile = await app.use(Profiles).load(app.user!.pubkey) for (const e of stored) e[verifiedSymbol] = true
repository.load(stored)
console.log("Hello,", profile?.display()) // Flush new events to IndexedDB
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. Publish a note — build a writer, wrap it in a command, publish it // 3. Log in
const writer = app.use(Domain).writer(Note).setContent("Hello, Nostr!") const pk = await window.nostr.getPublicKey()
const command = await app.use(Domain).command(writer) loginWithNip07(pk)
await command.publish().waitForError() // 4. Load user's profile reactively (triggers network fetch if not cached)
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
``` ```
Publishing goes through a `Command`, which owns the rendered event and its resolved relays: ### Lower-level Example
`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, isClientEvent, publish, request} from "@welshman/net" import { AbstractAdapter, ClientMessage, NetContext, isClientEvent, netContext, publish, request } from '@welshman/net'
import type {ClientMessage, NetContext} from "@welshman/net" import { call, sleep } from '@welshman/lib'
import {call, sleep} from "@welshman/lib" import { Nip01Signer } from '@welshman/signer'
import {Nip01Signer} from "@welshman/signer" import { makeEvent, NOTE } from '@welshman/util'
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"
// An adapter for our relay url which just prints the content // Create an adapter for our relay url which just prints the content
export class PrintAdapter extends AbstractAdapter { export class PrintAdapter extends AbstractAdapter {
get sockets() { return [] } get sockets() { return [] }
get urls() { return [] } get urls() { return [] }
@ -151,32 +157,37 @@ export class PrintAdapter extends AbstractAdapter {
} }
} }
// Context is passed explicitly. An `App` supplies its own via `app.netContext`; here we build one. // Configure net context to use our custom adapter
const context: NetContext = { netContext.getAdapter = (url: string, context: NetContext) => {
getAdapter: (url: string) => (url === RELAY_URL ? new PrintAdapter() : undefined), if (url === RELAY_URL) {
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(makeEvent(NOTE, {content: "ping"})) const ping = await pingSigner.sign(
makeEvent(NOTE, {content: 'ping'})
)
await publish({event: ping, relays: [RELAY_URL], context}) await publish({event: ping, relays: [RELAY_URL]})
} }
}) })
// 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], context}) await publish({event: pong, relays: [RELAY_URL]})
}, },
}) })
}) })

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_THEME="clay" VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="The chat app built for self-hosted communities." VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for 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,8 +19,7 @@ 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,7 +67,6 @@ 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
@ -75,7 +74,6 @@ ic_stat_notify.png
.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 for styling - TailwindCSS + DaisyUI 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/* (can only depend on lib/*) app/core/ & app/util/ (can only depend on lib/*)
↓ ↓
lib/ (can only depend on external libraries) lib/ (can only depend on external libraries)
↓ ↓
@ -45,42 +45,76 @@ 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 {app} from "@app/core" import {repository} from "$app/core/state"
``` ```
## Cleanup Pass ## File Structure
These are the things that most often get fixed by hand after the fact. Go through ```
them before handing work back. The full style reference is under Development src/
Conventions below. ├── lib/ # Generic reusable code
│ ├── components/ # 38 UI components (Button, Dialog, etc.)
- **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. │ ├── html.ts # DOM utilities
- **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. │ ├── indexeddb.ts # IndexedDB helpers
- **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. │ └── util.ts # Generic utilities
- **Truthiness** — test values directly (`if (invoice.paid_at)`) instead of comparing against `null`/`undefined`, and put the truthy branch first in a ternary. │
- **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. ├── app/
- **Errors** — toast a human-readable message and `console.error` anything unexpected (e.g. a non-`HostingError`). Never swallow an error you didn't anticipate. │ ├── core/
│ │ ├── 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)
- Everything hangs off the single `App` instance in `app/core.ts`. There are no welshman globals — - Most global state flows through Welshman's `repository` (unidirectional)
reach features with `app.use(Plugin)`, which is memoized per app and cheap to call inline. - Query state using `deriveEventsMapped` or `deriveProfile` etc
- `app/core.ts` also exports `pubkey`, `signer`, `user` and `session` stores (all with `.get()`), - Update state by publishing events via `publishThunk`
`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:**
@ -93,12 +127,12 @@ callbacks and hot paths.
**Welshman Library Suite:** **Welshman Library Suite:**
- `@welshman/app` - The `App` instance and its plugins (Profiles, Rooms, Thunks, Router, …) - `@welshman/app` - High-level state (pubkey, signer, repository, tracker)
- `@welshman/domain` - A typed Reader/Writer pair per event kind, plus `Relay` and `Zapper` - `@welshman/net` - Network layer (Pool, Socket, load, pull, request)
- `@welshman/net` - Network layer (Pool, Socket, adapters, request/publish/pull) - `@welshman/store` - Svelte integration (deriveEventsMapped, etc.)
- `@welshman/store` - Svelte integration (deriveEventsById, deriveItemsByKey, etc.) - `@welshman/util` - Event utilities (kinds, tags, validation)
- `@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
@ -127,30 +161,24 @@ callbacks and hot paths.
- **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 styling with css components customized by theme. See lib/components for examples. - TailwindCSS and DaisyUI styling
- Comments, naming, conditionals and single-use indirection are covered by the Cleanup Pass above. - Only add comments for really weird stuff
- 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.
- 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)`. - Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
- Do not render a profile's `about` directly (e.g. `profile.about()`); use the `ProfileAbout` component instead. - Do not render a profile's `about` directly (e.g. `profile.about`); use the `ProfileInfo` 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:** **Human-First Simplicity (Jon Staab Style):**
- 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.
- 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.
- 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.
@ -173,23 +201,20 @@ callbacks and hot paths.
### Loading Data from Network ### Loading Data from Network
1. Prefer a plugin's `one(key)` / `load(key)`, which handle outbox routing and caching 1. Use utilities from `app/core/requests.ts`
2. For anything else use `app.use(Network).load/request` or `app.use(Sync).pull/push` — the bare 2. Or create derived stores in `app/core/state.ts`
`load`/`request`/`publish` from `@welshman/net` throw without a context 3. Use `load`, `pull`, or `request` from `@welshman/net`
### Publishing Events ### Publishing Events
1. Build a writer: `app.use(Domain).writer(Kind, reader?)`, then chain its setters 1. Create `make*` function to build event template
2. Wrap it: `const command = await app.use(Domain).command(writer)` 2. Create `publish*` function using `publishThunk`
3. Publish it: `command.publish()`, `.publishToRelays(urls)`, or `.publishAsRelay(url)` 3. Display thunk status to user (for cancel/error handling)
4. Display thunk status to user (for cancel/error handling) 4. These go in in `app/core/commands.ts`
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/modal.ts` or `app/toast.ts` - Import from `app/util/modal.ts` or `app/util/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
@ -219,6 +244,15 @@ 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,22 +1,5 @@
# 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 (used only for generated manifest, for more control create a custom theme file) - `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
- `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. Each one may optionally include an invite code, delimited by `|`, e.g. `my.space.com|CODE`. - `VITE_DEFAULT_SPACES` - A comma-separated list of relay urls that new users will be automatically joined to on signup
- `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,6 +33,7 @@ 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 50 versionCode 49
versionName "1.9.0" versionName "1.8.2"
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,19 +36,6 @@
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,8 +1,5 @@
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;
@ -13,28 +10,6 @@ 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"),
state.optString("pubkey", ""), session.optString("pubkey", ""),
session.optJSONObject("data") ?: JSONObject(), session,
) )
} }
@ -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 data: JSONObject, val session: JSONObject,
) )
private data class Subscription( private data class Subscription(
@ -699,23 +699,24 @@ 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.data.optString("secret", "") val secret = sessionInfo.session.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.data.optString("signer", "") val signerPackage = sessionInfo.session.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 clientSecret = sessionInfo.data.optString("clientSecret", "") val handler = sessionInfo.session.optJSONObject("handler") ?: return false
val signerPubkey = sessionInfo.data.optString("signerPubkey", "") val clientSecret = sessionInfo.session.optString("secret", "")
val relays = sessionInfo.data.optJSONArray("relays") val signerPubkey = handler.optString("pubkey", "")
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,5 +4,4 @@
<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,11 +17,6 @@ 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)
@ -40,6 +35,3 @@ 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,10 +26,6 @@ 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/"],
@ -44,15 +40,6 @@ 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 = 41; CURRENT_PROJECT_VERSION = 40;
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.9.0; MARKETING_VERSION = 1.8.2;
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 = 41; CURRENT_PROJECT_VERSION = 40;
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.9.0; MARKETING_VERSION = 1.8.2;
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.9.0", "version": "1.8.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@ -35,7 +35,6 @@
"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",
@ -63,9 +62,8 @@
"@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.3", "@pomade/core": "^0.3.1",
"@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",
@ -73,18 +71,20 @@
"@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.9.0-pre2", "@welshman/app": "^0.8.16",
"@welshman/content": "^0.9.0-pre2", "@welshman/content": "^0.8.16",
"@welshman/domain": "^0.9.0-pre2", "@welshman/editor": "^0.8.16",
"@welshman/editor": "^0.9.0-pre2", "@welshman/feeds": "^0.8.16",
"@welshman/feeds": "^0.9.0-pre2", "@welshman/lib": "^0.8.16",
"@welshman/lib": "^0.9.0-pre2", "@welshman/net": "^0.8.16",
"@welshman/net": "^0.9.0-pre2", "@welshman/router": "^0.8.16",
"@welshman/signer": "^0.9.0-pre2", "@welshman/signer": "^0.8.16",
"@welshman/store": "^0.9.0-pre2", "@welshman/store": "^0.8.16",
"@welshman/util": "^0.9.0-pre2", "@welshman/util": "^0.8.16",
"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,19 +11,9 @@ minimumReleaseAgeExclude:
- '@welshman/feeds' - '@welshman/feeds'
- '@welshman/lib' - '@welshman/lib'
- '@welshman/net' - '@welshman/net'
- '@welshman/domain' - '@welshman/router'
- '@welshman/signer' - '@welshman/signer'
- '@welshman/store' - '@welshman/store'
- '@welshman/util' - '@welshman/util'
overrides: overrides:
'@welshman/app': link:../welshman/packages/app sharp: 0.35.0-rc.0
'@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

@ -1,54 +0,0 @@
#!/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)
}

459
src/app.css Normal file
View file

@ -0,0 +1,459 @@
@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="/apple-touch-icon-180x180.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/icons/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">

View file

@ -1,380 +0,0 @@
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,5 +1,3 @@
import {derived} from "svelte/store"
import {first, groupBy, removeUndefined, spec} from "@welshman/lib"
import { import {
REPORT, REPORT,
ROOM_ADD_MEMBER, ROOM_ADD_MEMBER,
@ -7,48 +5,28 @@ import {
ROOM_LEAVE, ROOM_LEAVE,
ROOM_MEMBERS, ROOM_MEMBERS,
ROOM_REMOVE_MEMBER, ROOM_REMOVE_MEMBER,
hexTags, getPubkeyTagValues,
sortEventsAsc, getTagValue,
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) =>
tagValue(tagSpec(e.kind === ROOM_MEMBERS ? "d" : "h"), e.tags) getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
const reports = $events.filter(spec({kind: REPORT})) const reports = $events.filter(e => e.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
@ -75,7 +53,7 @@ export const deriveSpaceActionItems = (url: string) =>
} }
} }
const roomMembers = getRoomMembers(roomMembershipEvents) const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents))
pendingJoins.push( pendingJoins.push(
...removeUndefined( ...removeUndefined(
@ -94,7 +72,7 @@ export const deriveSpaceActionItems = (url: string) =>
return true return true
} }
return tagValues(hexTags("p"), event.tags).includes(pubkey) return getPubkeyTagValues(event.tags).includes(pubkey)
}) })
) { ) {
return false return false

75
src/app/call/stores.ts Normal file
View file

@ -0,0 +1,75 @@
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))
},
)

99
src/app/call/video.ts Normal file
View file

@ -0,0 +1,99 @@
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

@ -2,7 +2,6 @@
* 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,
@ -15,60 +14,39 @@ import {
supportsAudioOutputSelection, supportsAudioOutputSelection,
type AudioCaptureOptions, type AudioCaptureOptions,
} from "livekit-client" } from "livekit-client"
import {derived, get, writable} from "svelte/store" import {derived, get} from "svelte/store"
import {first, not, nthEq, reject, uniqBy} from "@welshman/lib" import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
import {makeHttpAuth, makeHttpAuthHeader, sortEventsDesc, tagSpec, tagValues} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {deriveDeduplicated} from "@welshman/store" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {makeRoomKey} from "@welshman/app" import {signer} from "@welshman/app"
import type {Room} from "@welshman/app" import {load} from "@welshman/net"
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 {network, user} from "@app/core" import {
import {deriveEventsForUrl} from "@app/repository" currentVoiceRoom,
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",
@ -76,56 +54,312 @@ export enum DeviceKind {
VideoInput = "videoinput", VideoInput = "videoinput",
} }
export const currentCallSession = writable<CallSession | undefined>(undefined) export const switchVoiceActiveDevice = async (
kind: DeviceKind,
export const callState = writable<CallState>(CallState.Disconnected) targetDeviceId: string,
): Promise<void> => {
export const callTargetRoom = writable<Room | undefined>(undefined) const session = get(currentVoiceSession)
if (!session) return
export const speakingParticipants = writable<CallParticipant[]>([]) const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
try {
export const participantMediaState = writable(new Map<string, ParticipantMediaState>()) await session.room.switchActiveDevice(kind, id)
} catch {
export const mediaStateByIdentity = derived( let label: string
[participantMediaState, currentCallSession, callMicMuted], switch (kind) {
([$media, $session, $micMuted]) => case DeviceKind.AudioInput:
(liveKitIdentity: string) => { label = "microphone"
if ($session?.livekit.localParticipant.identity === liveKitIdentity) { break
return {muted: $micMuted, cameraOn: $session.cameraOn} case DeviceKind.AudioOutput:
label = "speaker"
break
case DeviceKind.VideoInput:
label = "camera"
break
}
pushToast({theme: "error", message: `Error changing ${label}`})
}
}
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
muted: !participant.isMicrophoneEnabled,
cameraOn: participant.isCameraEnabled,
})
const deleteParticipant = (identity: string) => {
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...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 = (room: LiveKitRoom) => {
if (room !== activeRoom) return
const next = new Map<string, ParticipantMediaState>()
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) {
next.set(p.identity, participantMediaFrom(p))
}
participantMediaState.set(next)
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}
}, },
) )
export const isParticipantSpeaking = derived( const setUpMicrophone = async (
speakingParticipants, startMuted: boolean,
$participants => (p: CallParticipant) => preferredMicId: string | undefined,
$participants.some(sp => participantKey(sp) === participantKey(p)), participant: LocalParticipant,
) signal?: AbortSignal,
settleSignal?: AbortSignal,
export const videoTrackRevision = writable(0) ): Promise<boolean> => {
if (startMuted) {
export const triggerVideoTrackRevision = () => { return true
videoTrackRevision.update(n => n + 1)
} }
export const isDesktopLayout = new MediaQuery("min-width: 768px", false) let muted = true
let capture: AudioCaptureOptions | undefined = undefined
export const videoCallViewportSync = { if (preferredMicId) {
previousLayout: undefined as ViewportSize | undefined, 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
} }
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split) // 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
export const videoPrimaryTileKey = writable<string | undefined>(undefined) const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
let reconnectAttempt = 0
export const videoTileCount = derived( const clearReconnectSchedule = () => {
[currentCallSession, callState, videoTrackRevision, participantMediaState], if (reconnectTimeout !== undefined) {
([$session, $state]) => { clearTimeout(reconnectTimeout)
if ($state !== CallState.Connected || !$session) return 0 reconnectTimeout = undefined
return countLiveVisualFeeds($session) }
}, reconnectAttempt = 0
) }
const attemptReconnect = async () => {
const target = get(currentVoiceRoom)
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(currentVoiceRoom)) return
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
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,
@ -135,8 +369,8 @@ export const joinVoiceRoom = async (
): Promise<void> => { ): Promise<void> => {
abortJoinVoiceRoom() abortJoinVoiceRoom()
callTargetRoom.set({url, h, id: makeRoomKey(url, h)}) currentVoiceRoom.set(get(deriveRoom(url, h)))
callState.set(CallState.Joining) voiceState.set(VoiceState.Joining)
const controller = new AbortController() const controller = new AbortController()
joinAbortController = controller joinAbortController = controller
@ -150,7 +384,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(currentCallSession)) { if (get(currentVoiceSession)) {
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}),
@ -159,8 +393,8 @@ export const joinVoiceRoom = async (
if (e instanceof AbortError) throw e if (e instanceof AbortError) throw e
}) })
// leaveVoiceRoom flips callState to Disconnected; re-assert Joining. // leaveVoiceRoom flips voiceState to Disconnected; re-assert Joining.
callState.set(CallState.Joining) voiceState.set(VoiceState.Joining)
} }
if (signal.aborted) throw new AbortError() if (signal.aborted) throw new AbortError()
@ -203,7 +437,9 @@ export const joinVoiceRoom = async (
whenAborted(signal), whenAborted(signal),
]) ])
} catch (e) { } catch (e) {
teardownRoom(liveKitRoom) if (activeRoom === liveKitRoom) activeRoom = undefined
liveKitRoom.removeAllListeners()
liveKitRoom.disconnect()
throw e throw e
} }
@ -226,23 +462,25 @@ 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) {
teardownRoom(liveKitRoom) if (activeRoom === liveKitRoom) activeRoom = undefined
liveKitRoom.removeAllListeners()
liveKitRoom.disconnect()
throw new AbortError() throw new AbortError()
} }
callMicMuted.set(muted) voiceMicMuted.set(muted)
currentCallSession.set({ currentVoiceSession.set({
url, url,
h, h,
livekit: liveKitRoom, room: liveKitRoom,
cameraOn: false, cameraOn: false,
screenShareOn: false, screenShareOn: false,
}) })
callState.set(CallState.Connected) voiceState.set(VoiceState.Connected)
clearReconnectSchedule() clearReconnectSchedule()
playJoinSound() playJoinSound()
} catch (e) { } catch (e) {
if (isActive()) callState.set(CallState.Disconnected) if (isActive()) voiceState.set(VoiceState.Disconnected)
if (e instanceof AbortError) { if (e instanceof AbortError) {
clearReconnectSchedule() clearReconnectSchedule()
return return
@ -256,7 +494,7 @@ export const joinVoiceRoom = async (
export const leaveVoiceRoom = async () => { export const leaveVoiceRoom = async () => {
clearReconnectSchedule() clearReconnectSchedule()
const session = get(currentCallSession) const session = get(currentVoiceSession)
if (!session) return if (!session) return
const audio = new Audio("/leave-voice-room.mp3") const audio = new Audio("/leave-voice-room.mp3")
@ -264,7 +502,7 @@ export const leaveVoiceRoom = async () => {
if (session.cameraOn) { if (session.cameraOn) {
try { try {
await session.livekit.localParticipant.setCameraEnabled(false) await session.room.localParticipant.setCameraEnabled(false)
} catch { } catch {
pushToast({theme: "error", message: "Error turning off camera."}) pushToast({theme: "error", message: "Error turning off camera."})
} }
@ -272,438 +510,46 @@ export const leaveVoiceRoom = async () => {
if (session.screenShareOn) { if (session.screenShareOn) {
try { try {
await session.livekit.localParticipant.setScreenShareEnabled(false) await session.room.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.
teardownRoom(session.livekit) if (activeRoom === session.room) activeRoom = undefined
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(currentCallSession) === session) { if (get(currentVoiceSession) === session) {
callState.set(CallState.Disconnected) voiceState.set(VoiceState.Disconnected)
callMicMuted.set(true) videoPrimaryTileKey.set(undefined)
currentCallSession.set(undefined) voiceMicMuted.set(true)
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(currentCallSession) const session = get(currentVoiceSession)
if (!session) return if (!session) return
callMicMuted.update(not) voiceMicMuted.update(not)
if (get(voiceMicMuted)) {
const muted = get(callMicMuted) // Disable and re-enable microphone to trigger permission prompt
try { session.room.localParticipant.setMicrophoneEnabled(false)
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 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 { try {
await Promise.race([ await session.room.localParticipant.setMicrophoneEnabled(true)
participant.setMicrophoneEnabled(true, capture),
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
whenAborted(signal),
])
muted = false
} catch (e) { } catch (e) {
// Timeout or microphone rejection: join muted, the call is still usable. A voiceMicMuted.set(true)
// 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"}) 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
}
const delay = RECONNECT_DELAYS[reconnectAttempt]!
reconnectAttempt++
reconnectTimeout = setTimeout(() => {
reconnectTimeout = undefined
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,14 +1,12 @@
import {derived, readable} from "svelte/store" import {DELETE, PROFILE, getPubkeyTagValues} from "@welshman/util"
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 {DELETE, PROFILE, hexTags, tagValues} from "@welshman/util" import {createSearch, displayProfileByPubkey, pubkey, repository} from "@welshman/app"
import type {TrustedEvent} from "@welshman/util" import {derived, readable} from "svelte/store"
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[]
@ -17,13 +15,13 @@ export type Chat = {
search_text: string search_text: string
} }
export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(user.get().pubkey, pubkeys))) export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(pubkey.get()!, pubkeys)))
export const getChatPubkeysFromEvent = (event: TrustedEvent) => export const getChatPubkeysFromEvent = (event: TrustedEvent) =>
getChatPubkeys(tagValues(hexTags("p"), event.tags).concat(event.pubkey)) getChatPubkeys(getPubkeyTagValues(event.tags).concat(event.pubkey))
export const makeChatId = (pubkeys: string[]) => { export const makeChatId = (pubkeys: string[]) => {
const userPubkey = user.get().pubkey const userPubkey = pubkey.get()!
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
@ -36,13 +34,11 @@ 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
? displayProfile(chat.pubkeys[0]) + " note to self" ? displayProfileByPubkey(chat.pubkeys[0]) + " note to self"
: remove(user.get().pubkey, chat.pubkeys).map(displayProfile).join(" ") : remove(pubkey.get()!, chat.pubkeys).map(displayProfileByPubkey).join(" ")
return chat as Chat return chat as Chat
} }
@ -95,7 +91,7 @@ export const chatsById = call(() => {
let dirty = false let dirty = false
for (const id of removed) { for (const id of removed) {
const event = app.get().repository.getEvent(id) const event = 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) || []) {
@ -114,10 +110,10 @@ export const chatsById = call(() => {
} }
} }
addEvents(app.get().repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}])) addEvents(repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
const unsubscribers = [ const unsubscribers = [
on(app.get().repository, "update", ({added, removed}: RepositoryUpdate) => { on(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)

16
src/app/comments.ts Normal file
View file

@ -0,0 +1,16 @@
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"
type Props = { interface 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 $app.user?.pubkey} {#if $pubkey}
<PrimaryNav> <PrimaryNav>
{@render children?.()} {@render children?.()}
</PrimaryNav> </PrimaryNav>

View file

@ -1,44 +0,0 @@
<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-group flex w-full items-center gap-2"> <label class="input input-bordered 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,8 +45,7 @@
{#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="button button-link" onclick={() => pushModal(InfoBunker)} <Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
>What is a bunker link?</Button>
</p> </p>
{/snippet} {/snippet}
</Field> </Field>

View file

@ -1,18 +1,20 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {getAddress, tagSpec, tagValue} from "@welshman/util" import {getTagValue, getAddress} 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 {user} from "@app/core" import {publishDelete} from "@app/deletes"
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"
@ -25,30 +27,33 @@
const {url, event, showRoom, showActivity}: Props = $props() const {url, event, showRoom, showActivity}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags) const h = getTagValue("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 = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h}) const createReaction = async (template: EventContent) =>
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="button button-neutral button-xs rounded-full"> <Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} /> Posted in #<RoomName {h} {url} />
</Link> </Link>
{/if} {/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip tip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-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 === $user.pubkey} {#if event.pubkey === $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 room members to events online or in real life.</ModalSubtitle> <ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle>
</ModalHeader> </ModalHeader>
{/snippet} {/snippet}
</CalendarEventForm> </CalendarEventForm>

View file

@ -1,24 +1,20 @@
<script lang="ts"> <script lang="ts">
import {LOCALE, secondsToDate} from "@welshman/lib" import {fromPairs, 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 timeEvent = reader(TimeEvent)(event) const start = $derived(parseInt(meta.start))
const start = timeEvent.start()
</script> </script>
{#if start} {#if !isNaN(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-2xl bg-surface-more p-2 sm:flex"> 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">
<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,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {TimeEvent} from "@welshman/domain" import {getTagValue} from "@welshman/util"
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 = {
@ -14,27 +13,21 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const timeEvent = reader(TimeEvent)(event) const initialValues = {
d: getTagValue("d", event.tags)!,
const initialValues = $derived( title: getTagValue("title", event.tags)!,
timeEvent && { location: getTagValue("location", event.tags)!,
d: timeEvent.identifier() ?? "", start: parseInt(getTagValue("start", event.tags)!),
title: timeEvent.title() ?? "", end: parseInt(getTagValue("end", event.tags)!),
location: timeEvent.location() ?? "", content: event.content,
start: timeEvent.start(), }
end: timeEvent.end(),
content: timeEvent.content(),
},
)
</script> </script>
{#if initialValues}
<CalendarEventForm {url} {initialValues}> <CalendarEventForm {url} {initialValues}>
{#snippet header()} {#snippet header()}
<ModalHeader> <ModalHeader>
<ModalTitle>Edit this Event</ModalTitle> <ModalTitle>Edit this Event</ModalTitle>
<ModalSubtitle>Invite other room members to events online or in real life.</ModalSubtitle> <ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle>
</ModalHeader> </ModalHeader>
{/snippet} {/snippet}
</CalendarEventForm> </CalendarEventForm>
{/if}

View file

@ -1,10 +1,11 @@
<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} from "@welshman/lib" import {randomId, HOUR} from "@welshman/lib"
import {publish} from "@welshman/app" import {makeEvent, EVENT_TIME} from "@welshman/util"
import {TimeEvent} from "@welshman/domain" import {publishThunk, waitForThunkError} from "@welshman/app"
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"
@ -15,13 +16,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 DateTimeRangeInput from "@lib/components/DateTimeRangeInput.svelte" import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {publishRoomQuote} from "@app/rooms" import {PROTECTED, publishRoomQuote} 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" import {canEnforceNip70} from "@app/relays"
type Values = { type Values = {
d: string d: string
@ -48,7 +49,7 @@
initialValues = draftKey.get() initialValues = draftKey.get()
} }
const shouldProtect = $relays.hasNip(url, 70) const shouldProtect = canEnforceNip70(url)
const uploading = writable(false) const uploading = writable(false)
@ -82,29 +83,32 @@
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)
.setIdentifier(d)
.setTitle(title)
.setLocation(location)
.setStart(start)
.setEnd(end)
.setContent(content)
.setProtected(protect)
.addTags(...ed.storage.nostr.getEditorTags())
if (h) { if (protect) {
eventWriter.setRoom(url, h) tags.push(PROTECTED)
} else {
eventWriter.forceRelays(url)
} }
const calendarThunk = await command(eventWriter).then(publish) if (h) {
const error = await calendarThunk.waitForError() tags.push(["h", h])
}
const event = makeEvent(EVENT_TIME, {content, tags})
const calendarThunk = publishThunk({event, relays: [url]})
const error = await waitForThunkError(calendarThunk)
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
@ -130,6 +134,7 @@
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) => {
@ -141,6 +146,14 @@
$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)}>
@ -151,7 +164,7 @@
<p>Title*</p> <p>Title*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input flex w-full items-center gap-2"> <label class="input input-bordered 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}
@ -161,17 +174,18 @@
<p>Summary</p> <p>Summary</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="relative z-feature flex gap-2"> <div
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="button button-neutral button-input tip" class="center btn tooltip"
onclick={selectFiles} onclick={selectFiles}
disabled={loading}> disabled={loading}>
{#if $uploading} {#if $uploading}
<Spinner size="xs" /> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
<Icon icon={GallerySend} /> <Icon icon={GallerySend} />
{/if} {/if}
@ -181,10 +195,18 @@
</Field> </Field>
<Field> <Field>
{#snippet label()} {#snippet label()}
<p>Date and time*</p> Start*
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<DateTimeRangeInput bind:start bind:end /> <DateTimeInput bind:value={start} />
{/snippet}
</Field>
<Field>
{#snippet label()}
End*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={end} />
{/snippet} {/snippet}
</Field> </Field>
<Field> <Field>
@ -192,7 +214,7 @@
<p>Location (optional)</p> <p>Location (optional)</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input flex w-full items-center gap-2"> <label class="input input-bordered 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>
@ -200,11 +222,11 @@
</Field> </Field>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="button button-link" onclick={back} disabled={loading}> <Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button class="button button-primary" type="submit" disabled={$uploading || loading}> <Button type="submit" class="btn btn-primary" 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 {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/lib" import {
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 timeEvent = reader(TimeEvent)(event) const start = $derived(parseInt(meta.start))
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">{title}</p> <p class="text-lg">{meta.title || meta.name}</p>
{#if start && end} {#if !isNaN(start) && !isNaN(end)}
{@const isSingleDay = formatTimestampAsDate(start) === formatTimestampAsDate(end)} {@const startDateDisplay = formatTimestampAsDate(start)}
{@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 {getAddress, tagValue, tagSpec} from "@welshman/util" import {getTagValue, getAddress} 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,12 +15,10 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags) const h = getTagValue("h", event.tags)
</script> </script>
<Link <Link class="cv col-3 card2 w-full cursor-pointer" href={makeCalendarPath(url, getAddress(event))}>
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,10 +1,9 @@
<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 = {
@ -13,10 +12,7 @@
} }
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">
@ -24,10 +20,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 location} {#if meta.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">{location}</span> <span class="wrap-break-word">{meta.location}</span>
</span> </span>
{/if} {/if}
</div> </div>

View file

@ -18,14 +18,28 @@
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 {MessagingRelayLists, Thunks} from "@welshman/app" import {
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"
@ -39,12 +53,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 {prependParent} from "@app/rooms" import {makeDelete} from "@app/deletes"
import {prependParent} from "@app/groups"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
type Props = { type Props = {
@ -57,9 +71,8 @@
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($user.pubkey, pubkeys) const others = remove($pubkey!, pubkeys)
const messagingRelayLists = $app.use(MessagingRelayLists).index.$ const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk)))
const showMembers = () => const showMembers = () =>
others.length === 1 others.length === 1
@ -83,26 +96,28 @@
const onSubmit = async (params: EventContent) => { const onSubmit = async (params: EventContent) => {
try { try {
const ptags = others.map(pk => ["p", pk]) const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
// 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 = await prependParent(parent, params) params = prependParent(parent, params)
if (eventToEdit) { if (eventToEdit) {
if (eventToEdit.content === params.content) { if (eventToEdit.content === params.content) {
return return
} }
const command = await $deletes.deleteEvent(eventToEdit) await sendWrapped({
event: makeDelete({event: eventToEdit, protect: false}),
await $wraps.publish({event: command.event, recipients: pubkeys, pow: 16}) recipients: pubkeys,
pow: 16,
})
} }
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags) const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
const imetas = imetaTags.map(tag => tag.slice(1).map(entry => entry.split(" "))) const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
const templates: EventTemplate[] = [] const templates: EventTemplate[] = []
const buffer = [] const buffer = []
@ -127,7 +142,11 @@
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(DIRECT_MESSAGE_FILE, p.value.url.toString(), imeta.filter(nthNe(0, "url"))) addTemplate(
DIRECT_MESSAGE_FILE,
p.value.url.toString(),
imeta.slice(1).filter(nthNe(0, "url")),
)
} else { } else {
buffer.push(p.raw) buffer.push(p.raw)
} }
@ -139,7 +158,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]) =>
$wraps.publish({ sendWrapped({
event, event,
recipients: pubkeys, recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i), delay: $userSettingsValues.send_delay + ms(i),
@ -152,7 +171,7 @@
timeout: 30_000, timeout: 30_000,
children: { children: {
component: ThunkToast, component: ThunkToast,
props: {thunk: $app.use(Thunks).merge(thunks)}, props: {thunk: mergeThunks(thunks)},
}, },
}) })
} finally { } finally {
@ -167,7 +186,7 @@
} }
const canEditEvent = (event: TrustedEvent) => const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $user.pubkey && event.pubkey === $pubkey &&
event.kind === DIRECT_MESSAGE && event.kind === DIRECT_MESSAGE &&
event.created_at >= ago(500, MINUTE) event.created_at >= ago(500, MINUTE)
@ -215,7 +234,7 @@
onMount(() => { onMount(() => {
for (const pubkey of others) { for (const pubkey of others) {
$app.use(MessagingRelayLists).load(pubkey) loadMessagingRelayList(pubkey)
} }
}) })
@ -230,15 +249,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="truncate min-w-0 flex items-center gap-4 whitespace-nowrap"> <div class="ellipsize 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="flex gap-2"> <div class="row-2">
<ProfileCircle pubkey={$user.pubkey} size={5} /> <ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$user.pubkey} /> <ProfileName pubkey={$pubkey!} />
</div> </div>
{:else if others.length === 1} {:else if others.length === 1}
<div class="flex gap-2"> <div class="row-2">
<ProfileCircle pubkey={others[0]} size={5} /> <ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} /> <ProfileName pubkey={others[0]} />
</div> </div>
@ -263,11 +282,11 @@
</div> </div>
</PageBar> </PageBar>
<div class="flex flex-col-reverse gap-4 py-2 bg-surface scroll-container h-screen overflow-y-auto"> <PageContent class="flex flex-col-reverse gap-2 py-2 !mb-0">
{#if missingRelayLists.length > 0} {#if missingRelayLists.length > 0}
<div class="py-12"> <div class="py-12">
<div class="card flex flex-col gap-2 m-auto max-w-md items-center text-center"> <div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="flex gap-2 text-lg text-error"> <p class="row-2 text-lg text-error">
<Icon icon={Danger} /> <Icon icon={Danger} />
Direct messages are not enabled Direct messages are not enabled
</p> </p>
@ -276,7 +295,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>
@ -305,9 +324,9 @@
{@render info?.()} {@render info?.()}
</p> </p>
<div class="h-screen"></div> <div class="h-screen"></div>
</div> </PageContent>
<div class="room__compose bg-surface"> <div class="chat__compose bg-base-200">
<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,7 +8,6 @@
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"
@ -114,11 +113,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="button button-neutral button-square tip tip-right h-10 w-10 min-w-10 rounded-2xl transition-colors" class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
disabled={$uploading || disabled} disabled={$uploading || disabled}
onclick={uploadFiles}> onclick={uploadFiles}>
{#if $uploading} {#if $uploading}
<Spinner size="xs" /> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
<Icon icon={GallerySend} /> <Icon icon={GallerySend} />
{/if} {/if}
@ -128,7 +127,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="button button-primary button-circle tip tip-left h-10 w-10 min-w-10" class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
disabled={$uploading || disabled} disabled={$uploading || disabled}
onclick={submit}> onclick={submit}>
<Icon icon={Plane} /> <Icon icon={Plane} />

View file

@ -12,8 +12,7 @@
</script> </script>
<div <div
class="bg-surface-more relative flex h-8 items-center justify-between border-l-2 border-solid px-2 pr-7 text-xs" 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"
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,28 +1,27 @@
<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"
type Props = { const {
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="bg-surface-more relative border-l-2 border-solid px-2 py-1 pr-8" class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8"
style="border-color: var(--primary)"
transition:slide> transition:slide>
<p class="text-primary text-xs">{verb} @{$display}</p> <p class="text-xs text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id} {#key event.id}
<NoteContentMinimal trimParent {event} /> <NoteContentMinimal trimParent {event} />
{/key} {/key}

View file

@ -1,6 +1,7 @@
<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"
@ -11,7 +12,6 @@
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()
const userRelayList = deriveUserItem(RelayLists) let loading = $state(false)
const back = () => history.back() const back = () => history.back()
@ -29,14 +29,8 @@
loading = true loading = true
try { try {
const relayUrls = $userRelayList?.urls() ?? [] if (getRelaysFromList($userRelayList).length === 0) {
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})
@ -44,8 +38,7 @@
} }
} }
const command = await $messagingRelayLists.setUrls(DEFAULT_MESSAGING_RELAYS) const error = await waitForThunkError(await setMessagingRelays(DEFAULT_MESSAGING_RELAYS))
const error = await command.publish().waitForError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
@ -57,8 +50,6 @@
loading = false loading = false
} }
} }
let loading = $state(false)
</script> </script>
<Modal tag="form" onsubmit={preventDefault(enable)}> <Modal tag="form" onsubmit={preventDefault(enable)}>
@ -69,11 +60,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="button button-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="button button-primary" disabled={loading}> <Button type="submit" class="btn btn-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,7 +11,6 @@
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
@ -22,31 +21,27 @@
const {...props}: Props = $props() const {...props}: Props = $props()
const others = uniq(remove($user.pubkey, props.pubkeys)) const others = uniq(remove($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) {
$messagingRelayLists.load(pk) loadMessagingRelayList(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={cx( class="cursor-pointer border-t border-solid border-base-100 px-3 py-2 transition-colors hover:bg-base-100 {props.class}"
"cursor-pointer border-t border-solid px-3 py-2 transition-colors hover:bg-surface", class:bg-base-100={active}>
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={$user.pubkey} size={5} /> <ProfileCircle pubkey={$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} />
@ -61,12 +56,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 text-primary-content" transition:fade></div> <div class="h-2 w-2 rounded-full bg-primary" 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 === $user.pubkey} {#if props.messages[0].pubkey === $pubkey}
You: You:
{/if} {/if}
</span> </span>

View file

@ -7,13 +7,11 @@
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"
type Props = { interface Props {
pubkeys: string[] pubkeys: string[]
} }
const {pubkeys}: Props = $props() const {pubkeys}: Props = $props()
const back = () => history.back()
</script> </script>
<Modal> <Modal>
@ -23,13 +21,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="card"> <div class="card2 bg-alt">
<Profile {pubkey} /> <Profile {pubkey} />
</div> </div>
{/each} {/each}
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="button button-primary" onclick={back}>Got it</Button> <Button class="btn btn-primary" onclick={() => history.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="button button-neutral" onclick={markAsRead}> <Button class="btn btn-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="button button-neutral" onclick={disableAlerts}> <Button class="btn btn-neutral" onclick={disableAlerts}>
<Icon size={4} icon={BellOff} /> <Icon size={4} icon={BellOff} />
Disable alerts Disable alerts
</Button> </Button>
{:else} {:else}
<Button class="button button-neutral" onclick={enableAlerts}> <Button class="btn btn-neutral" onclick={enableAlerts}>
<Icon size={4} icon={Bell} /> <Icon size={4} icon={Bell} />
Enable alerts Enable alerts
</Button> </Button>

View file

@ -1,9 +1,8 @@
<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} from "@welshman/app" import {thunks, mergeThunks, pubkey, deriveProfileDisplay, sendWrapped} 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"
@ -11,15 +10,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 {
@ -33,18 +32,23 @@
const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props() const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props()
const isOwn = event.pubkey === $user.pubkey const isOwn = event.pubkey === $pubkey
const profileDisplay = $profiles.display(event.pubkey).$ const profileDisplay = deriveProfileDisplay(event.pubkey)
const thunks = $app.use(Thunks).history const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
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 = (reaction: TrustedEvent) => retractWrappedReaction(reaction, pubkeys) const deleteReaction = (event: TrustedEvent) =>
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys, pow: 16})
const createReaction = (values: EventContent) => publishWrappedReaction(event, values, pubkeys) const createReaction = (template: EventContent) =>
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})
@ -62,10 +66,15 @@
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={cx("group flex items-center justify-end gap-1 px-2", {"flex-row-reverse": !isOwn})}> class="group chat flex items-center justify-end gap-1 px-2"
class:chat-start={!isOwn}
class:flex-row-reverse={!isOwn}
class:chat-end={isOwn}>
{#if !isMobile} {#if !isMobile}
<Tippy <Tippy
bind:popover bind:popover
@ -91,15 +100,16 @@
</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 class={cx("chat-bubble", {"chat-bubble--user": isOwn})} onTap={showMobileMenu}> <TapTarget
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" class="border border-solid border-base-content"
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}">
@ -116,7 +126,7 @@
<Content showEntire {event} /> <Content showEntire {event} />
</div> </div>
</TapTarget> </TapTarget>
<div class="flex gap-2 z-feature -mt-4 ml-4"> <div class="row-2 z-feature -mt-4 ml-4">
<ReactionSummary {event} {deleteReaction} {createReaction} noTooltip /> <ReactionSummary {event} {deleteReaction} {createReaction} noTooltip />
</div> </div>
</div> </div>

View file

@ -1,25 +1,27 @@
<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 {reactions, wraps} from "@app/core" import {makeReaction} from "@app/reactions"
type Props = { interface Props {
event: TrustedEvent event: TrustedEvent
pubkeys: string[] pubkeys: string[]
} }
const {event, pubkeys}: Props = $props() const {event, pubkeys}: Props = $props()
const onEmoji = async (emoji: NativeEmoji) => { const onEmoji = (emoji: NativeEmoji) =>
const reaction = await $reactions.react(event, emoji.unicode) sendWrapped({
event: makeReaction({event, content: emoji.unicode, protect: false}),
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16}) recipients: pubkeys,
} pow: 16,
})
</script> </script>
<EmojiButton {onEmoji} class="button button-neutral button-xs join-item"> <EmojiButton {onEmoji} class="btn join-item btn-xs">
<Icon icon={SmileCircle} size={4} /> <Icon icon={SmileCircle} size={4} />
</EmojiButton> </EmojiButton>

View file

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

View file

@ -1,6 +1,7 @@
<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"
@ -12,7 +13,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 {reactions, wraps} from "@app/core" import {makeReaction} from "@app/reactions"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {clip} from "@app/toast" import {clip} from "@app/toast"
@ -25,13 +26,14 @@
const {event, pubkeys, reply, edit}: Props = $props() const {event, pubkeys, reply, edit}: Props = $props()
const onEmoji = async (emoji: NativeEmoji) => { const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
history.back() history.back()
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,
} })
}).bind(undefined, event, pubkeys)
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true}) const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
@ -56,25 +58,25 @@
<Modal> <Modal>
<ModalBody> <ModalBody>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Button class="button button-neutral" onclick={showInfo}> <Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon={Code2} /> <Icon size={4} icon={Code2} />
Message Info Message Info
</Button> </Button>
<Button class="button button-neutral w-full" onclick={copyText}> <Button class="btn btn-neutral w-full" onclick={copyText}>
<Icon size={4} icon={Copy} /> <Icon size={4} icon={Copy} />
Copy Text Copy Text
</Button> </Button>
<Button class="button button-neutral w-full" onclick={sendReply}> <Button class="btn btn-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="button button-neutral w-full" onclick={sendEdit}> <Button class="btn btn-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="button button-primary w-full" onclick={showEmojiPicker}> <Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} /> <Icon size={4} icon={SmileCircle} />
Send Reaction Send Reaction
</Button> </Button>

View file

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

View file

@ -1,26 +1,27 @@
<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 {getAddress} from "@welshman/util" import {getTagValue, getTagValues, getAddress} from "@welshman/util"
import {Classified} from "@welshman/domain" import {pubkey} from "@welshman/app"
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 {reader, user} from "@app/core" import {publishDelete} from "@app/deletes"
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"
type Props = { interface Props {
url: string url: string
event: TrustedEvent event: TrustedEvent
showRoom?: boolean showRoom?: boolean
@ -29,33 +30,34 @@
const {url, event, showRoom, showActivity}: Props = $props() const {url, event, showRoom, showActivity}: Props = $props()
const classified = reader(Classified)(event) const h = getTagValue("h", event.tags)
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 = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h}) const createReaction = async (template: EventContent) =>
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="button button-neutral button-xs rounded-full"> <Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-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="button button-xs rounded-full font-normal"> <button type="button" class="btn btn-xs rounded-full font-normal">
#{normalizeTopic(topic)} #{normalizeTopic(topic)}
</button> </button>
{/each} {/each}
</div> </div>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event}> <ThunkStatusOrDeleted {event}>
<ClassifiedStatus {event} /> <ClassifiedStatus {event} />
</ThunkStatusOrDeleted> </ThunkStatusOrDeleted>
@ -64,7 +66,7 @@
{/if} {/if}
<EventActions {url} {event} noun="Listing"> <EventActions {url} {event} noun="Listing">
{#snippet customActions()} {#snippet customActions()}
{#if event.pubkey === $user.pubkey} {#if event.pubkey === $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 {Classified} from "@welshman/domain" import {getTag, getTagValues} from "@welshman/util"
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,23 +14,14 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const classified = reader(Classified)(event) const {content} = event
const {d, title, status} = fromPairs(event.tags)
const initialValues = $derived( const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
classified && { const images = getTagValues("image", event.tags)
d: classified.identifier() ?? "", const topics = getTagValues("t", event.tags)
title: classified.title() ?? "", const initialValues = {d, title, status, content, price: Number(price), currency, images, topics}
status: classified.status() ?? "",
content: classified.content(),
price: classified.price()?.amount ?? 0,
currency: classified.price()?.currency ?? "SAT",
images: classified.images(),
topics: classified.topics(),
},
)
</script> </script>
{#if initialValues}
<ClassifiedForm {url} {initialValues}> <ClassifiedForm {url} {initialValues}>
{#snippet header()} {#snippet header()}
<ModalHeader> <ModalHeader>
@ -39,4 +30,3 @@
</ModalHeader> </ModalHeader>
{/snippet} {/snippet}
</ClassifiedForm> </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 {publish} from "@welshman/app" import {makeEvent, CLASSIFIED} from "@welshman/util"
import {Classified} from "@welshman/domain" import {publishThunk, waitForThunkError} from "@welshman/app"
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 {publishRoomQuote} from "@app/rooms" import {PROTECTED, publishRoomQuote} from "@app/groups"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts" import {DraftKey} from "@app/drafts"
import {compressFileForUpload, uploadFile} from "@app/uploads" import {canEnforceNip70} from "@app/relays"
import {uploadFile} from "@app/uploads"
type Values = { type Values = {
d: string d: string
@ -51,6 +51,8 @@
initialValues = draftKey.get() initialValues = draftKey.get()
} }
const shouldProtect = canEnforceNip70(url)
const back = () => history.back() const back = () => history.back()
const submit = async () => { const submit = async () => {
@ -74,13 +76,34 @@
}) })
} }
const imageUrls: string[] = [] const tags = [
["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") {
imageUrls.push(image) tags.push(["image", image])
} else { } else {
const {result, error} = await uploadFile(await compressFileForUpload(image), {url}) const {result, error} = await uploadFile(image, {url})
if (error) { if (error) {
return pushToast({ return pushToast({
@ -90,33 +113,17 @@
} }
if (result) { if (result) {
imageUrls.push(result.url) tags.push(["image", result.url])
} }
} }
} }
const protect = await $relays.hasNip(url, 70) const classifiedThunk = publishThunk({
relays: [url],
event: makeEvent(CLASSIFIED, {content, tags}),
})
const eventWriter = writer(Classified) const error = await waitForThunkError(classifiedThunk)
.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})
@ -157,13 +164,13 @@
<Modal tag="form" onsubmit={preventDefault(submit)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody> <ModalBody>
{@render header?.()} {@render header?.()}
<div class="flex flex-col gap-8 relative"> <div class="col-8 relative">
<Field> <Field>
{#snippet label()} {#snippet label()}
<p>Title*</p> <p>Title*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y_autofocus -->
<input <input
autofocus={!isMobile} autofocus={!isMobile}
@ -198,10 +205,10 @@
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<div class="join grid grid-cols-2"> <div class="join grid grid-cols-2">
<label class="input flex w-full items-center gap-2"> <label class="join-item input input-bordered 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 bind:value={currency} /> <CurrencyInput class="join-item" bind:value={currency} />
</div> </div>
{/snippet} {/snippet}
</Field> </Field>
@ -211,7 +218,7 @@
<p>Status*</p> <p>Status*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<select class="select input w-full" bind:value={status}> <select class="select select-bordered 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>
@ -229,11 +236,11 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button onclick={back} disabled={loading} class="button button-link"> <Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" disabled={loading} class="button button-primary"> <Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Save Listing</Spinner> <Spinner {loading}>Save Listing</Spinner>
</Button> </Button>
</ModalFooter> </ModalFooter>

View file

@ -1,11 +1,9 @@
<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 {getAddress} from "@welshman/util" import {getTag, getAddress, getTagValue, getTagValues} 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"
@ -20,22 +18,20 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const classified = reader(Classified)(event) const title = getTagValue("title", event.tags)
const h = getTagValue("h", event.tags)
const title = classified.title() const images = new Set(getTagValues("image", event.tags))
const h = classified.room() const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
const images = new Set(classified.images())
const price = classified.price()
</script> </script>
<Link <Link
class="cv flex flex-col gap-2 card card-interactive w-full" class="cv col-2 card2 w-full cursor-pointer"
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={price?.currency ?? "SAT"} />{price?.amount ?? 0} <CurrencySymbol code={currency} />{price}
</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,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {Classified} from "@welshman/domain" import {getTagValue} from "@welshman/util"
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
@ -11,13 +10,11 @@
const {event}: Props = $props() const {event}: Props = $props()
const classified = reader(Classified)(event) const status = getTagValue("status", event.tags)
const status = classified.status()
</script> </script>
{#if status} {#if status}
<Badge variant={status !== "active" ? "primary" : "neutral"} class="rounded-full"> <div class={cx("btn btn-xs rounded-full", {"btn-primary": status !== "active"})}>
{ucFirst(status)} {ucFirst(status)}
</Badge> </div>
{/if} {/if}

View file

@ -1,14 +1,15 @@
<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"
type Props = { interface Props {
url: string url: string
event: TrustedEvent event: TrustedEvent
segment: string segment: string
@ -17,18 +18,20 @@
const {url, event, segment, showActivity = false}: Props = $props() const {url, event, segment, showActivity = false}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags) const shouldProtect = canEnforceNip70(url)
const path = makeSpacePath(url, segment, event.id) const path = makeSpacePath(url, segment, event.id)
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h}) const createReaction = async (template: EventContent) =>
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="tip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-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-2xl bg-surface p-2" bind:this={ul}> <ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md" bind:this={ul}>
<li> <li>
<Button onclick={createGoal}> <Button onclick={createGoal}>
<Icon size={4} icon={StarFallMinimalistic} /> <Icon size={4} icon={StarFallMinimalistic} />

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import {fromNostrURI, tagSpec, tagValue} from "@welshman/util" import {fromNostrURI} from "@welshman/util"
import {nthEq} from "@welshman/lib"
import { import {
parse, parse,
truncate, truncate,
@ -24,7 +25,6 @@
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"
type Props = { interface 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 = $derived(parse(event)) const fullContent = parse(event)
const expand = () => { const expand = () => {
showEntire = true showEntire = true
@ -97,16 +97,12 @@
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p) const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
let warningDismissed = $state(false)
const ignoreWarning = () => { const ignoreWarning = () => {
warningDismissed = true warning = null
} }
const warning = $derived( let warning = $state(
warningDismissed $userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
? 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>) => {
@ -148,13 +144,13 @@
<div class="relative"> <div class="relative">
{#if warning} {#if warning}
<Card sm class="shadow-none flex gap-2"> <div class="card2 card2-sm shadow-none row-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 onclick={ignoreWarning} class="button button-link">Show anyway</Button> <Button class="link" onclick={ignoreWarning}>Show anyway</Button>
</p> </p>
</Card> </div>
{:else} {:else}
<div <div
class="overflow-hidden text-ellipsis wrap-break-word" class="overflow-hidden text-ellipsis wrap-break-word"
@ -208,9 +204,12 @@
</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 onclick={stopPropagation(preventDefault(expand))} class="button button-neutral"> <button
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="bg-surface-more text-content w-full overflow-auto whitespace-pre rounded px-1 text-sm" class="w-full overflow-auto whitespace-pre rounded bg-neutral px-1 text-neutral-content text-sm"
class:block={isBlock}> class:block={isBlock}>
{value.trim()} {value.trim()}
</code> </code>

View file

@ -1,33 +1,16 @@
<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} from "@welshman/lib" import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
import {isRelayUrl, tagSpec, tagValue} from "@welshman/util" import {isRelayUrl, getTagValue} 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 {PLATFORM_URL, THUMBNAIL_URL} from "@app/env" import {dufflepud, 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/rooms" import {isRoomId} from "@app/groups"
const {value, event} = $props() const {value, event} = $props()
@ -41,7 +24,7 @@
return [url, true] return [url, true]
}) })
const fileType = tagValue(tagSpec("file-type"), event.tags) || "" const fileType = getTagValue("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) {
@ -51,6 +34,16 @@
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
} }
@ -59,37 +52,39 @@
</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> </div>
{:then preview} {:then preview}
<div <div
class="border border-solid flex max-w-xl flex-col overflow-hidden leading-normal rounded-2xl" class="border border-solid border-base-content/20 flex max-w-xl flex-col leading-normal rounded-box">
style="border-color: var(--line)">
{#if preview.image && !hideImage} {#if preview.image && !hideImage}
<img <img
alt="" alt=""
onerror={onError} onerror={onError}
src={preview.image} src={preview.image}
class="bg-surface max-h-72 object-contain object-center" /> class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if} {/if}
<div class="flex flex-col gap-2 p-4"> <div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap" <strong class="overflow-hidden text-ellipsis whitespace-nowrap"
@ -98,12 +93,11 @@
</div> </div>
</div> </div>
{:catch} {:catch}
<p <p class="border border-solid border-base-content/20 p-12 text-center leading-normal">
class="border border-solid p-12 text-center leading-normal rounded-2xl"
style="border-color: var(--line)">
Unable to load a preview for {url} Unable to load a preview for {url}
</p> </p>
{/await} {/await}
{/if} {/if}
</div>
</Link> </Link>
{/if} {/if}

View file

@ -2,35 +2,34 @@
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 =
matchTags(tagSpec("imeta"), event.tags) getTags("imeta", event.tags)
.map(([, ...values]: string[]) => values.map(value => value.split(" "))) .map(tagsFromIMeta)
.find(meta => tagValue(tagSpec("url"), meta) === url) || event.tags .find(meta => getTagValue("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 = tagValue(tagSpec("x"), meta) || url.split(/[\/\.]/).slice(-2)[0] const hash = getTagValue("x", meta) || url.split(/[\/\.]/).slice(-2)[0]
const key = tagValue(tagSpec("decryption-key"), meta) const key = getTagValue("decryption-key", meta)
const nonce = tagValue(tagSpec("decryption-nonce"), meta) const nonce = getTagValue("decryption-nonce", meta)
const algorithm = tagValue(tagSpec("encryption-algorithm"), meta) const algorithm = getTagValue("encryption-algorithm", meta)
const mime = tagValue(tagSpec("m"), meta) const mime = getTagValue("m", meta)
const fileName = const fileName =
tagValue(tagSpec("filename"), meta) || getTagValue("filename", meta) ||
tagValue(tagSpec("name"), meta) || getTagValue("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 = () => {
@ -46,10 +45,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) { if (hash && $signer) {
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 $user.signer.sign(template) const authEvent = await $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-2xl" /> <ContentLinkBlockImage {value} {event} class="m-auto max-h-full max-w-full rounded-box" />
</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 {tagSpec, tagValue} from "@welshman/util" import {getTagValue} 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 = tagValue(tagSpec("file-type"), event.tags) || "" const fileType = getTagValue("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,28 +1,27 @@
<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 {isRoomId} from "@app/rooms" import {displayRoom, isRoomId, splitRoomId} from "@app/groups"
import {makeRoomPath, makeSpacePath} from "@app/routes" import {makeRoomPath, makeSpacePath} from "@app/routes"
type Props = { const {
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] = splitRoomKey(url) const [roomUrl, h] = splitRoomId(url)
if (!roomUrl || !h || !isRelayUrl(roomUrl)) { if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
return undefined return undefined
@ -50,7 +49,8 @@
<Link {external} {href} class={className}> <Link {external} {href} class={className}>
{#if roomReference} {#if roomReference}
#<RoomName {...roomReference} /> ~<span class="text-primary">{displayRelayUrl(roomReference.url)}</span> /
{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,9 +1,10 @@
<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
@ -12,7 +13,7 @@
const {value, url}: Props = $props() const {value, url}: Props = $props()
const display = $profiles.display(value.pubkey).$ const display = deriveProfileDisplay(value.pubkey, removeUndefined([url]))
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url}) const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
</script> </script>

View file

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

View file

@ -1,22 +1,13 @@
<script lang="ts"> <script lang="ts">
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {derived, writable} from "svelte/store" import {Router} from "@welshman/router"
import {removeUndefined} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import { import {Address, MESSAGE} from "@welshman/util"
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"
@ -31,43 +22,22 @@
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 ref = {id, pubkey, kind, identifier, relays} const mergedRelays = Router.get().Quote(event, idOrAddress, relays).getUrls()
const hints = removeUndefined([...relays, url])
const mergedRelays = writable(hints) if (url) {
mergedRelays.push(url)
const quote = deriveEvent(idOrAddress, hints)
// Start with the hints we were handed, then widen to everything the router can work out:
// 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 => const quote = deriveEvent(idOrAddress, mergedRelays)
id const entity = id
? nip19.neventEncode({id, relays: $mergedRelays}) ? nip19.neventEncode({id, relays: mergedRelays})
: new Address(kind, pubkey, identifier, $mergedRelays).toNaddr(), : 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>
@ -78,21 +48,20 @@
<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 py-1 pl-2 opacity-90" class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
style="border-left-color: var(--primary); background-color: color-mix(in srgb, var(--primary) 10%, var(--surface-more) 90%);"> style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 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 rounded-2xl p-4" class="border border-solid border-base-content/20 rounded-box p-4">
style="border-color: var(--line)">
<NoteContentMinimal {url} event={$quote} /> <NoteContentMinimal {url} event={$quote} />
</NoteCard> </NoteCard>
{/if} {/if}
{:else} {:else}
<div class="rounded-2xl p-4"> <div class="rounded-box p-4">
<Spinner loading>Loading event...</Spinner> <Spinner loading>Loading event...</Spinner>
</div> </div>
{/if} {/if}

View file

@ -1,5 +1,4 @@
<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"
@ -46,12 +45,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(spec({code: value}))) const currency = $derived(currencyOptions.find(c => c.code === value))
let wrapper: Element | undefined = $state() let wrapper: Element | undefined = $state()
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
@ -67,13 +66,17 @@
</script> </script>
<button <button
class={cx(props.class, {"bg-surface": currency}, "input flex items-center gap-2 cursor-pointer")} class={cx(
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="min-w-0 truncate text-sm whitespace-nowrap"> <span class="text-sm ellipsize whitespace-nowrap">
{displayCurrency(currency)} {displayCurrency(currency)}
</span> </span>
{:else} {:else}

View file

@ -31,8 +31,7 @@
loading = true loading = true
try { try {
const thunk = await setFeaturedContent(url, content) const error = await setFeaturedContent(url, content)
const error = await thunk.waitForError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
@ -40,9 +39,6 @@
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
} }
@ -72,11 +68,11 @@
</Field> </Field>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="button button-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button class="button button-primary" onclick={submit} disabled={loading}> <Button class="btn btn-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 EmojiButton from "@lib/components/EmojiButton.svelte"
import ZapButton from "@app/components/ZapButton.svelte" import ZapButton from "@app/components/ZapButton.svelte"
import EmojiButton from "@lib/components/EmojiButton.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,46 +26,40 @@
const {url, noun, event, hideZap, customActions}: Props = $props() const {url, noun, event, hideZap, customActions}: Props = $props()
const shouldProtect = $relays.hasNip(url, 70) const shouldProtect = canEnforceNip70(url)
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) =>
const protect = await shouldProtect publishReaction({
const room = tagValue(tagSpec("h"), event.tags) event,
content: emoji.unicode,
const command = await $reactions.react(event, emoji.unicode, writer => { relays: [url],
writer.setProtected(protect) protect: await shouldProtect,
if (room) {
writer.setRoom(url, room)
}
}) })
return command.publishToRelays([url])
}
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
</script> </script>
<div class="items-center join"> <div class="join items-center rounded-full">
{#if ENABLE_ZAPS && !hideZap} {#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="button button-neutral button-xs join-item"> <ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
<Icon icon={Bolt} size={4} /> <Icon icon={Bolt} size={4} />
</ZapButton> </ZapButton>
{/if} {/if}
<EmojiButton {onEmoji} class="button button-neutral button-xs join-item"> <EmojiButton {onEmoji} class="btn join-item btn-neutral btn-xs">
<Icon icon={SmileCircle} size={4} /> <Icon icon={SmileCircle} size={4} />
</EmojiButton> </EmojiButton>
<Button onclick={showPopover} class="flex join-item button button-neutral button-xs">
<Tippy <Tippy
class="flex"
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} />
</Tippy>
</Button> </Button>
</Tippy>
</div> </div>

View file

@ -2,32 +2,33 @@
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 = deriveEvents(filters) const replies = deriveArray(deriveEventsById({repository, 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(() => {
$network.load({relays: [url], filters}) load({relays: [url], filters})
}) })
</script> </script>
<div class="flex-inline button button-neutral button-xs gap-1 rounded-full"> <div class="flex-inline btn btn-neutral btn-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="button button-neutral button-xs relative rounded-full"> <div class="btn btn-neutral btn-xs relative rounded-full">
{#if gt(lastActive, $checked)} {#if gt(lastActive, $checked)}
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div> <div class="h-2 w-2 rounded-full bg-primary"></div>
{/if} {/if}
Active {formatTimestampRelative(lastActive)} Active {formatTimestampRelative(lastActive)}
</div> </div>

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

View file

@ -1,23 +1,22 @@
<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, seen, toNostrURI} from "@welshman/util" import {displayRelayUrl} 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 = {
@ -27,26 +26,19 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const seenOn = $app.tracker.getRelays(event.id) const relays = url ? [url] : Router.get().Event(event).getUrls()
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>
@ -68,9 +60,9 @@
<p>Event Link</p> <p>Event Link</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={FileText} /> <Icon icon={FileText} />
<input type="text" class="truncate min-w-0 grow" value={nevent1} /> <input type="text" class="ellipsize 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>
@ -82,9 +74,9 @@
<p>Author Pubkey</p> <p>Author Pubkey</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={UserCircle} /> <Icon icon={UserCircle} />
<input type="text" class="truncate min-w-0 grow" value={npub1} /> <input type="text" class="ellipsize 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>
@ -99,24 +91,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)}
<Badge class="bg-surface flex gap-1"> <span class="bg-alt badge flex gap-1">
{displayRelayUrl(url)} {displayRelayUrl(url)}
</Badge> </span>
{/each} {/each}
</div> </div>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
{/if} {/if}
<div class="relative"> <div class="relative">
<pre class="card card-sm overflow-auto text-xs"><code>{json}</code></pre> <pre class="card2 card2-sm bg-alt 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="button button-neutral button-sm flex items-center"> <Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
<Icon icon={Copy} /> Copy <Icon icon={Copy} /> Copy
</Button> </Button>
</p> </p>
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="button button-primary grow" onclick={back}>Got it</Button> <Button class="btn btn-primary grow" onclick={() => history.back()}>Got it</Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View file

@ -1,25 +1,27 @@
<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} from "@welshman/util" import {COMMENT, ManagementMethod} from "@welshman/util"
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl" import {pubkey, repository, relaysByUrl, manageRelay} from "@welshman/app"
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 PinboardSelect from "@app/components/PinboardSelect.svelte" import {hasNip29} from "@app/relays"
import {shareEventToChat} from "@app/share" import {deriveUserIsSpaceAdmin} from "@app/members"
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 {app, relayManagement, user} from "@app/core" import {makeSpaceChatPath} from "@app/routes"
type Props = { type Props = {
url: string url: string
@ -38,9 +40,14 @@
const showInfo = () => pushModal(EventInfo, {url, event}) const showInfo = () => pushModal(EventInfo, {url, event})
const addToLibrary = () => pushModal(PinboardSelect, {url, event}) const share = async () => {
if (hasNip29($relaysByUrl.get(url))) {
const share = () => shareEventToChat(url, noun, event) pushModal(EventShare, {url, event})
} else {
setKey("share", event)
goto(makeSpaceChatPath(url))
}
}
const showDelete = () => pushModal(EventDeleteConfirm, {url, event}) const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
@ -49,13 +56,16 @@
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 $relayManagement.forUrl(url).banEvent(event.id) const {error} = await manageRelay(url, {
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!"})
$app.repository.removeEvent(event.id) repository.removeEvent(event.id)
history.back() history.back()
} }
}, },
@ -68,13 +78,7 @@
}) })
</script> </script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}> <ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md" 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}>
@ -84,13 +88,13 @@
</li> </li>
{/if} {/if}
<li> <li>
<Button onclick={addToLibrary}> <Button onclick={showInfo}>
<Icon size={4} icon={GalleryWide} /> <Icon size={4} icon={Code2} />
Add to Library {noun} Details
</Button> </Button>
</li> </li>
{@render customActions?.()} {@render customActions?.()}
{#if event.pubkey === $user.pubkey} {#if event.pubkey === $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,21 +2,19 @@
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 {prependParent} from "@app/rooms" import {publishComment} from "@app/comments"
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
@ -34,6 +32,7 @@
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
@ -46,6 +45,10 @@
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",
@ -54,20 +57,11 @@
} }
if (parent) { if (parent) {
;({content, tags} = await prependParent(parent, {content, tags}, url)) ;({content, tags} = 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(thunk) onSubmit(publishComment({event, content, tags, relays: [url]}))
} }
let form: HTMLElement let form: HTMLElement
@ -107,7 +101,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="card mx-2 my-2 shadow-md"> <div class="card2 mx-2 my-2 bg-alt shadow-md">
{#if parent} {#if parent}
<ChatComposeParent event={parent} clear={() => onClearParent?.()} verb="Replying to" /> <ChatComposeParent event={parent} clear={() => onClearParent?.()} verb="Replying to" />
{/if} {/if}
@ -117,18 +111,18 @@
</div> </div>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tip tip-left absolute bottom-1 right-2" class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles}> onclick={selectFiles}>
{#if $uploading} {#if $uploading}
<Spinner size="xs" /> <span class="loading loading-spinner loading-xs"></span>
{: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="button button-link" onclick={onClose}>Cancel</Button> <Button class="btn btn-link" onclick={onClose}>Cancel</Button>
<Button type="submit" class="button button-primary">Post Reply</Button> <Button type="submit" class="btn btn-primary">Post Reply</Button>
</div> </div>
</div> </div>
</form> </form>

View file

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

View file

@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {ZapGoal} from "@welshman/domain" import {getTagValue} from "@welshman/util"
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 {
@ -21,22 +22,23 @@
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 goal = reader(ZapGoal)(event) const h = getTagValue("h", event.tags)
const shouldProtect = canEnforceNip70(url)
const h = goal.room() const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url}) const createReaction = async (template: EventContent) =>
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="button button-neutral button-xs rounded-full"> <Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<RoomName {h} {url} /> Posted in #<RoomName {h} {url} />
</Link> </Link>
{/if} {/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-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 {ZapGoal} from "@welshman/domain" import {makeEvent, ZAP_GOAL} from "@welshman/util"
import {publish} from "@welshman/app" import {publishThunk, waitForThunkError} 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 {publishRoomQuote} from "@app/rooms" import {PROTECTED, publishRoomQuote} from "@app/groups"
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 = $relays.hasNip(url, 70) const shouldProtect = canEnforceNip70(url)
const uploading = writable(false) const uploading = writable(false)
@ -73,25 +73,32 @@
}) })
} }
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)
.setTitle(title)
.setSummary(content)
.setAmount(amount)
.setUrls([url])
.setProtected(protect)
.addTags(...ed.storage.nostr.getEditorTags())
.forceRelays(url)
if (h) { if (protect) {
eventWriter.setRoom(url, h) tags.push(PROTECTED)
} }
const goalThunk = await command(eventWriter).then(publish) if (h) {
const error = await goalThunk.waitForError() tags.push(["h", h])
}
const goalThunk = publishThunk({
relays: [url],
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})
@ -138,13 +145,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="flex flex-col gap-8 relative"> <div class="col-8 relative">
<Field> <Field>
{#snippet label()} {#snippet label()}
<p>Title*</p> <p>Title*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<label class="input flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y_autofocus -->
<input <input
autofocus={!isMobile} autofocus={!isMobile}
@ -168,24 +175,24 @@
</Field> </Field>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tip tip-left absolute bottom-1 right-2" class="tooltip tooltip-left absolute bottom-1 right-2"
onclick={selectFiles} onclick={selectFiles}
disabled={loading}> disabled={loading}>
{#if $uploading} {#if $uploading}
<Spinner size="xs" /> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
<Icon icon={Paperclip} size={3} /> <Icon icon={Paperclip} size={3} />
{/if} {/if}
</Button> </Button>
</div> </div>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-1">
<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 flex w-auto items-center gap-2"> <label class="input input-bordered 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>
@ -194,7 +201,7 @@
{/snippet} {/snippet}
</FieldInline> </FieldInline>
<input <input
class="range w-full" class="range range-primary -mt-2 w-full"
type="range" type="range"
min="1000" min="1000"
max="100000" max="100000"
@ -204,11 +211,11 @@
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="button button-link" onclick={back} disabled={loading}> <Button class="btn btn-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button type="submit" class="button button-primary" disabled={$uploading || loading}> <Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
<Spinner {loading}>Create Goal</Spinner> <Spinner {loading}>Create Goal</Spinner>
</Button> </Button>
</ModalFooter> </ModalFooter>

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 {ZapGoal} from "@welshman/domain" import {getTagValue} from "@welshman/util"
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"
@ -17,17 +16,12 @@
const {url, event}: Props = $props() const {url, event}: Props = $props()
const goal = reader(ZapGoal)(event) const summary = getTagValue("summary", event.tags)
const h = getTagValue("h", event.tags)
const title = goal.title()
const summary = goal.summary()
const h = goal.room()
</script> </script>
<Link <Link class="cv col-2 card2 w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
class="cv flex flex-col gap-2 card card-interactive w-full" <p class="text-2xl">{event.content}</p>
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,16 +1,12 @@
<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 {TrustedEvent} from "@welshman/util" import type {Zap, TrustedEvent} from "@welshman/util"
import {fromMsats, ZAP_RECEIPT} from "@welshman/util" import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
import {ZapGoal} from "@welshman/domain" import {deriveItemsByKey, deriveArray} from "@welshman/store"
import type {Zap} from "@welshman/domain" import {repository, getValidZap} from "@welshman/app"
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
@ -20,23 +16,18 @@
const {url, event, ...props}: Props = $props() const {url, event, ...props}: Props = $props()
const receipts = deriveEvents([{kinds: [ZAP_RECEIPT], "#e": [event.id]}]) const zaps = deriveArray(
deriveItemsByKey<Zap>({
const zaps = derived<typeof receipts, Zap[]>( repository,
receipts, getKey: zap => zap.response.id,
($receipts, set) => $app.use(Zappers).validZapReceipts($receipts, event).$.subscribe(set), filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
[], eventToItem: (response: TrustedEvent) => getValidZap(response, event),
}),
) )
const goal = reader(ZapGoal)(event) const goalAmount = parseInt(getTagValue("amount", event.tags) || "0")
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
const goalAmount = goal.amount() ?? 0 const contributorsCount = $derived(uniq($zaps.map(zap => zap.request.pubkey)).length)
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>
@ -55,8 +46,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" value={zapAmount} max={goalAmount}></progress> <progress class="progress progress-primary" value={zapAmount} max={goalAmount}></progress>
<ZapButton {url} {event} class="button button-primary lg:m-auto lg:px-20"> <ZapButton {url} {event} class="btn btn-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 flex w-full items-center gap-2"> <label class="input input-bordered 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-2xl transition-colors hover:bg-primary hover:text-primary-content" class="flex aspect-square items-center justify-center rounded-box 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-2xl bg-surface p-4 shadow-2xl"> <div class="w-96 rounded-box bg-base-100 p-4 shadow-2xl">
<IconPicker {onSelect} /> <IconPicker {onSelect} />
</div> </div>

View file

@ -6,8 +6,6 @@
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
@ -39,5 +37,5 @@
href="https://nostrapps.com#signers">nostrapps.com</Link href="https://nostrapps.com#signers">nostrapps.com</Link
>. >.
</p> </p>
<Button class="button button-primary" onclick={back}>Got it</Button> <Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
</ModalBody></Modal> </ModalBody></Modal>

View file

@ -6,8 +6,6 @@
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
@ -29,5 +27,5 @@
class="underline">nostr.how</Link class="underline">nostr.how</Link
>. >.
</p> </p>
<Button class="button button-primary" onclick={back}>Got it</Button> <Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
</ModalBody></Modal> </ModalBody></Modal>

View file

@ -1,4 +1,5 @@
<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"
@ -10,10 +11,8 @@
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()
@ -37,7 +36,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 isPomadeSession($session)} {#if $session?.email}
<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}
@ -45,16 +44,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="button button-primary" onclick={back}>Got it</Button> <Button class="btn btn-primary" onclick={back}>Got it</Button>
{/if} {/if}
</ModalBody> </ModalBody>
{#if isPomadeSession($session)} {#if $session?.email}
<ModalFooter> <ModalFooter>
<Button class="button button-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button class="button button-primary" onclick={startRecoveryRequest}> <Button class="btn btn-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,8 +5,6 @@
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
@ -30,5 +28,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="button button-primary" onclick={back}>Got it</Button> <Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
</ModalBody></Modal> </ModalBody></Modal>

View file

@ -7,8 +7,6 @@
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>
@ -29,6 +27,6 @@
</p> </p>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="button button-primary" onclick={back}>Got it</Button> <Button class="btn btn-primary" onclick={() => history.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="button button-primary" onclick={back}>Got it</Button> <Button class="btn btn-primary" onclick={back}>Got it</Button>
</ModalBody></Modal> </ModalBody></Modal>

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {Zappers} from "@welshman/app" import {deriveZapperForPubkey} 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,15 +9,10 @@
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"
type Props = { const {pubkey} = $props()
pubkey: string
}
const {pubkey}: Props = $props() const zapper = deriveZapperForPubkey(pubkey)
const zapper = $app.use(Zappers).forPubkey(pubkey).$
const back = () => history.back() const back = () => history.back()
</script> </script>
@ -37,7 +32,7 @@
</p> </p>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button class="button button-link" onclick={back}> <Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>

View file

@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import {randomId} from "@welshman/lib" import {randomId, call} from "@welshman/lib"
import {preventDefault, stopPropagation} from "@lib/html" import {preventDefault, stopPropagation, compressFile} 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 Spinner from "@lib/components/Spinner.svelte" import {uploadFile} from "@app/uploads"
import {compressFileForUpload, uploadFileOrFallback} from "@app/uploads"
interface Props { interface Props {
file?: File | undefined file?: File | undefined
@ -17,10 +16,6 @@
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
} }
@ -36,27 +31,11 @@
const onDrop = async (e: any) => { const onDrop = async (e: any) => {
active = false active = false
submit(e.dataTransfer.files[0]) file = await compressFile(e.dataTransfer.files[0])
} }
const onChange = async (e: any) => { const onChange = async (e: any) => {
submit(e.target.files[0]) file = await compressFile(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 = () => {
@ -66,42 +45,65 @@
} }
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 <input {id} type="file" accept="image/*" onchange={onChange} class="hidden" />
{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}); border-color: {active ? 'var(--primary)' : 'var(--line)'};" style="background-image: url({url});"
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="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: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))}>
<button <div
onclick={url ? stopPropagation(onClear) : undefined} class="absolute right-0 top-0 h-5 w-5 overflow-hidden rounded-full bg-primary"
class="absolute right-0 top-0 h-5 w-5 overflow-hidden rounded-full flex items-center justify-center" class:bg-error={url}
style="background: {url ? 'var(--error)' : 'var(--primary)'};"> class:bg-primary={!url}>
{#if url} {#if url}
<Icon icon={CloseCircle} class="scale-150 bg-surface-more!" /> <span
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-surface-more!" /> <Icon icon={AddCircle} class="scale-150 bg-base-300!" />
{/if} {/if}
</button>
{#if loading}
<div class="absolute inset-0 flex items-center justify-center rounded-full bg-black/40">
<Spinner />
</div> </div>
{:else if !url} {#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