Migrate to new welshman
This commit is contained in:
parent
d02e0ed56a
commit
9a80d059bb
306 changed files with 5106 additions and 6272 deletions
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"Stop": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"statusMessage": "Checking added comments",
|
|
||||||
"command": "cd \"${CLAUDE_PROJECT_DIR:-.}\" && git diff HEAD -U0 -- src | grep -E '^\\+' | grep -v '^+++' | grep -E '//|/\\*|\\*/|<!--' | sed 's/^+[[:space:]]*//' | jq -R -s 'select(length > 0) | {systemMessage: (\"Comment lines added to src/ — check each against the Cleanup Pass rule in AGENTS.md. Only genuinely surprising things: a workaround, an external constraint, an invariant not visible from the code. Not restating the next line, not justifying an ordinary decision.\\n\\n\" + .)}' 2>/dev/null || true"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,634 +1,279 @@
|
||||||
---
|
---
|
||||||
name: welshman-app
|
name: welshman-app
|
||||||
description: "Use this skill when working with @welshman/app: high-level Svelte stores for nostr apps, session management, WoT (web of trust), making requests, publishing events, user data, or relay selection at the app layer."
|
description: "Use this skill when working with @welshman/app: the App instance and its plugins, sessions and login, publishing via Commands and Thunks, app policies, WoT, feeds, sync, or relay selection at the app layer."
|
||||||
---
|
---
|
||||||
|
|
||||||
# welshman/app — Application Layer Stores
|
# welshman/app — The App Instance and its Plugins
|
||||||
|
|
||||||
`@welshman/app` is the top-level application framework in the welshman stack. It wires together `@welshman/net` (subscriptions/publishing), `@welshman/store` (reactive collections), `@welshman/router` (relay selection), and `@welshman/signer` (key management) into ready-to-use Svelte stores and high-level utilities. It powers production apps like Coracle and Flotilla.
|
`@welshman/app` composes `net`, `store`, `domain`, `signer`, and `feeds` into an application
|
||||||
|
framework built around a single `App` object.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @welshman/app
|
npm i @welshman/app
|
||||||
# or
|
|
||||||
pnpm add @welshman/app
|
|
||||||
yarn add @welshman/app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Exports
|
## The App
|
||||||
|
|
||||||
### Core Singletons
|
```typescript
|
||||||
|
import {App, createApp, User} from "@welshman/app"
|
||||||
|
|
||||||
| Export | Description |
|
const app = createApp({
|
||||||
|
user: await User.fromSigner(signer), // omit for a signed-out app
|
||||||
|
config: {
|
||||||
|
dufflepudUrl: "https://dufflepud.example.com",
|
||||||
|
getDefaultRelays: () => ["wss://relay.example.com"],
|
||||||
|
getIndexerRelays: () => ["wss://indexer.example.com"],
|
||||||
|
getSearchRelays: () => ["wss://search.example.com"],
|
||||||
|
},
|
||||||
|
getAdapter, // optional: custom net adapters (tests, mocks)
|
||||||
|
policies, // optional: overrides the defaults
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
An `App` owns everything scoped to one identity:
|
||||||
|
|
||||||
|
| Property | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `repository` | Singleton `Repository` from `@welshman/net`; non-DVM, non-ephemeral events received from the pool are stored here. WRAP (NIP-59) events are handled separately via `unwrapAndStore` and require `shouldUnwrap` to be set to `true` to process. |
|
| `app.user` | the signed-in `User`, or `undefined` |
|
||||||
| `tracker` | Singleton `Tracker`; maps event IDs to the relays they were seen on |
|
| `app.config` | the `AppConfig` above |
|
||||||
|
| `app.repository` | this identity's event store |
|
||||||
|
| `app.tracker` | which relays each event was seen on |
|
||||||
|
| `app.pool` | socket pool |
|
||||||
|
| `app.wrapManager` | NIP-59 gift wrap bookkeeping |
|
||||||
|
| `app.netContext` | `{pool, repository, getAdapter}` for the net layer |
|
||||||
|
| `app.use(Plugin)` | resolve a per-app plugin singleton |
|
||||||
|
| `app.cleanup()` | run policy teardown and clear pool/tracker/repository |
|
||||||
|
|
||||||
### Session Management
|
`createApp` is `new App` plus `defaultAppPolicies`. Use `new App({policies: [...]})` for a bare app.
|
||||||
|
|
||||||
| Export | Description |
|
**An app is scoped to one identity.** To log in, build a *new* app and `cleanup()` the old one —
|
||||||
|
never attach a user to an existing app. That's what keeps one account's data out of another's
|
||||||
|
repository.
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
`app.use(Ctor)` constructs the plugin on first use and memoizes it per app, so calling it inline
|
||||||
|
is cheap and idiomatic:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.use(Profiles).load(pubkey)
|
||||||
|
app.use(RelayLists).writeUrls(pubkey).get()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin base classes
|
||||||
|
|
||||||
|
| Base | Shape |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pubkey` | `Writable<string \| undefined>` — active session's pubkey |
|
| `MapPlugin<T>` | a plain keyed map of non-event data (relay stats, NIP-11 info) |
|
||||||
| `session` | `Readable<Session \| undefined>` — derived from `pubkey` + `sessions` |
|
| `LoadableMapPlugin<T>` | a `MapPlugin` that knows how to `fetch(key)` from the network |
|
||||||
| `sessions` | `Writable<Record<string, Session>>` — all loaded sessions |
|
| `DerivedPlugin<T>` | a keyed collection **derived from the repository** — the repository is the source of truth, never a duplicated map |
|
||||||
| `signer` | `Readable<ISigner \| undefined>` — signer for the active session |
|
| `RelayScopedDerivedPlugin<T>` | keyed by `getKey(item, url)` per relay, for data that only means something relative to a relay |
|
||||||
| `signerLog` | `WritableWithGetter<SignerLogEntry[]>` — writable store that the session layer appends signer-operation entries to (useful for UI feedback during remote signing) |
|
| `RelaySignedDerivedPlugin<T>` | the same, but only accepts events authored by the relay's NIP-11 `self` pubkey (NIP-29 room state, relay membership/roles) |
|
||||||
| `SessionMethod` | Enum: `Nip01`, `Nip07`, `Nip46`, `Nip55`, `Pomade`, `Pubkey`, `Anonymous` |
|
|
||||||
|
|
||||||
**Login functions** (all call `addSession` internally):
|
Derived plugins expose:
|
||||||
|
|
||||||
|
- `index` — `Projection<ItemsByKey<T>>`
|
||||||
|
- `all` — `Projection<T[]>`
|
||||||
|
- `one(key)` — a store for a single key, loading it on first subscribe
|
||||||
|
- `get(key)` — synchronous snapshot
|
||||||
|
- `load(key)` / `forceLoad(key)` — network fetch (cached / uncached)
|
||||||
|
- `project(key, read)` — a `Projection` derived from one key
|
||||||
|
|
||||||
|
A **`Projection<T>` is `{get(): T, $: Readable<T>}`** — bind `.$` in markup, call `.get()` in
|
||||||
|
callbacks and hot paths. Build new ones with `projection(store)` or `projectFrom(source, read)`.
|
||||||
|
|
||||||
|
### Available plugins
|
||||||
|
|
||||||
|
**Core:** `Network`, `Router`, `Domain`, `Thunks`, `Sync`, `Logger`, `Plaintext`
|
||||||
|
|
||||||
|
**Relays:** `Relays` (NIP-11), `RelayStats`, `RelayManagement` (NIP-86), `RelayLists`,
|
||||||
|
`BlockedRelayLists`, `SearchRelayLists`, `MessagingRelayLists`, `BlossomServerLists`
|
||||||
|
|
||||||
|
**People:** `Profiles`, `FollowLists`, `MuteLists`, `Handles`, `Zappers`, `Wot`, `Topics`
|
||||||
|
|
||||||
|
**Content:** `Reactions`, `Deletes`, `Pins`, `Pinboards`, `Feeds`, `FeedLists`, `Wraps`
|
||||||
|
|
||||||
|
**NIP-29 / membership:** `Rooms`, `RoomLists`, `RoomPinLists`, `RelayMemberLists`, `RelayRoles`
|
||||||
|
|
||||||
|
## Sessions and login
|
||||||
|
|
||||||
|
A `Session` is `{method, ...data}`, serializable so you can persist it. Handlers convert one into
|
||||||
|
a signer: `nip01`, `nip07`, `nip46`, `nip55`, `pomade`, plus `registerSessionHandler` for your own.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
loginWithNip01(secret: string): void
|
import {User, createApp, nip07, toSession} from "@welshman/app"
|
||||||
loginWithNip07(pubkey: string): void
|
|
||||||
loginWithNip46(pubkey, clientSecret, signerPubkey, relays): void
|
const session = toSession(nip07, {pubkey})
|
||||||
loginWithNip55(pubkey: string, signerPackageName: string): void
|
const user = await User.fromSession(session) // undefined if the handler can't build a signer
|
||||||
loginWithPomade(pubkey, email, clientOptions): void
|
|
||||||
loginWithPubkey(pubkey: string): void // read-only
|
const app = createApp({user})
|
||||||
```
|
```
|
||||||
|
|
||||||
**Session utilities**:
|
`User` wraps a signer and pubkey:
|
||||||
|
|
||||||
|
- `User.fromSigner(signer)` / `User.fromSession(session)`
|
||||||
|
- `User.require(app)` — the signed-in user or **throws**; use on paths that require login
|
||||||
|
- `user.sign(event)`, `user.wrapSigner(fn)`
|
||||||
|
|
||||||
|
Persist the `Session`, not the `User` — rebuild the user on startup and construct the app with it.
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
Two layers, and you usually want the first.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
A `Command` owns a rendered event plus the relays routing resolved for it:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
addSession(session: Session): void // add and activate
|
import {Domain, publish} from "@welshman/app"
|
||||||
dropSession(pubkey: string): void // remove and clean up signer
|
import {Note} from "@welshman/domain"
|
||||||
getSession(pubkey: string): Session | undefined
|
|
||||||
updateSession(pubkey, fn): void
|
const writer = app.use(Domain).writer(Note).setContent("hello")
|
||||||
clearSessions(): void
|
const command = await app.use(Domain).command(writer)
|
||||||
nip46Perms: string // default NIP-46 permission string
|
|
||||||
|
command.publish() // to the resolved relays
|
||||||
|
command.publishToRelays(urls) // to specific relays
|
||||||
|
command.publishAsRelay(url) // signed by the relay itself (NIP-86)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gift wrap (NIP-59)**:
|
Plugin mutators already return a `Command`, so `.then(publish)` is the common shape:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
shouldUnwrap: Writable<boolean> // must be true to process incoming wraps
|
await app.use(FollowLists).follow(["p", pubkey]).then(publish)
|
||||||
wrapManager: WrapManager // tracks wrap↔rumor mappings
|
await app.use(RelayLists).addWriteUrl(url).then(publish)
|
||||||
unwrapAndStore(wrap: SignedEvent): Promise<void>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Publishing — Thunks
|
Free-function forms exist for pipelines: `publish`, `publishToRelays(urls)`,
|
||||||
|
`publishAsRelay(url)`, `signAsRelay(url)`.
|
||||||
|
|
||||||
| Export | Description |
|
### Thunks
|
||||||
|---|---|
|
|
||||||
| `publishThunk(options: ThunkOptions): Thunk` | Create, enqueue, and optimistically publish an event |
|
|
||||||
| `Thunk` | Class representing a single in-flight publish |
|
|
||||||
| `MergedThunk` | Aggregates multiple `Thunk`s (used by `sendWrapped`) |
|
|
||||||
| `thunks` | `Writable<Thunk[]>` — all active thunks |
|
|
||||||
| `abortThunk(thunk)` | Abort all constituent thunks |
|
|
||||||
| `retryThunk(thunk)` | Re-publish with original options |
|
|
||||||
| `mergeThunks(thunks[])` | Combine into a `MergedThunk` |
|
|
||||||
|
|
||||||
**Thunk status helpers**:
|
`app.use(Thunks).publish({event, relays, delay})` publishes optimistically: the event lands in the
|
||||||
|
local repository immediately, so the UI updates before the network settles. The returned `Thunk`
|
||||||
|
is a store you can render:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
thunkIsComplete(thunk): boolean
|
const thunk = app.use(Thunks).publish({event, relays})
|
||||||
getThunkError(thunk): string | undefined
|
|
||||||
getThunkUrlsWithStatus(statuses, thunk): string[]
|
thunk.getUrlsWithStatus(PublishStatus.Success)
|
||||||
getCompleteThunkUrls(thunk): string[]
|
thunk.getFailedUrls()
|
||||||
getFailedThunkUrls(thunk): string[]
|
thunk.isComplete()
|
||||||
waitForThunkError(thunk): Promise<string>
|
await thunk.waitForError() // "" when everything succeeded
|
||||||
waitForThunkCompletion(thunk): Promise<void>
|
await thunk.waitForCompletion()
|
||||||
```
|
```
|
||||||
|
|
||||||
`ThunkOptions`:
|
`app.use(Thunks).history` is a writable of every thunk this app has published — useful for a
|
||||||
|
"sending" indicator or deciding which relays the user has actually written to.
|
||||||
|
|
||||||
|
## Requests
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type ThunkOptions = {
|
const network = app.use(Network)
|
||||||
event: EventTemplate // unsigned — will be signed lazily
|
|
||||||
relays: string[]
|
network.load({relays, filters}) // batched, deduped, shared loader
|
||||||
recipient?: string // if set, event is NIP-59 gift-wrapped
|
network.request({relays, filters, onEvent})
|
||||||
delay?: number // ms to wait before sending (abort window)
|
network.publish({event, relays})
|
||||||
pow?: number // proof-of-work difficulty target
|
network.loadUsingOutbox(pubkey, filter) // newest matching event from the author's write relays
|
||||||
timeout?: number // ms per relay before marking as timed out
|
network.loadAllUsingOutbox(pubkey, filter) // every matching event
|
||||||
// PublishOptions callbacks: onSuccess, onFailure, onPending, onTimeout, onAborted, onComplete
|
```
|
||||||
|
|
||||||
|
Prefer a plugin's `one(key)` / `load(key)` when one exists — they handle outbox routing and
|
||||||
|
caching for you. The bare `load`/`request`/`publish` from `@welshman/net` need an explicit
|
||||||
|
`context`; `Network` supplies `app.netContext`.
|
||||||
|
|
||||||
|
## Relay selection
|
||||||
|
|
||||||
|
Routing is the `RelaySelection` DSL from `@welshman/util`, resolved by `app.use(Router)`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {outbox, inbox, seen, userOutbox, indexers, relay, relays} from "@welshman/util"
|
||||||
|
|
||||||
|
const scenario = await app.use(Router).resolve([userOutbox(), outbox(pubkey)])
|
||||||
|
const urls = scenario.getUrls()
|
||||||
|
|
||||||
|
// single best relay for a route
|
||||||
|
const hint = await app.use(Router).resolver.relay([outbox(event.pubkey)])
|
||||||
|
```
|
||||||
|
|
||||||
|
Selections are weighted (`outbox(pubkey, 2)`), and resolution is **async** — it may need to load
|
||||||
|
the target's relay list first.
|
||||||
|
|
||||||
|
## App policies
|
||||||
|
|
||||||
|
An `AppPolicy` is `(app) => Unsubscriber`, applied once at construction and torn down by
|
||||||
|
`cleanup()`. Policies own everything that subscribes or wires components together, keeping the
|
||||||
|
data classes free of side effects.
|
||||||
|
|
||||||
|
Built-ins: `appPolicyIngest`, `appPolicyRelayStats`, `appPolicyWraps`, `appPolicyCacheDecrypt`,
|
||||||
|
`appPolicyLogSignerMethods`, plus auth: `appPolicyAuthNever`, `appPolicyAuthAlways`,
|
||||||
|
`appPolicyAuthUnlessBlocked`, and `makeAppPolicyAuth(shouldAuth)` for a custom predicate.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const app = createApp({
|
||||||
|
user,
|
||||||
|
policies: [...defaultAppPolicies, appPolicyAuthUnlessBlocked, myPolicy],
|
||||||
|
})
|
||||||
|
|
||||||
|
const myPolicy: AppPolicy = app => {
|
||||||
|
const unsubscribe = on(app.repository, "update", handleUpdate)
|
||||||
|
|
||||||
|
return unsubscribe
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Commands (Higher-level Thunk Factories)
|
**Ordering gotcha:** policies run in the `App` constructor. If a policy module imports something
|
||||||
|
that transitively imports your app module, construct the app lazily (on first access) so every
|
||||||
|
policy has registered by the time it's built.
|
||||||
|
|
||||||
Most return a `Thunk` (or `Promise<Thunk>`). They automatically load the relevant user list before modifying it. Exception: `manageRelay` returns `Promise<Response>` (an HTTP response from the NIP-86 management endpoint), not a Thunk.
|
## Web of trust
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|---|---|
|
|
||||||
| `setProfile(profile: Profile)` | Publish NIP-01 profile metadata |
|
|
||||||
| `follow(tag: string[])` | Add to NIP-02 follow list |
|
|
||||||
| `unfollow(value: string)` | Remove from follow list |
|
|
||||||
| `mutePublicly(tag)` | Add to public mute list |
|
|
||||||
| `mutePrivately(tag)` | Add to private (encrypted) mute list |
|
|
||||||
| `unmute(value)` | Remove from mute list |
|
|
||||||
| `setMutes({publicTags?, privateTags?})` | Replace entire mute list |
|
|
||||||
| `pin(tag)` / `unpin(value)` | Manage pin list |
|
|
||||||
| `addRelay(url, mode)` / `removeRelay(url, mode)` | NIP-65 relay list management |
|
|
||||||
| `setRelays(tags)` / `setReadRelays(urls)` / `setWriteRelays(urls)` | Bulk relay list updates |
|
|
||||||
| `addMessagingRelay(url)` / `removeMessagingRelay(url)` | NIP-17 messaging relay list |
|
|
||||||
| `addBlockedRelay(url)` / `removeBlockedRelay(url)` | Blocked relay list |
|
|
||||||
| `addSearchRelay(url)` / `removeSearchRelay(url)` | Search relay list |
|
|
||||||
| `sendWrapped({event, recipients, ...options})` | NIP-59 gift-wrap to multiple recipients |
|
|
||||||
| `manageRelay(url, request)` | NIP-86 relay management |
|
|
||||||
| `createRoom` / `editRoom` / `deleteRoom` / `joinRoom` / `leaveRoom` | NIP-29 group room management |
|
|
||||||
|
|
||||||
### Profiles
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
profilesByPubkey: Readable<Map<string, Profile>>
|
const wot = app.use(Wot)
|
||||||
profiles: Readable<Profile[]>
|
|
||||||
getProfile(pubkey: string): Profile | undefined
|
wot.follows(pubkey).get()
|
||||||
loadProfile(pubkey, relayHints?): Promise<void>
|
wot.followers(pubkey).get()
|
||||||
forceLoadProfile(pubkey, relayHints?): Promise<void>
|
wot.network(pubkey).$ // follows-of-follows
|
||||||
deriveProfile(pubkey: string): Readable<Profile | undefined> // auto-loads
|
wot.followsWhoFollow(pubkey, target).$
|
||||||
deriveProfileDisplay(pubkey: string): Readable<string> // display name with fallback
|
wot.wotScore(pubkey, target).$
|
||||||
displayProfileByPubkey(pubkey: string): string // synchronous
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Follow / Mute / Pin Lists
|
## Feeds and sync
|
||||||
|
|
||||||
Each list type follows the same pattern (`follow` shown, `mute` and `pin` are identical):
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
followListsByPubkey: Readable<Map<string, List>>
|
app.use(Feeds).makeFeedController({feed, onEvent, ...})
|
||||||
followLists: Readable<List[]>
|
app.use(Feeds).getPubkeysForScope(scope)
|
||||||
getFollowList(pubkey): List | undefined
|
app.use(Feeds).forAuthor(pubkey).$
|
||||||
loadFollowList(pubkey, relayHints?): Promise<void>
|
|
||||||
forceLoadFollowList(pubkey, relayHints?): Promise<void>
|
app.use(Sync).pull({relays, filters}) // negentropy: fetch what we're missing
|
||||||
deriveFollowList(pubkey): Readable<List | undefined>
|
app.use(Sync).push({relays, filters}) // publish what the relay is missing
|
||||||
```
|
```
|
||||||
|
|
||||||
### Relay Lists
|
## Using welshman stores outside Svelte
|
||||||
|
|
||||||
|
Projections and plugin stores implement the Svelte store contract — `subscribe(cb) → unsubscribe`,
|
||||||
|
firing synchronously with the current value — so they adapt to any reactive framework with a small
|
||||||
|
hook. Only the `svelte/store` *types* are needed, not the runtime.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// NIP-65 relay lists
|
// React
|
||||||
relayListsByPubkey / relayLists / getRelayList / loadRelayList / deriveRelayList
|
const useStore = <T>(store: Readable<T>): T => {
|
||||||
|
const [value, setValue] = useState<T>(() => get(store))
|
||||||
|
|
||||||
// NIP-17 messaging relay lists
|
|
||||||
messagingRelayListsByPubkey / messagingRelayLists / getMessagingRelayList / loadMessagingRelayList / deriveMessagingRelayList
|
|
||||||
|
|
||||||
// Blocked relay lists
|
|
||||||
blockedRelayListsByPubkey / getBlockedRelayList / loadBlockedRelayList / deriveBlockedRelayList
|
|
||||||
|
|
||||||
// Search relay lists (internal only — not exported from @welshman/app)
|
|
||||||
// Use userSearchRelayList / loadUserSearchRelayList / forceLoadUserSearchRelayList from user.ts instead
|
|
||||||
```
|
|
||||||
|
|
||||||
### Outbox Loading
|
|
||||||
|
|
||||||
`makeOutboxLoader` creates a loader function for any event kind. It looks up the target pubkey's relay list (fetching it if needed), then fetches events from their write relays using the outbox model. This is the internal mechanism used by all built-in `loadX` helpers.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {makeOutboxLoader} from '@welshman/app'
|
|
||||||
|
|
||||||
// Signature: makeOutboxLoader(kind, filter?, limit?)
|
|
||||||
// Returns: (pubkey: string, relayHints?: string[]) => Promise<void>
|
|
||||||
// Results are stored in repository — read via the derived store/getter, not the return value.
|
|
||||||
|
|
||||||
// Loader for kind 1 notes (default limit = 1)
|
|
||||||
const loadNote = makeOutboxLoader(1)
|
|
||||||
await loadNote('target-pubkey')
|
|
||||||
|
|
||||||
// With extra filter constraints
|
|
||||||
const loadRecentNotes = makeOutboxLoader(1, {since: Math.floor(Date.now() / 1000) - 86400})
|
|
||||||
await loadRecentNotes('target-pubkey')
|
|
||||||
|
|
||||||
// Override the limit via the third positional argument (not inside the filter object)
|
|
||||||
const loadMany = makeOutboxLoader(1, {}, 20)
|
|
||||||
await loadMany('target-pubkey')
|
|
||||||
|
|
||||||
// With relay hints to seed the lookup
|
|
||||||
await loadNote('target-pubkey', ['wss://relay.damus.io/'])
|
|
||||||
```
|
|
||||||
|
|
||||||
**Relay URL helpers** (exported from index):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
getPubkeyRelays(pubkey: string, mode?: RelayMode): string[]
|
|
||||||
derivePubkeyRelays(pubkey: string, mode?: RelayMode): Readable<string[]>
|
|
||||||
```
|
|
||||||
|
|
||||||
### User Data Stores (current session)
|
|
||||||
|
|
||||||
These automatically derive from the active `pubkey` and trigger a load on first access:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
userProfile: Readable<Profile | undefined>
|
|
||||||
userFollowList: Readable<List | undefined>
|
|
||||||
userMuteList: Readable<List | undefined>
|
|
||||||
userPinList: Readable<List | undefined>
|
|
||||||
userRelayList: Readable<List | undefined>
|
|
||||||
userMessagingRelayList: Readable<List | undefined>
|
|
||||||
userSearchRelayList: Readable<List | undefined>
|
|
||||||
userBlockedRelayList: Readable<List | undefined>
|
|
||||||
userBlossomServerList: Readable<List | undefined>
|
|
||||||
```
|
|
||||||
|
|
||||||
Corresponding loaders (operate on the current session's pubkey):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
loadUserProfile(relays?)
|
|
||||||
forceLoadUserProfile(relays?)
|
|
||||||
loadUserFollowList / forceLoadUserFollowList
|
|
||||||
loadUserMuteList / forceLoadUserMuteList
|
|
||||||
loadUserPinList / forceLoadUserPinList
|
|
||||||
loadUserRelayList / forceLoadUserRelayList
|
|
||||||
loadUserMessagingRelayList / forceLoadUserMessagingRelayList
|
|
||||||
// ...etc for each list type
|
|
||||||
```
|
|
||||||
|
|
||||||
### Router
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {Router, routerContext, addMaximalFallbacks, addMinimalFallbacks} from '@welshman/router'
|
|
||||||
|
|
||||||
// The index.ts wires up routerContext automatically:
|
|
||||||
// routerContext.getUserPubkey, getPubkeyRelays, getRelayQuality, getDefaultRelays, etc.
|
|
||||||
|
|
||||||
Router.get() // singleton with app-wired context
|
|
||||||
Router.get().FromUser() // relays to publish from the current user
|
|
||||||
Router.get().ForPubkey(pubkey) // relays to read a pubkey's events
|
|
||||||
Router.get().Event(event) // best relay for a specific event
|
|
||||||
Router.get().Index() // indexer/bootstrap relays
|
|
||||||
Router.get().FromRelays(urls) // relay set from explicit URLs
|
|
||||||
.policy(addMaximalFallbacks) // add fallback relays
|
|
||||||
.limit(8)
|
|
||||||
.getUrls() // string[]
|
|
||||||
.getUrl() // string | undefined (first)
|
|
||||||
```
|
|
||||||
|
|
||||||
`routerContext` settings (configure before using router):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {routerContext} from '@welshman/router' // from @welshman/router, not @welshman/app
|
|
||||||
|
|
||||||
routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tag Utilities
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
tagPubkey(pubkey: string): string[] // ["p", pubkey, relayHint, displayName]
|
|
||||||
tagEvent(event, url?, mark?): string[][] // e-tag (+ a-tag if replaceable)
|
|
||||||
tagEventPubkeys(event): string[][] // p-tags for all mentioned pubkeys (excl. self)
|
|
||||||
tagEventForQuote(event, relay?): string[] // q-tag
|
|
||||||
tagEventForReply(event, relay?): string[][] // full reply thread tags
|
|
||||||
tagEventForComment(event, relay?): string[][]// NIP-22 comment tags
|
|
||||||
tagEventForReaction(event, relay?): string[][]// reaction tags
|
|
||||||
tagZapSplit(pubkey, split?): string[] // zap tag
|
|
||||||
```
|
|
||||||
|
|
||||||
### Web of Trust (WoT)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Reactive stores
|
|
||||||
followersByPubkey: Readable<Map<string, Set<string>>>
|
|
||||||
mutersByPubkey: Readable<Map<string, Set<string>>>
|
|
||||||
wotGraph: Writable<Map<string, number>> // pubkey → score; rebuilt on follow/mute changes
|
|
||||||
maxWot: Readable<number>
|
|
||||||
|
|
||||||
// Synchronous getters
|
|
||||||
getFollows(pubkey): string[]
|
|
||||||
getMutes(pubkey): string[]
|
|
||||||
getFollowers(pubkey): string[]
|
|
||||||
getMuters(pubkey): string[]
|
|
||||||
getNetwork(pubkey): string[] // follows-of-follows (excludes direct follows)
|
|
||||||
getFollowsWhoFollow(pubkey, target): string[]
|
|
||||||
getFollowsWhoMute(pubkey, target): string[]
|
|
||||||
getWotScore(pubkey, target): number // follows-who-follow minus follows-who-mute
|
|
||||||
|
|
||||||
// Per-user reactive score
|
|
||||||
getUserWotScore(tpk: string): number
|
|
||||||
deriveUserWotScore(tpk: string): Readable<number>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Handles & Zappers
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
handlesByNip05: Writable<Map<string, Handle>>
|
|
||||||
deriveHandle(nip05: string): Readable<Handle | undefined> // auto-loads
|
|
||||||
loadHandle(nip05): Promise<void>
|
|
||||||
|
|
||||||
zappersByLnurl: Writable<Map<string, Zapper>>
|
|
||||||
deriveZapper(lnurl: string): Readable<Zapper | undefined> // auto-loads
|
|
||||||
loadZapper(lnurl): Promise<void>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Feeds
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {makeFeedController} from '@welshman/app'
|
|
||||||
import {makeKindFeed} from '@welshman/feeds'
|
|
||||||
|
|
||||||
// makeFeedController wraps FeedController with app-level scope/WoT helpers
|
|
||||||
const ctrl = makeFeedController({
|
|
||||||
feed: makeKindFeed(NOTE),
|
|
||||||
useWindowing: true,
|
|
||||||
signal: abortController.signal,
|
|
||||||
onEvent: (e) => { /* handle event */ },
|
|
||||||
onExhausted: () => { /* no more events */ },
|
|
||||||
})
|
|
||||||
|
|
||||||
ctrl.load(100)
|
|
||||||
abortController.abort()
|
|
||||||
```
|
|
||||||
|
|
||||||
WoT-scoped feed helpers (passed automatically to `FeedController`):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
getPubkeysForScope(scope: string): string[] // Scope.Self|Follows|Network|Followers
|
|
||||||
getPubkeysForWOTRange(min: number, max: number): string[] // fractional of maxWot
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sync (Negentropy)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {pull, push, hasNegentropy} from '@welshman/app'
|
|
||||||
|
|
||||||
// pull/push use negentropy if the relay supports it, falling back to plain requests
|
|
||||||
await pull({relays, filters})
|
|
||||||
await push({relays, filters})
|
|
||||||
hasNegentropy(url: string): boolean
|
|
||||||
```
|
|
||||||
|
|
||||||
### Application Context
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {appContext} from '@welshman/app'
|
|
||||||
|
|
||||||
appContext.dufflepudUrl = 'https://my-dufflepud.example.com'
|
|
||||||
```
|
|
||||||
|
|
||||||
[Dufflepud](https://github.com/coracle-social/dufflepud) is an optional proxy server for NIP-05 lookups, zapper resolution, relay metadata, and link previews. Not required but helps bypass CORS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Login and publish a note
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {makeSecret} from '@welshman/util'
|
|
||||||
import {loginWithNip07, publishThunk, signer} from '@welshman/app'
|
|
||||||
import {Router} from '@welshman/router'
|
|
||||||
import {NOTE, makeEvent} from '@welshman/util'
|
|
||||||
import {Nip07Signer} from '@welshman/signer'
|
|
||||||
|
|
||||||
// NIP-07 login
|
|
||||||
const nip07 = new Nip07Signer()
|
|
||||||
const pubkey = await nip07.getPubkey()
|
|
||||||
loginWithNip07(pubkey)
|
|
||||||
|
|
||||||
// Publish with optimistic local update and 3s undo window
|
|
||||||
const thunk = publishThunk({
|
|
||||||
event: makeEvent(NOTE, {content: 'Hello Nostr!'}),
|
|
||||||
relays: Router.get().FromUser().getUrls(),
|
|
||||||
delay: 3000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Subscribe to per-relay status
|
|
||||||
thunk.subscribe($thunk => {
|
|
||||||
for (const [url, result] of Object.entries($thunk.results)) {
|
|
||||||
console.log(url, result.status, result.detail)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Soft-undo within delay window
|
|
||||||
setTimeout(() => thunk.controller.abort(), 1000)
|
|
||||||
|
|
||||||
// Wait for all relays to finish (thunk.complete is a Deferred<void>)
|
|
||||||
await thunk.complete
|
|
||||||
```
|
|
||||||
|
|
||||||
### Derive a reactive profile
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {deriveProfile, deriveProfileDisplay} from '@welshman/app'
|
|
||||||
|
|
||||||
const targetPubkey = '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'
|
|
||||||
|
|
||||||
// Reactive store — loads the profile in the background on first subscribe
|
|
||||||
const profile = deriveProfile(targetPubkey)
|
|
||||||
|
|
||||||
// Reactive display name with npub fallback
|
|
||||||
const name = deriveProfileDisplay(targetPubkey)
|
|
||||||
|
|
||||||
// In Svelte
|
|
||||||
// $: displayName = $name
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reply to an event
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {publishThunk, tagEventForReply, tagPubkey, signer} from '@welshman/app'
|
|
||||||
import {Router} from '@welshman/router'
|
|
||||||
import {NOTE, makeEvent} from '@welshman/util'
|
|
||||||
import type {TrustedEvent} from '@welshman/util'
|
|
||||||
|
|
||||||
async function replyTo(parent: TrustedEvent, content: string) {
|
|
||||||
const tags = tagEventForReply(parent)
|
|
||||||
|
|
||||||
return publishThunk({
|
|
||||||
event: makeEvent(NOTE, {content, tags}),
|
|
||||||
relays: Router.get().PublishEvent(parent).getUrls(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Send a NIP-59 gift-wrapped DM
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {sendWrapped} from '@welshman/app'
|
|
||||||
import {DIRECT_MESSAGE, makeEvent} from '@welshman/util'
|
|
||||||
|
|
||||||
const mergedThunk = await sendWrapped({
|
|
||||||
event: makeEvent(DIRECT_MESSAGE, {content: 'secret message'}),
|
|
||||||
recipients: [recipientPubkey],
|
|
||||||
})
|
|
||||||
|
|
||||||
// Monitor combined status
|
|
||||||
mergedThunk.subscribe($t => {
|
|
||||||
for (const [url, result] of Object.entries($t.results)) {
|
|
||||||
console.log(url, result.status)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Follow/unfollow
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {follow, unfollow} from '@welshman/app'
|
|
||||||
|
|
||||||
// tag format: ["p", pubkey] or ["p", pubkey, relayHint, petname]
|
|
||||||
await follow(["p", "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"])
|
|
||||||
await unfollow("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Web of Trust filtering
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {deriveUserWotScore, getWotScore, wotGraph, maxWot} from '@welshman/app'
|
|
||||||
import {get} from 'svelte/store'
|
|
||||||
|
|
||||||
// Filter a list of pubkeys to those with positive WoT score
|
|
||||||
const $graph = get(wotGraph)
|
|
||||||
const trusted = pubkeys.filter(pk => ($graph.get(pk) ?? 0) > 0)
|
|
||||||
|
|
||||||
// Reactive score for a single user
|
|
||||||
const score = deriveUserWotScore(somePubkey)
|
|
||||||
|
|
||||||
// Normalize by max score (0–1 range)
|
|
||||||
const $max = get(maxWot)
|
|
||||||
const normalized = ($graph.get(somePubkey) ?? 0) / ($max || 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Load a feed of notes
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {makeFeedController, getPubkeysForScope} from '@welshman/app'
|
|
||||||
import {makeKindFeed} from '@welshman/feeds'
|
|
||||||
import {NOTE} from '@welshman/util'
|
|
||||||
|
|
||||||
const abort = new AbortController()
|
|
||||||
|
|
||||||
const ctrl = makeFeedController({
|
|
||||||
feed: makeKindFeed(NOTE),
|
|
||||||
useWindowing: true,
|
|
||||||
signal: abort.signal,
|
|
||||||
onEvent: event => console.log(event),
|
|
||||||
onExhausted: () => console.log('no more events'),
|
|
||||||
})
|
|
||||||
|
|
||||||
ctrl.load(50)
|
|
||||||
|
|
||||||
// cleanup
|
|
||||||
abort.abort()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration Notes
|
|
||||||
|
|
||||||
- `@welshman/app` **re-exports nothing** from `@welshman/net`, `@welshman/router`, etc. Import those directly when you need low-level primitives (`load`, `request`, `publish`, `Router` scenarios beyond `FromUser`).
|
|
||||||
- The `index.ts` bootstrap code runs on import and automatically wires `routerContext` (pubkey relays, relay quality, default/indexer/search relays) and hooks `Pool` to store incoming events in `repository`. **Import `@welshman/app` early in your app entry point** so this runs before any requests. The canonical side-effect import pattern is:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// app entry point — must be first, before any @welshman/net or @welshman/router imports
|
|
||||||
import "@welshman/app"
|
|
||||||
|
|
||||||
// Then optionally override defaults
|
|
||||||
import {routerContext} from "@welshman/router"
|
|
||||||
routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"]
|
|
||||||
```
|
|
||||||
- `repository` and `tracker` are singletons shared across the whole app. All subscriptions made through `@welshman/net` that pass through the pool will populate `repository` automatically.
|
|
||||||
- `Router` is imported from `@welshman/router` but `routerContext` is configured by `@welshman/app/index.ts`. Use `Router.get()` (not `new Router(...)`) to get the app-configured singleton.
|
|
||||||
- `deriveProfile`, `deriveFollowList`, etc. use `makeLoadItem` under the hood: they fire a network request on first subscribe if data is not already in the repository, then resolve immediately on subsequent subscribes.
|
|
||||||
- `userFollowList`, `userMuteList`, etc. are derived from `pubkey`. They automatically re-derive when the active session changes (multi-account support).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Using Welshman Stores Outside Svelte
|
|
||||||
|
|
||||||
All welshman stores implement the Svelte store contract: a `subscribe(callback) → unsubscribe` method where the callback fires **synchronously** with the current value on first call, then again on every change. This makes them trivially adaptable to any reactive framework — no Svelte runtime required, only the type imports.
|
|
||||||
|
|
||||||
### React
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {useState, useEffect} from 'react'
|
|
||||||
import type {Readable, Writable} from 'svelte/store'
|
|
||||||
|
|
||||||
// Returns the current store value; re-renders when it changes.
|
|
||||||
function useReadable<T>(store: Readable<T>): T {
|
|
||||||
const [value, setValue] = useState<T>(() => {
|
|
||||||
// subscribe fires synchronously — capture the initial value then unsub immediately
|
|
||||||
let initial!: T
|
|
||||||
store.subscribe(v => { initial = v })()
|
|
||||||
return initial
|
|
||||||
})
|
|
||||||
useEffect(() => store.subscribe(setValue), [store])
|
useEffect(() => store.subscribe(setValue), [store])
|
||||||
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns [currentValue, setter] — setter calls store.set directly.
|
|
||||||
function useWritable<T>(store: Writable<T>): [T, (value: T) => void] {
|
|
||||||
return [useReadable(store), store.set]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Usage:
|
For a `Projection`, subscribe to `.$` and read `.get()` for a synchronous snapshot.
|
||||||
|
|
||||||
```tsx
|
## Related skills
|
||||||
import {userProfile, pubkey} from '@welshman/app'
|
|
||||||
|
|
||||||
function ProfileHeader() {
|
- `welshman-domain` — the readers/writers every plugin decodes events with
|
||||||
const profile = useReadable(userProfile)
|
- `welshman-net` — sockets, adapters, request/publish lifecycle, auth
|
||||||
const [currentPubkey, setPubkey] = useWritable(pubkey)
|
- `welshman-store` — the repository and the derive helpers plugins are built on
|
||||||
|
- `welshman-signer` — signer implementations behind `User`
|
||||||
return <div>{profile?.name ?? currentPubkey}</div>
|
- `welshman-util` — kinds, filters, tag specs, and the `RelaySelection` DSL
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SolidJS
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {createSignal, onCleanup} from 'solid-js'
|
|
||||||
import type {Readable, Writable} from 'svelte/store'
|
|
||||||
|
|
||||||
// Returns a SolidJS accessor (getter function); updates reactively.
|
|
||||||
function useReadable<T>(store: Readable<T>): () => T {
|
|
||||||
let initial!: T
|
|
||||||
store.subscribe(v => { initial = v })() // sync capture then unsubscribe
|
|
||||||
|
|
||||||
const [value, setValue] = createSignal<T>(initial)
|
|
||||||
onCleanup(store.subscribe(v => setValue(() => v)))
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns [accessor, setter].
|
|
||||||
function useWritable<T>(store: Writable<T>): [() => T, (value: T) => void] {
|
|
||||||
return [useReadable(store), store.set]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import {userProfile} from '@welshman/app'
|
|
||||||
|
|
||||||
function ProfileHeader() {
|
|
||||||
const profile = useReadable(userProfile)
|
|
||||||
return <div>{profile()?.name}</div>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vue
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {ref, onUnmounted} from 'vue'
|
|
||||||
import type {Readable, Writable} from 'svelte/store'
|
|
||||||
|
|
||||||
function useReadable<T>(store: Readable<T>) {
|
|
||||||
let initial!: T
|
|
||||||
store.subscribe(v => { initial = v })()
|
|
||||||
|
|
||||||
const value = ref<T>(initial)
|
|
||||||
const unsub = store.subscribe(v => { value.value = v as any })
|
|
||||||
onUnmounted(unsub)
|
|
||||||
return value // use as a readonly ref
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
- **No Svelte runtime needed.** Only `svelte/store` types are imported. The store objects themselves ship with `@welshman/app`.
|
|
||||||
- **Welshman stores with `.get()`** (created via `withGetter`) can be read synchronously without subscribing — useful in event handlers and callbacks outside any reactive context. Most writable stores in `@welshman/app` expose `.get()`.
|
|
||||||
- **`subscribe` always fires immediately.** Unlike many observable libraries, the initial emission is synchronous, so the `useState` / `createSignal` initial value is always populated on first render.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gotchas & Tips
|
|
||||||
|
|
||||||
- **Thunks sign lazily.** `publishThunk` returns synchronously and immediately writes an unsigned/hashed event to `repository` for optimistic UI. Actual signing happens in a background queue. Do not assume the event has an `id` suitable for embedding in other events until signing completes.
|
|
||||||
- **`delay` is an undo window, not a debounce.** The thunk starts the delay timer immediately; if not aborted before `delay` ms, it signs and publishes. Calling `thunk.controller.abort()` after the delay has elapsed does nothing.
|
|
||||||
- **`sendWrapped` uses `recipients`, not `pubkeys`.** The docs example uses `pubkeys` but the actual type is `recipients: string[]`.
|
|
||||||
- **Gift wrap processing requires opt-in.** Set `shouldUnwrap.set(true)` to enable automatic NIP-59 unwrapping of incoming `kind:1059` events. Without this, wrapped events are silently discarded.
|
|
||||||
- **`commands` force-load lists before modifying them.** `follow()`, `unfollow()`, etc. call `forceLoadUserFollowList` to ensure they have the latest list before adding/removing, preventing accidental list truncation. Do not call these in rapid succession without awaiting each one.
|
|
||||||
- **WoT graph is rebuilt at most once per second** (throttled). Do not expect `wotGraph` to reflect a `follow()` call immediately; subscribe to the store instead.
|
|
||||||
- **`routerContext.getDefaultRelays` is throttled** with a 200 ms window by default in `index.ts`. It returns up to the 5 highest-quality known relays. Override it before any relay connections if you want a fixed bootstrap list.
|
|
||||||
- **Multiple sessions are supported.** Call `loginWith*` multiple times to add sessions. Switch the active session with `pubkey.set(otherPubkey)`. Remove a session with `dropSession(pubkey)` — this also cleans up the cached signer.
|
|
||||||
- **Stores have `.get()` via `withGetter`.** `pubkey.get()`, `signer.get()`, `session.get()`, `signerLog.get()`, `shouldUnwrap.get()` all work without `get()` from `svelte/store`. Use this for synchronous reads outside of reactive contexts.
|
|
||||||
- **`appContext.dufflepudUrl` must be set before first handle/zapper load.** There is no lazy re-fetch; set it at app startup.
|
|
||||||
|
|
|
||||||
197
.agents/skills/welshman-domain/SKILL.md
Normal file
197
.agents/skills/welshman-domain/SKILL.md
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
---
|
||||||
|
name: welshman-domain
|
||||||
|
description: "Use this skill when working with @welshman/domain: reading or writing a specific nostr event kind, parsing tags, building events to publish, or adding support for a new kind. Provides a typed Reader/Writer pair per kind so you never hand-parse tags."
|
||||||
|
---
|
||||||
|
|
||||||
|
# welshman/domain — Typed Readers and Writers per Kind
|
||||||
|
|
||||||
|
`@welshman/domain` replaces ad-hoc tag digging. Every supported kind gets a **Reader** (typed
|
||||||
|
accessors over an event) and a **Writer** (a builder that renders a new event), paired in a
|
||||||
|
`KindFactory`.
|
||||||
|
|
||||||
|
**The rule this package exists to enforce:** never reach into `event.tags` yourself. Use the
|
||||||
|
reader's getter (`note.content()`, `roomMeta.name()`, `goal.amount()`). If a kind has no reader,
|
||||||
|
use `tagValue`/`tagValues` from `@welshman/util` — not `tags.find(...)`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i @welshman/domain
|
||||||
|
```
|
||||||
|
|
||||||
|
## The three pieces
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {KindFactory} from "@welshman/domain"
|
||||||
|
|
||||||
|
KindFactory // the exported per-kind object, e.g. `Note`, `Profile`, `RoomMeta`
|
||||||
|
.configure(ctx) // binds a resolver/signer -> ConfiguredKind
|
||||||
|
.reader(event) // -> Reader instance (call .parse() before use)
|
||||||
|
.writer(reader?) // -> Writer, optionally seeded from an existing reader to edit it
|
||||||
|
```
|
||||||
|
|
||||||
|
You rarely call `configure` directly — the `Domain` plugin does it for you.
|
||||||
|
|
||||||
|
## Usage through an app
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {Domain} from "@welshman/app"
|
||||||
|
import {Note, Profile} from "@welshman/domain"
|
||||||
|
|
||||||
|
// Read: returns a parsed reader, ready to use
|
||||||
|
const note = app.use(Domain).reader(Note)(event)
|
||||||
|
|
||||||
|
note.content() // typed accessors, no tag digging
|
||||||
|
note.author()
|
||||||
|
note.createdAt()
|
||||||
|
|
||||||
|
// Write: build, then wrap in a Command to publish
|
||||||
|
const writer = app.use(Domain).writer(Note).setContent("hello").addMention(pubkey)
|
||||||
|
const command = await app.use(Domain).command(writer)
|
||||||
|
|
||||||
|
await command.publish().waitForError()
|
||||||
|
|
||||||
|
// Edit an existing event: seed the writer with its reader
|
||||||
|
const profile = app.use(Domain).reader(Profile)(profileEvent)
|
||||||
|
const edit = app.use(Domain).writer(Profile, profile).setName("new name")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sync vs async readers
|
||||||
|
|
||||||
|
Some kinds decrypt on parse (encrypted lists, app data), so `parse()` is async for them:
|
||||||
|
|
||||||
|
- `EventReader.parse(): this`
|
||||||
|
- `AsyncEventReader.parse(): Promise<this>`
|
||||||
|
|
||||||
|
`Parsed<R> = ReturnType<R["parse"]>` captures which one you get. `app.use(Domain).reader(F)`
|
||||||
|
returns `Parsed<R>` — the reader itself for sync kinds, a promise for async ones. That's exactly
|
||||||
|
what `EventToItem` accepts, so collections keep their synchronous path where the kind allows one.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const note = app.use(Domain).reader(Note)(event) // NoteReader
|
||||||
|
const list = await app.use(Domain).reader(MuteList)(event) // MuteListReader (decrypts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reader base API
|
||||||
|
|
||||||
|
Every reader inherits from `BaseEventReader`:
|
||||||
|
|
||||||
|
| Method | Returns |
|
||||||
|
|---|---|
|
||||||
|
| `id()` | event id |
|
||||||
|
| `author()` | pubkey |
|
||||||
|
| `content()` | content string |
|
||||||
|
| `tags()` | raw tags (prefer a typed getter when one exists) |
|
||||||
|
| `createdAt()` | timestamp |
|
||||||
|
| `identifier()` | `d` tag |
|
||||||
|
| `address()` | `kind:pubkey:d` |
|
||||||
|
| `room()` | `h` tag |
|
||||||
|
| `protect()` | whether the event carries `-` (NIP-70) |
|
||||||
|
| `expiration()` | `expiration` tag as a number |
|
||||||
|
| `emojis()` | parsed `emoji` tags |
|
||||||
|
| `zapSplits()` | parsed `zap` tags |
|
||||||
|
|
||||||
|
Kind-specific readers add their own on top — `RoomMetaReader.name()`, `ZapGoalReader.amount()`,
|
||||||
|
`RelayMembersReader.isMember(pubkey)`, and so on.
|
||||||
|
|
||||||
|
## Writer base API
|
||||||
|
|
||||||
|
Writers are chainable and validate on render:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
writer
|
||||||
|
.setContent(content)
|
||||||
|
.addTags(...tags)
|
||||||
|
.dropTags(pred) // also keepTags(pred)
|
||||||
|
.setIdentifier(d) // defaults to a random id
|
||||||
|
.setRoom(url, h) // NIP-29 `h` tag + forced relay
|
||||||
|
.setProtected(true) // NIP-70 `-`
|
||||||
|
.setExpiration(ts)
|
||||||
|
.addMention(pubkey)
|
||||||
|
.addQuote(event)
|
||||||
|
.addZapSplit(pubkey, split)
|
||||||
|
.addEmoji(shortcode, url)
|
||||||
|
.forceRelays(...urls) // bypass routing for relay-scoped kinds
|
||||||
|
```
|
||||||
|
|
||||||
|
`readonly requiresRelays` marks kinds that are meaningless without an explicit relay (NIP-29 room
|
||||||
|
ops, relay membership). Subclasses override `validate()` to enforce kind-specific invariants —
|
||||||
|
e.g. `RoomUpdatePins` throws without a room, `ZapGoal` requires an amount and at least one relay.
|
||||||
|
|
||||||
|
## Display helpers
|
||||||
|
|
||||||
|
A few kinds export display helpers alongside their reader:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {displayPubkey} from "@welshman/domain"
|
||||||
|
|
||||||
|
profile.display("Anonymous") // ProfileReader: name/display_name, or the fallback
|
||||||
|
displayPubkey(pubkey) // 'npub1abc...xyz'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a new kind
|
||||||
|
|
||||||
|
1. Add the kind constant to `@welshman/util`'s `Kinds.ts`.
|
||||||
|
2. Create `packages/domain/src/kinds/YourKind.ts` with a Reader, a Writer, and the factory:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {uniq, spec} from "@welshman/lib"
|
||||||
|
import {YOUR_KIND, hexTags, tagValues} from "@welshman/util"
|
||||||
|
import {EventReader} from "../core/EventReader.js"
|
||||||
|
import {EventWriter} from "../core/EventWriter.js"
|
||||||
|
import {KindFactory} from "../core/Kind.js"
|
||||||
|
|
||||||
|
export class YourKindReader extends EventReader {
|
||||||
|
pubkeys() {
|
||||||
|
return uniq(tagValues(hexTags("p"), this.event.tags))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YourKindWriter extends EventWriter<YourKindReader> {
|
||||||
|
readonly requiresRelays = true
|
||||||
|
|
||||||
|
validate() {
|
||||||
|
super.validate()
|
||||||
|
|
||||||
|
if (!this.roomTag) throw new Error("YourKind requires a room")
|
||||||
|
}
|
||||||
|
|
||||||
|
setPubkeys(pubkeys: string[]) {
|
||||||
|
return this.dropTags(spec(["p"])).addTags(...uniq(pubkeys).map(pk => ["p", pk]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const YourKind = new KindFactory({
|
||||||
|
kind: YOUR_KIND,
|
||||||
|
reader: YourKindReader,
|
||||||
|
writer: YourKindWriter,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Export it from `packages/domain/src/index.ts`.
|
||||||
|
4. Add `packages/domain/__tests__/YourKind.test.ts` — every kind has one. Use the `read`, `write`,
|
||||||
|
and `buildTemplate` helpers from `./helpers.js`, and cover: reading represented tags,
|
||||||
|
round-tripping without duplicating tags, and any `validate()` rule.
|
||||||
|
|
||||||
|
## Available kinds
|
||||||
|
|
||||||
|
Profile, Note, Comment, Thread, Delete, Reaction, Report, Poll, PollResponse, Classified,
|
||||||
|
TimeEvent, AppData, Handler, HandlerRecommendation, Feed, Pin, Pinboard.
|
||||||
|
|
||||||
|
Lists: FollowList, MuteList, PinList, BookmarkList, TopicList, EmojiList, RelayList, RelaySet,
|
||||||
|
SearchRelayList, MessagingRelayList, BlossomServerList, BlockedRelayList, CommunityList,
|
||||||
|
FeedList, RoomList.
|
||||||
|
|
||||||
|
Zaps: ZapRequest, ZapReceipt, ZapGoal.
|
||||||
|
|
||||||
|
NIP-29 rooms: RoomMeta, RoomCreate, RoomEdit, RoomDelete, RoomJoin, RoomLeave, RoomAddMember,
|
||||||
|
RoomRemoveMember, RoomMembers, RoomAdmins, RoomCreatePermission, RoomPins, RoomUpdatePins.
|
||||||
|
|
||||||
|
Relay membership: RelayInvite, RelayJoin, RelayLeave, RelayAddMember, RelayRemoveMember,
|
||||||
|
RelayMembers, RelayRole.
|
||||||
|
|
||||||
|
## Related skills
|
||||||
|
|
||||||
|
- `welshman-app` — the `Domain` plugin, `Command`, and the collections that decode events for you
|
||||||
|
- `welshman-util` — kind constants, tag specs (`tagValue`, `hexTags`, `addressTags`), filters
|
||||||
|
- `welshman-store` — indexing decoded readers into reactive collections
|
||||||
|
|
@ -138,8 +138,8 @@ import {get, writable} from "svelte/store"
|
||||||
import {Node, Extension, mergeAttributes} from "@tiptap/core"
|
import {Node, Extension, mergeAttributes} from "@tiptap/core"
|
||||||
import {Plugin, PluginKey} from "@tiptap/pm/state"
|
import {Plugin, PluginKey} from "@tiptap/pm/state"
|
||||||
import type {NodeViewRendererProps} from "@tiptap/core"
|
import type {NodeViewRendererProps} from "@tiptap/core"
|
||||||
import {Router} from "@welshman/router"
|
import {Profiles, Router, createSearch} from "@welshman/app"
|
||||||
import {createSearch, profiles, searchProfiles, deriveProfileDisplay} from "@welshman/app"
|
import {outbox} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
|
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
|
||||||
} from "@welshman/editor"
|
} from "@welshman/editor"
|
||||||
|
|
@ -323,8 +323,8 @@ const onSubmit = (editor: Editor) => {
|
||||||
|
|
||||||
## Integration Notes
|
## Integration Notes
|
||||||
|
|
||||||
- **`@welshman/app`** — `profileSearch` and `deriveProfileDisplay` are the typical sources for mention autocomplete data and display names.
|
- **`@welshman/app`** — `app.use(Profiles).profileSearch` and `app.use(Profiles).display(pubkey)` are the typical sources for mention autocomplete data and display names.
|
||||||
- **`@welshman/router`** — `Router.get().FromPubkeys([pubkey]).getUrls()` provides the relay hints encoded into nprofile bech32 strings.
|
- **`@welshman/app`** — relay hints for nprofile bech32 strings come from `app.use(Router).resolve([outbox(pubkey)])`.
|
||||||
- **`@welshman/util`** — `fromNostrURI` is used internally by `EventNodeView` to strip the `nostr:` scheme before displaying.
|
- **`@welshman/util`** — `fromNostrURI` is used internally by `EventNodeView` to strip the `nostr:` scheme before displaying.
|
||||||
- **`nostr-editor`** — `WelshmanExtension` extends `NostrExtension` from this package. Storage at `editor.storage.nostr` (including `getEditorTags()`) is provided by `nostr-editor`, not welshman itself.
|
- **`nostr-editor`** — `WelshmanExtension` extends `NostrExtension` from this package. Storage at `editor.storage.nostr` (including `getEditorTags()`) is provided by `nostr-editor`, not welshman itself.
|
||||||
- **`@tiptap/core`** — `Editor`, `NodeViewProps`, and all extension primitives come from Tiptap. Welshman does not re-export every Tiptap helper; import additional ones directly from `@tiptap/core` as needed.
|
- **`@tiptap/core`** — `Editor`, `NodeViewProps`, and all extension primitives come from Tiptap. Welshman does not re-export every Tiptap helper; import additional ones directly from `@tiptap/core` as needed.
|
||||||
|
|
|
||||||
|
|
@ -387,9 +387,9 @@ const label = formatTimestampRelative(event.created_at) // "3 hours ago"
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { on } from '@welshman/lib'
|
import { on } from '@welshman/lib'
|
||||||
import { repository } from '@welshman/app'
|
|
||||||
|
|
||||||
const unsub = on(repository, 'update', updates => {
|
// Each App owns its repository.
|
||||||
|
const unsub = on(app.repository, 'update', updates => {
|
||||||
console.log('added', updates.flatMap(u => u.added).length, 'events')
|
console.log('added', updates.flatMap(u => u.added).length, 'events')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ description: "Use this skill when working with @welshman/net: relay connections,
|
||||||
|
|
||||||
# welshman/net — Relay Network Layer
|
# welshman/net — Relay Network Layer
|
||||||
|
|
||||||
`@welshman/net` is the core networking layer for welshman-based nostr apps. It manages WebSocket relay connections, subscriptions, event publishing, NIP-42 auth, and NIP-77 negentropy sync. It sits below `@welshman/app` (which provides higher-level reactive stores and routing) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities.
|
`@welshman/net` is the core networking layer for welshman-based nostr apps. It manages WebSocket relay connections, subscriptions, event publishing, NIP-42 auth, and NIP-77 negentropy sync. It sits below `@welshman/app` (which owns an `App` instance and its plugins) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -135,10 +135,10 @@ Emits `"update"` with `RepositoryUpdate` (`{ added: TrustedEvent[], removed: Set
|
||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `netContext` | Global `NetContext` config object |
|
| `NetContext` | `{ pool?, repository?, getAdapter? }` |
|
||||||
| `NetContext` | `{ pool, repository, isEventValid, isEventDeleted, getAdapter? }` |
|
|
||||||
|
|
||||||
Mutate `netContext` fields directly to change global defaults; pass `context` to individual calls to override per-request.
|
Pass `context` to each `request`/`publish`/`load` call. An `App` from `@welshman/app` owns one per
|
||||||
|
instance (`app.netContext`), and `app.use(Network)` supplies it for you.
|
||||||
|
|
||||||
### Negentropy / Diff (NIP-77)
|
### Negentropy / Diff (NIP-77)
|
||||||
|
|
||||||
|
|
@ -366,7 +366,7 @@ repo.load(storedEvents)
|
||||||
### Startup: bulk-load Tracker state
|
### Startup: bulk-load Tracker state
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {tracker} from '@welshman/app' // singleton wired to the pool and repository
|
// `app.tracker` — wired to that app's pool and repository
|
||||||
|
|
||||||
// Build the map from your stored relay<->event mappings
|
// Build the map from your stored relay<->event mappings
|
||||||
const relaysById = new Map<string, Set<string>>()
|
const relaysById = new Map<string, Set<string>>()
|
||||||
|
|
@ -384,7 +384,7 @@ tracker.load(relaysById)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {on, batch} from '@welshman/lib'
|
import {on, batch} from '@welshman/lib'
|
||||||
import {repository} from '@welshman/app' // singleton; or Repository.get() standalone
|
// `app.repository`, or `new Repository()` when using @welshman/net standalone
|
||||||
import type {RepositoryUpdate} from '@welshman/net'
|
import type {RepositoryUpdate} from '@welshman/net'
|
||||||
import type {TrustedEvent} from '@welshman/util'
|
import type {TrustedEvent} from '@welshman/util'
|
||||||
|
|
||||||
|
|
@ -417,8 +417,8 @@ on(
|
||||||
|
|
||||||
- **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent` (already verified).
|
- **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent` (already verified).
|
||||||
- **`@welshman/lib`** — utility helpers (`Emitter`, `batcher`, `defer`, `on`, etc.) used internally; `Emitter` (from `@welshman/lib`) is the base class for `Tracker`, `Repository`, and `WrapManager`. `Socket`, `AuthState`, `AbstractAdapter`, and `Difference` extend node's built-in `EventEmitter` directly.
|
- **`@welshman/lib`** — utility helpers (`Emitter`, `batcher`, `defer`, `on`, etc.) used internally; `Emitter` (from `@welshman/lib`) is the base class for `Tracker`, `Repository`, and `WrapManager`. `Socket`, `AuthState`, `AbstractAdapter`, and `Difference` extend node's built-in `EventEmitter` directly.
|
||||||
- **`@welshman/app`** — wraps `@welshman/net` with reactive Svelte stores, a router, and higher-level helpers. Most app-level code should use `@welshman/app`; drop down to `@welshman/net` only for raw relay I/O or when building non-Svelte clients.
|
- **`@welshman/app`** — wraps `@welshman/net` in an `App` instance whose plugins own routing, collections, and publishing. Most app-level code should go through `app.use(Network)`; drop down to `@welshman/net` only for raw relay I/O or when building without an `App`.
|
||||||
- **`netContext`** — shared singleton used as the default by `request`, `requestOne`, and the repository. Override fields on `netContext` at startup, or pass a `context` object per-call to isolate behavior.
|
- **`NetContext`** — passed explicitly per call. Each `App` owns its own pool and repository, which keeps one identity's data out of another's.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,291 +0,0 @@
|
||||||
---
|
|
||||||
name: welshman-router
|
|
||||||
description: "Use this skill when working with @welshman/router: relay selection, routing strategies, scenario-based relay routing, or choosing which relays to use for reads/writes."
|
|
||||||
---
|
|
||||||
|
|
||||||
# welshman/router — Relay Selection
|
|
||||||
|
|
||||||
`@welshman/router` provides scenario-based relay selection for nostr clients. It answers the question "which relays should I use for this operation?" by scoring candidate relays based on pubkey relay lists, relay quality, and configurable fallback policies. It sits between `@welshman/util` (types/helpers) and `@welshman/net` (actual relay connections), and is wrapped by `@welshman/app` for full-stack usage.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @welshman/router
|
|
||||||
# or
|
|
||||||
pnpm add @welshman/router
|
|
||||||
yarn add @welshman/router
|
|
||||||
```
|
|
||||||
|
|
||||||
Peer dependencies: `@welshman/lib`, `@welshman/net`, `@welshman/util`.
|
|
||||||
|
|
||||||
## Key Exports
|
|
||||||
|
|
||||||
### Router (class)
|
|
||||||
|
|
||||||
The main entry point. Use as a singleton via `Router.configure()` + `Router.get()`, or instantiate directly with options.
|
|
||||||
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `Router.configure(options)` | Merge options into the global `routerContext` |
|
|
||||||
| `Router.get()` | Return a `Router` instance using the global context |
|
|
||||||
| `new Router(options)` | Create a router that overrides specific options from the global context |
|
|
||||||
|
|
||||||
**RouterOptions** (all optional):
|
|
||||||
|
|
||||||
| Option | Signature | Description |
|
|
||||||
|--------|-----------|-------------|
|
|
||||||
| `getUserPubkey` | `() => string \| undefined` | Current user's pubkey |
|
|
||||||
| `getPubkeyRelays` | `(pubkey, mode?) => string[]` | Relays for a pubkey; `mode` is `"read"`, `"write"`, or `"messaging"` |
|
|
||||||
| `getDefaultRelays` | `() => string[]` | Fallback relays of last resort |
|
|
||||||
| `getIndexerRelays` | `() => string[]` | Relays that index profiles and relay lists (NIP-65) |
|
|
||||||
| `getSearchRelays` | `() => string[]` | Relays supporting NIP-50 search |
|
|
||||||
| `getRelayQuality` | `(url) => number` | Quality score 0–1 for a relay (affects selection ranking) |
|
|
||||||
| `getLimit` | `() => number` | Max relays returned by `getUrls()` (default: 3) |
|
|
||||||
|
|
||||||
**Default behavior:** if `getPubkeyRelays` is not configured, the router falls back to querying the local `Repository` (from `@welshman/net`) for kind-10002 events.
|
|
||||||
|
|
||||||
### Router Scenario Methods
|
|
||||||
|
|
||||||
All return a `RouterScenario`. Naming convention: `For*` = relays to write to (so others can read), `From*` = relays to read from (author's outbox).
|
|
||||||
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `FromRelays(relays)` | Use an explicit list of relay URLs |
|
|
||||||
| `ForUser()` | User's read relays (where others can send things to the user) |
|
|
||||||
| `FromUser()` | User's write relays (user's outbox) |
|
|
||||||
| `MessagesForUser()` | User's messaging relays (NIP-17 DMs) |
|
|
||||||
| `ForPubkey(pubkey)` | A pubkey's read relays |
|
|
||||||
| `FromPubkey(pubkey)` | A pubkey's write relays (outbox) |
|
|
||||||
| `MessagesForPubkey(pubkey)` | A pubkey's messaging relays |
|
|
||||||
| `ForPubkeys(pubkeys)` | Merged read relays for multiple pubkeys |
|
|
||||||
| `FromPubkeys(pubkeys)` | Merged write relays for multiple pubkeys |
|
|
||||||
| `MessagesForPubkeys(pubkeys)` | Merged messaging relays for multiple pubkeys |
|
|
||||||
| `Event(event)` | Event author's write relays (where the event lives) |
|
|
||||||
| `Replies(event)` | Event author's read relays (where replies should be sent) |
|
|
||||||
| `PublishEvent(event)` | Author's outbox + mentioned pubkeys' read relays; hard-limits to 30 |
|
|
||||||
| `Quote(event, id, hints)` | Best relays to find a quoted event; checks tag relay hints and author pubkey from tag |
|
|
||||||
| `EventParents(event)` | Relays for fetching parent events (from ancestor tags + mentioned pubkeys) |
|
|
||||||
| `EventRoots(event)` | Relays for fetching root events |
|
|
||||||
| `Search()` | Search relays |
|
|
||||||
| `Index()` | Indexer relays |
|
|
||||||
| `Default()` | Default/fallback relays |
|
|
||||||
| `merge(scenarios)` | Combine multiple scenarios into one |
|
|
||||||
|
|
||||||
### RouterScenario (class)
|
|
||||||
|
|
||||||
Immutable builder — every builder method returns a new instance. Terminal methods (`getUrls()`, `getUrl()`) return relay URLs, not instances.
|
|
||||||
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `getUrls()` | Execute selection; returns `string[]` |
|
|
||||||
| `getUrl()` | Returns the first selected URL or `undefined` |
|
|
||||||
| `limit(n)` | Override max relay count for this scenario |
|
|
||||||
| `weight(scale)` | Multiply all selection weights by `scale` |
|
|
||||||
| `policy(fn)` | Set fallback policy |
|
|
||||||
| `allowLocal(bool)` | Allow `ws://localhost` / `ws://127.*` URLs (default: false) |
|
|
||||||
| `allowOnion(bool)` | Allow `.onion` URLs (default: false) |
|
|
||||||
| `allowInsecure(bool)` | Allow plain `ws://` non-onion URLs (default: false) |
|
|
||||||
| `filter(fn)` | Filter the internal `Selection[]` |
|
|
||||||
| `update(fn)` | Map over the internal `Selection[]` |
|
|
||||||
|
|
||||||
### Fallback Policies
|
|
||||||
|
|
||||||
Applied after relay scoring when not enough relays are found. Draw from `getDefaultRelays`.
|
|
||||||
|
|
||||||
| Export | Behavior |
|
|
||||||
|--------|----------|
|
|
||||||
| `addNoFallbacks` | Never add fallbacks (default) |
|
|
||||||
| `addMinimalFallbacks` | Add 1 fallback only if zero relays were selected |
|
|
||||||
| `addMaximalFallbacks` | Fill remaining slots up to the limit |
|
|
||||||
|
|
||||||
### Filter Selection
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `getFilterSelections(filters, rules?)` | Returns `RelaysAndFilters[]` — optimized relay+filter combos for a subscription |
|
|
||||||
| `RelaysAndFilters` | `{ relays: string[], filters: Filter[] }` |
|
|
||||||
| `defaultFilterSelectionRules` | The default ordered rule array |
|
|
||||||
| `getFilterSelectionsForSearch` | Rule: search filters → search relays (weight 10) |
|
|
||||||
| `getFilterSelectionsForWraps` | Rule: kind-1059 wraps without authors → user messaging relays |
|
|
||||||
| `getFilterSelectionsForIndexedKinds` | Rule: kinds 0/3/10002/10050 → indexer relays |
|
|
||||||
| `getFilterSelectionsForAuthors` | Rule: author filters → each author's outbox (split into up to 30 chunks) |
|
|
||||||
| `getFilterSelectionsForUser` | Rule: low-weight (0.2) baseline that always fires for every filter → user's read relays. It is not conditional on other rules failing. |
|
|
||||||
|
|
||||||
### Other Exports
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `INDEXED_KINDS` | `[PROFILE, RELAYS, MESSAGING_RELAYS, FOLLOWS]` — kinds routed to indexers |
|
|
||||||
| `makeSelection(relays, weight?)` | Create a `Selection` object; validates and normalizes URLs |
|
|
||||||
| `Selection` | `{ weight: number, relays: string[] }` |
|
|
||||||
| `FallbackPolicy` | `(count: number, limit: number) => number` |
|
|
||||||
| `routerContext` | The global mutable options object updated by `Router.configure()` |
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### 1. Configure once at app startup
|
|
||||||
|
|
||||||
`Router.get()` is the primary entry point — it returns a `Router` instance using the global `routerContext`. Call `Router.configure()` once at startup to set options, or assign directly to `routerContext` for individual overrides.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {Router} from '@welshman/router'
|
|
||||||
|
|
||||||
Router.configure({
|
|
||||||
getUserPubkey: () => myStore.userPubkey,
|
|
||||||
getPubkeyRelays: (pubkey, mode) => myStore.getRelaysForPubkey(pubkey, mode),
|
|
||||||
getDefaultRelays: () => ['wss://relay.example.com/', 'wss://relay2.example.com/'],
|
|
||||||
getIndexerRelays: () => ['wss://indexer.example.com/', 'wss://indexer2.example.com/'],
|
|
||||||
getSearchRelays: () => ['wss://search.example.com/', 'wss://search2.example.com/'],
|
|
||||||
getRelayQuality: (url) => myStore.getRelayQuality(url),
|
|
||||||
getLimit: () => 5,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
When using `@welshman/app`, it pre-configures `Router` automatically. The two most common customization points when using `@welshman/app` are `getDefaultRelays` and `getIndexerRelays`, which you can assign directly on `routerContext`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {routerContext} from '@welshman/router'
|
|
||||||
|
|
||||||
routerContext.getDefaultRelays = () => [
|
|
||||||
'wss://relay.example.com/',
|
|
||||||
'wss://relay2.example.com/',
|
|
||||||
]
|
|
||||||
|
|
||||||
routerContext.getIndexerRelays = () => [
|
|
||||||
'wss://indexer.example.com/',
|
|
||||||
'wss://indexer2.example.com/',
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Fetch events from specific pubkeys
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {Router} from '@welshman/router'
|
|
||||||
|
|
||||||
const relays = Router.get().FromPubkeys(['pubkey1', 'pubkey2']).getUrls()
|
|
||||||
// relays is string[] — pass to your subscription
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Publish an event
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {Router} from '@welshman/router'
|
|
||||||
import type {TrustedEvent} from '@welshman/util'
|
|
||||||
|
|
||||||
function getPublishRelays(event: TrustedEvent): string[] {
|
|
||||||
return Router.get().PublishEvent(event).getUrls()
|
|
||||||
// Automatically includes author's outbox + mentioned pubkeys' read relays
|
|
||||||
// Hard-limited to 30 relays for deliverability
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Find a quoted/referenced event with fallbacks
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {Router, addMaximalFallbacks} from '@welshman/router'
|
|
||||||
import type {TrustedEvent} from '@welshman/util'
|
|
||||||
|
|
||||||
function getQuoteRelays(event: TrustedEvent, quotedId: string, hints: string[]) {
|
|
||||||
return Router.get()
|
|
||||||
.Quote(event, quotedId, hints)
|
|
||||||
.policy(addMaximalFallbacks)
|
|
||||||
.limit(8)
|
|
||||||
.getUrls()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Common scenario cheat-sheet
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {Router} from '@welshman/router'
|
|
||||||
|
|
||||||
const router = Router.get()
|
|
||||||
|
|
||||||
// Read relays for the current user (where others deliver events to you)
|
|
||||||
router.ForUser().getUrls()
|
|
||||||
|
|
||||||
// Write relays for the current user (your outbox)
|
|
||||||
router.FromUser().getUrls()
|
|
||||||
|
|
||||||
// Best relays to deliver an event to a pubkey (their inbox)
|
|
||||||
router.ForPubkey('pubkey').getUrls()
|
|
||||||
|
|
||||||
// Best relays to fetch events authored by a pubkey (their outbox)
|
|
||||||
router.FromPubkey('pubkey').getUrls()
|
|
||||||
|
|
||||||
// Indexer relays (profiles, relay lists)
|
|
||||||
router.Index().getUrls()
|
|
||||||
|
|
||||||
// Cap relay count for this scenario only
|
|
||||||
router.ForPubkey('pubkey').limit(3).getUrls()
|
|
||||||
|
|
||||||
// Merge multiple scenarios; relay URLs are deduplicated when getUrls() is called
|
|
||||||
router.merge([
|
|
||||||
router.FromUser(),
|
|
||||||
router.Index(),
|
|
||||||
]).getUrls()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Build subscriptions with getFilterSelections
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {getFilterSelections} from '@welshman/router'
|
|
||||||
import type {Filter} from '@welshman/util'
|
|
||||||
|
|
||||||
const filters: Filter[] = [
|
|
||||||
{kinds: [1], authors: ['pubkey1', 'pubkey2']},
|
|
||||||
{kinds: [0], search: 'bitcoin'},
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const {relays, filters} of getFilterSelections(filters)) {
|
|
||||||
// Open one subscription per relay group
|
|
||||||
myPool.subscribe(relays, filters)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Use a custom filter routing rule
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
Router,
|
|
||||||
getFilterSelections,
|
|
||||||
defaultFilterSelectionRules,
|
|
||||||
type RelaysAndFilters,
|
|
||||||
} from '@welshman/router'
|
|
||||||
import type {Filter} from '@welshman/util'
|
|
||||||
|
|
||||||
// Add a rule that sends kind-1 to a dedicated relay
|
|
||||||
const myRule = (filter: Filter) => {
|
|
||||||
if (!filter.kinds?.includes(1)) return []
|
|
||||||
return [{filter, scenario: Router.get().FromRelays(['wss://notes.example.com/'])}]
|
|
||||||
}
|
|
||||||
|
|
||||||
const selections = getFilterSelections(filters, [myRule, ...defaultFilterSelectionRules])
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration Notes
|
|
||||||
|
|
||||||
- **`@welshman/util`** — Router imports `TrustedEvent`, `Filter`, `RelayMode`, `PROFILE`, `RELAYS`, `MESSAGING_RELAYS`, `FOLLOWS`, `WRAP`, `normalizeRelayUrl`, and tag-parsing helpers. All relay URLs are normalized with `normalizeRelayUrl` and validated with `isRelayUrl` before use.
|
|
||||||
- **`@welshman/net`** — The default `getPubkeyRelays` implementation queries `Repository.get()` (the in-memory event store from `@welshman/net`) for kind-10002 events. Override it if you maintain relay lists elsewhere.
|
|
||||||
- **`@welshman/app`** — The app layer pre-configures `Router` using its own stores (relay lists, connection quality). If you use `@welshman/app`, call `Router.configure` only to override specific options; the app layer handles the rest.
|
|
||||||
- **`@welshman/lib`** — Used internally for utilities (`sortBy`, `shuffle`, `uniq`, etc.); no direct integration needed.
|
|
||||||
|
|
||||||
## Gotchas & Tips
|
|
||||||
|
|
||||||
- **Relay list events must be in the Repository for pubkey routing to work.** The default `getPubkeyRelays` implementation reads kind-10002 (NIP-65) relay list events from the global in-memory `Repository`. If those events haven't been loaded — either from a local cache at startup or fetched from the network — the Router has no relay list data for that pubkey and silently falls back to default/indexer relays. When using `@welshman/app`, relay lists are fetched automatically as part of profile loading (`loadRelayList`, `makeOutboxLoader`). Without `@welshman/app`, you must fetch and load them yourself before calling pubkey-based scenarios.
|
|
||||||
|
|
||||||
- **`For*` vs `From*`**: `ForPubkey` returns a pubkey's **read** relays (where you send things for that pubkey to receive); `FromPubkey` returns their **write** relays (their outbox, where their events live). Use `From*` to fetch events, `For*` to deliver events.
|
|
||||||
|
|
||||||
- **Default limit is 3.** Set `getLimit` in `Router.configure` or call `.limit(n)` on a scenario if you need more. `PublishEvent` unconditionally overrides to 30.
|
|
||||||
|
|
||||||
- **Scoring includes randomness.** `getUrls()` introduces `Math.random()` in the scoring formula so that lower-quality or less-popular relays get occasional selection. Results are not deterministic across calls.
|
|
||||||
|
|
||||||
- **`addNoFallbacks` is the default policy.** If no relays are found for a scenario (e.g. no relay list for a pubkey) and you haven't set a policy, `getUrls()` returns `[]`. Use `addMinimalFallbacks` or `addMaximalFallbacks` when you need a result even for unknown pubkeys.
|
|
||||||
|
|
||||||
- **Insecure `ws://` URLs are filtered by default.** Only onion addresses (`*.onion`) are exempt from the TLS requirement. Pass `.allowInsecure(true)` to a scenario if you need to support plain websocket relays (e.g. local dev).
|
|
||||||
|
|
||||||
- **`getFilterSelections` uses `addMinimalFallbacks`.** Each resulting relay group will have at least one relay *if* `getDefaultRelays` is configured and returns relays. If `getDefaultRelays` is not configured or returns an empty array, the group may still be empty.
|
|
||||||
|
|
||||||
- **`routerContext` is a shared mutable object.** `Router.configure()` mutates it in place with `Object.assign`. `new Router(options)` merges the supplied options *over* the global `routerContext` (via `mergeLeft`), so any options not provided in `options` still fall back to whatever is in `routerContext`. For true isolation (e.g. in tests), pass a complete options object or reset `routerContext` first.
|
|
||||||
|
|
||||||
- **`Quote` reads relay hints from event tags.** It looks for a tag whose second element (`t[1]`) matches the quoted event ID, then extracts a relay hint from `t[2]` and an author pubkey from `t[3]`. Standard NIP-21/NIP-10 tag format.
|
|
||||||
|
|
@ -211,7 +211,7 @@ const plaintext = await signer.nip44.decrypt(theirPubkey, ciphertext)
|
||||||
|
|
||||||
- **`@welshman/util`** supplies `makeEvent`, `makeSecret`, `StampedEvent`, `SignedEvent`, and nostr kind constants (`NOTE`, `DIRECT_MESSAGE`, etc.) used in all examples above.
|
- **`@welshman/util`** supplies `makeEvent`, `makeSecret`, `StampedEvent`, `SignedEvent`, and nostr kind constants (`NOTE`, `DIRECT_MESSAGE`, etc.) used in all examples above.
|
||||||
- **`@welshman/net`** and **`@welshman/app`** accept an `ISigner` wherever signing is needed (e.g. publishing events). Pass any concrete signer — they are interchangeable.
|
- **`@welshman/net`** and **`@welshman/app`** accept an `ISigner` wherever signing is needed (e.g. publishing events). Pass any concrete signer — they are interchangeable.
|
||||||
- **`@welshman/app`** exposes a `signer` writable store (`import { signer } from '@welshman/app'`) that the rest of the app stack reads. Set it to your chosen `ISigner` after login.
|
- **`@welshman/app`** wraps a signer in a `User` (`User.fromSigner(signer)` or `User.fromSession(session)`), passed to the app at construction: `createApp({user})`. Reach it again via `app.user?.signer`, or `User.require(app).signer` where a login is required.
|
||||||
- `Nip59` wraps events with an ephemeral `Nip01Signer` by default (per the NIP-59 spec), so callers do not need to supply a wrapper unless they want a custom one.
|
- `Nip59` wraps events with an ephemeral `Nip01Signer` by default (per the NIP-59 spec), so callers do not need to supply a wrapper unless they want a custom one.
|
||||||
|
|
||||||
## Gotchas & Tips
|
## Gotchas & Tips
|
||||||
|
|
|
||||||
|
|
@ -140,15 +140,19 @@ notes.subscribe($notes => {
|
||||||
```typescript
|
```typescript
|
||||||
import { Repository } from "@welshman/net"
|
import { Repository } from "@welshman/net"
|
||||||
import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store"
|
import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store"
|
||||||
import { readProfile, PROFILE, type PublishedProfile } from "@welshman/util"
|
import { PROFILE } from "@welshman/util"
|
||||||
|
import { Profile, type ProfileReader } from "@welshman/domain"
|
||||||
|
|
||||||
const repository = new Repository()
|
const repository = new Repository()
|
||||||
|
|
||||||
const profilesByPubkey = deriveItemsByKey<PublishedProfile>({
|
// Decoding is @welshman/domain's job — configure the kind, then use its reader as eventToItem.
|
||||||
|
const readProfile = Profile.configure({}).reader
|
||||||
|
|
||||||
|
const profilesByPubkey = deriveItemsByKey<ProfileReader>({
|
||||||
repository,
|
repository,
|
||||||
filters: [{ kinds: [PROFILE] }],
|
filters: [{ kinds: [PROFILE] }],
|
||||||
eventToItem: event => readProfile(event),
|
eventToItem: event => readProfile(event).parse(),
|
||||||
getKey: profile => profile.event.pubkey,
|
getKey: profile => profile.author(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// All profiles as array
|
// All profiles as array
|
||||||
|
|
@ -159,7 +163,7 @@ const deriveProfile = makeDeriveItem(profilesByPubkey)
|
||||||
const aliceProfile = deriveProfile("alice-pubkey-hex")
|
const aliceProfile = deriveProfile("alice-pubkey-hex")
|
||||||
|
|
||||||
aliceProfile.subscribe($profile => {
|
aliceProfile.subscribe($profile => {
|
||||||
console.log($profile?.name)
|
console.log($profile?.display())
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -227,7 +231,8 @@ This `getBookmark` function is the right shape to pass as `getItem` to `makeLoad
|
||||||
### 6. Full reactive item chain: deriveItemsByKey → deriveItems → getter → makeLoadItem → makeDeriveItem
|
### 6. Full reactive item chain: deriveItemsByKey → deriveItems → getter → makeLoadItem → makeDeriveItem
|
||||||
|
|
||||||
This is the canonical pattern for domain objects derived from repository events with
|
This is the canonical pattern for domain objects derived from repository events with
|
||||||
on-demand network loading.
|
on-demand network loading. `@welshman/app` packages it as `DerivedPlugin` — prefer subclassing
|
||||||
|
that over hand-rolling the chain unless you need something it doesn't cover.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import {
|
||||||
|
|
@ -237,10 +242,9 @@ import {
|
||||||
makeLoadItem,
|
makeLoadItem,
|
||||||
makeDeriveItem,
|
makeDeriveItem,
|
||||||
} from "@welshman/store"
|
} from "@welshman/store"
|
||||||
import { load } from "@welshman/net"
|
import { Network, Router } from "@welshman/app"
|
||||||
import { repository } from "@welshman/app"
|
import { outbox } from "@welshman/util"
|
||||||
import { Router } from "@welshman/router"
|
import { relayTags, tagSpec, tagValue, tagValues } from "@welshman/util"
|
||||||
import { getTagValue, getTagValues } from "@welshman/util"
|
|
||||||
import type { TrustedEvent } from "@welshman/util"
|
import type { TrustedEvent } from "@welshman/util"
|
||||||
|
|
||||||
const BOOKMARK_KIND = 30003
|
const BOOKMARK_KIND = 30003
|
||||||
|
|
@ -254,14 +258,14 @@ type Bookmark = {
|
||||||
|
|
||||||
const parseBookmark = (event: TrustedEvent): Bookmark => ({
|
const parseBookmark = (event: TrustedEvent): Bookmark => ({
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
title: getTagValue("title", event.tags) ?? "Untitled",
|
title: tagValue(tagSpec("title"), event.tags) ?? "Untitled",
|
||||||
urls: getTagValues("r", event.tags),
|
urls: tagValues(relayTags("r"), event.tags),
|
||||||
event,
|
event,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 1: Reactive Map<pubkey, Bookmark> — live-updates from repository
|
// Step 1: Reactive Map<pubkey, Bookmark> — live-updates from repository
|
||||||
const bookmarksByPubkey = deriveItemsByKey<Bookmark>({
|
const bookmarksByPubkey = deriveItemsByKey<Bookmark>({
|
||||||
repository,
|
repository: app.repository,
|
||||||
filters: [{ kinds: [BOOKMARK_KIND] }],
|
filters: [{ kinds: [BOOKMARK_KIND] }],
|
||||||
getKey: b => b.pubkey,
|
getKey: b => b.pubkey,
|
||||||
eventToItem: parseBookmark,
|
eventToItem: parseBookmark,
|
||||||
|
|
@ -278,8 +282,10 @@ const getBookmark = (pubkey: string) => getBookmarksByPubkey().get(pubkey)
|
||||||
// re-fetches only after the timeout window (default: 3600 s)
|
// re-fetches only after the timeout window (default: 3600 s)
|
||||||
const loadBookmark = makeLoadItem<Bookmark>(
|
const loadBookmark = makeLoadItem<Bookmark>(
|
||||||
async (pubkey: string) => {
|
async (pubkey: string) => {
|
||||||
await load({
|
const scenario = await app.use(Router).resolve([outbox(pubkey)])
|
||||||
relays: Router.get().ForPubkey(pubkey).getUrls(),
|
|
||||||
|
await app.use(Network).load({
|
||||||
|
relays: scenario.getUrls(),
|
||||||
filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }],
|
filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }],
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -298,7 +304,8 @@ aliceBookmark.subscribe($b => console.log($b?.title))
|
||||||
## Integration Notes
|
## Integration Notes
|
||||||
|
|
||||||
- **`@welshman/net`** — provides `Repository` and `Tracker`. `Repository` is the event cache that feeds all store primitives in this package. Events flow from the network into the repository, which triggers store updates automatically.
|
- **`@welshman/net`** — provides `Repository` and `Tracker`. `Repository` is the event cache that feeds all store primitives in this package. Events flow from the network into the repository, which triggers store updates automatically.
|
||||||
- **`@welshman/util`** — provides `TrustedEvent`, `Filter`, `readProfile`, `readList`, and other event-parsing helpers that feed into `deriveItemsByKey` / `deriveEventsById`.
|
- **`@welshman/util`** — provides `TrustedEvent`, `Filter`, and the tag specs (`tagValue`, `hexTags`, …) used when decoding events.
|
||||||
|
- **`@welshman/domain`** — the typed readers you normally pass as `eventToItem`, instead of writing a parser by hand.
|
||||||
- **`@welshman/app`** — the high-level app layer re-exports and composes store utilities with pre-configured repositories, loaders, and context. If you are using `@welshman/app`, many of these stores are already wired up for you.
|
- **`@welshman/app`** — the high-level app layer re-exports and composes store utilities with pre-configured repositories, loaders, and context. If you are using `@welshman/app`, many of these stores are already wired up for you.
|
||||||
- Stores in this package are **framework-agnostic** at runtime (plain Svelte stores), so they work in SvelteKit SSR as well as browser-only Svelte apps. The `synced` store's `localStorageProvider` is browser-only — guard it with `if (browser)` in SvelteKit.
|
- Stores in this package are **framework-agnostic** at runtime (plain Svelte stores), so they work in SvelteKit SSR as well as browser-only Svelte apps. The `synced` store's `localStorageProvider` is browser-only — guard it with `if (browser)` in SvelteKit.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ yarn add @welshman/util
|
||||||
| `HashedEvent` | `OwnedEvent + id` |
|
| `HashedEvent` | `OwnedEvent + id` |
|
||||||
| `SignedEvent` | `HashedEvent + sig` |
|
| `SignedEvent` | `HashedEvent + sig` |
|
||||||
| `TrustedEvent` | `HashedEvent + optional sig` — most common in-app type |
|
| `TrustedEvent` | `HashedEvent + optional sig` — most common in-app type |
|
||||||
| `DecryptedEvent` | `TrustedEvent + plaintext` (for encrypted lists/events) |
|
|
||||||
|
|
||||||
### Event Utilities
|
### Event Utilities
|
||||||
|
|
||||||
|
|
@ -49,9 +48,6 @@ yarn add @welshman/util
|
||||||
| `isReplaceable(event)` | True for plain or parameterized replaceable |
|
| `isReplaceable(event)` | True for plain or parameterized replaceable |
|
||||||
| `isPlainReplaceable(event)` | True for kinds 10000–19999 and metadata/contacts |
|
| `isPlainReplaceable(event)` | True for kinds 10000–19999 and metadata/contacts |
|
||||||
| `isParameterizedReplaceable(event)` | True for kinds 30000–39999 |
|
| `isParameterizedReplaceable(event)` | True for kinds 30000–39999 |
|
||||||
| `getAncestors(event)` | Returns `{ roots, replies, mentions }` for NIP-10 events (mentions may be empty `[]` but is always present); NIP-22/COMMENT path returns `{ roots, replies }` without mentions |
|
|
||||||
| `getParentIdOrAddr(event)` | Immediate parent id or address |
|
|
||||||
| `isChildOf(child, parent)` | Check if child replies to parent |
|
|
||||||
|
|
||||||
### Type Guards
|
### Type Guards
|
||||||
|
|
||||||
|
|
@ -274,24 +270,16 @@ isDVMKind(kind) // 5000–7000
|
||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `getTags(types, tags)` | Get all tags matching one or more type strings |
|
| `tagSpec(keys, matchValue?, normalize?)` | Build a spec: which tag keys, plus optional validation/normalization |
|
||||||
| `getTag(types, tags)` | Get first matching tag |
|
| `hexTags(keys)` | Spec for 32-byte hex values (`e`, `p`, …) |
|
||||||
| `getTagValues(types, tags)` | Get value (index 1) of all matching tags — types first, then the tags array |
|
| `addressTags(keys)` | Spec for `kind:pubkey:d` addresses (`a`, `A`) |
|
||||||
| `getTagValue(types, tags)` | Get value of first matching tag — types first, then the tags array |
|
| `kindTags(keys)` | Spec for kind numbers (`k`) |
|
||||||
| `getEventTags(tags)` | `e` tags |
|
| `topicTags(keys)` | Spec for topics, normalized (`t`) |
|
||||||
| `getEventTagValues(tags)` | Values of `e` tags |
|
| `relayTags(keys)` | Spec for relay urls (`r`, `relay`) |
|
||||||
| `getAddressTags(tags)` | `a` tags |
|
| `tagValue(spec, tags)` | Value (index 1) of the first matching tag |
|
||||||
| `getAddressTagValues(tags)` | Values of `a` tags |
|
| `tagValues(spec, tags)` | Values of all matching tags |
|
||||||
| `getPubkeyTags(tags)` | `p` tags |
|
| `matchTag(spec, tags)` / `matchTags(spec, tags)` | The matching tag(s) themselves |
|
||||||
| `getPubkeyTagValues(tags)` | Values of `p` tags |
|
| `tagMatcher(spec)` | A `(tag) => boolean` predicate, for filtering in one pass |
|
||||||
| `getTopicTags(tags)` / `getTopicTagValues(tags)` | `t` (hashtag) tags |
|
|
||||||
| `getRelayTags(tags)` / `getRelayTagValues(tags)` | `r` and `relay` tags |
|
|
||||||
| `getKindTags(tags)` / `getKindTagValues(tags)` | `k` tags (returns `number[]`) |
|
|
||||||
| `getGroupTags(tags)` / `getGroupTagValues(tags)` | group tags |
|
|
||||||
| `getReplyTags(tags)` | `{ roots, replies, mentions }` — NIP-10 threading |
|
|
||||||
| `getCommentTags(tags)` | `{ roots, replies }` — NIP-22 uppercase/lowercase tags |
|
|
||||||
| `uniqTags(tags)` | Remove duplicate tags |
|
|
||||||
| `tagsFromIMeta(imeta)` | Parse `imeta` tag into array of tag arrays |
|
|
||||||
|
|
||||||
### Filters
|
### Filters
|
||||||
|
|
||||||
|
|
@ -319,50 +307,12 @@ isDVMKind(kind) // 5000–7000
|
||||||
| `Address.from(s, relays?)` | Parse from `kind:pubkey:identifier` string |
|
| `Address.from(s, relays?)` | Parse from `kind:pubkey:identifier` string |
|
||||||
| `Address.fromNaddr(naddr)` | Parse from NIP-19 naddr |
|
| `Address.fromNaddr(naddr)` | Parse from NIP-19 naddr |
|
||||||
| `Address.fromEvent(event, relays?)` | Create from addressable event |
|
| `Address.fromEvent(event, relays?)` | Create from addressable event |
|
||||||
| `address.toString()` | Serialize to `kind:pubkey:identifier` |
|
|
||||||
| `address.toNaddr()` | Serialize to NIP-19 naddr |
|
|
||||||
| `getAddress(event)` | Convenience: get address string from event |
|
| `getAddress(event)` | Convenience: get address string from event |
|
||||||
|
|
||||||
### Profile
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `makeProfile(partial)` | Create a profile object |
|
|
||||||
| `readProfile(event)` | Parse `PublishedProfile` from kind 0 event |
|
|
||||||
| `createProfile(profile)` | Create kind 0 `EventTemplate` |
|
|
||||||
| `editProfile(published)` | Update existing profile event |
|
|
||||||
| `displayProfile(profile?, fallback?)` | Get best display name string |
|
|
||||||
| `displayPubkey(pubkey)` | Shorten pubkey to `npub1abc...xyz` |
|
|
||||||
| `profileHasName(profile?)` | Check if profile has a name field |
|
|
||||||
|
|
||||||
Profile fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`, `nip05`, `lud06`, `lud16`, `lnurl`
|
|
||||||
|
|
||||||
### Lists (kind 10000+)
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `makeList(params)` | Create a new list |
|
|
||||||
| `readList(event)` | Parse `PublishedList` from `DecryptedEvent` |
|
|
||||||
| `getListTags(list)` | Combined public + private tags |
|
|
||||||
| `addToListPublicly(list, ...tags)` | Returns `Encryptable` with tag added publicly |
|
|
||||||
| `addToListPrivately(list, ...tags)` | Returns `Encryptable` with tag added privately |
|
|
||||||
| `removeFromList(list, value)` | Returns `Encryptable` with tag removed |
|
|
||||||
| `removeFromListByPredicate(list, pred)` | Returns `Encryptable` with matching tags removed |
|
|
||||||
| `updateList(list, { publicTags?, privateTags? })` | Bulk update tags |
|
|
||||||
|
|
||||||
### Encryptable
|
|
||||||
|
|
||||||
| Export | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `Encryptable<T>` | Wraps a partial event with plaintext updates; call `.reconcile(encrypt)` to produce encrypted event |
|
|
||||||
| `asDecryptedEvent(event, plaintext?)` | Attach plaintext data to a `TrustedEvent` |
|
|
||||||
|
|
||||||
### Relay
|
### Relay
|
||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `RelayMode` | Enum: `Read`, `Write`, `Search`, `Blocked`, `Messaging` |
|
|
||||||
| `RelayProfile` | NIP-11 relay info type |
|
|
||||||
| `isRelayUrl(url)` | Validate relay URL |
|
| `isRelayUrl(url)` | Validate relay URL |
|
||||||
| `isShareableRelayUrl(url)` | True if valid relay URL and not a local address |
|
| `isShareableRelayUrl(url)` | True if valid relay URL and not a local address |
|
||||||
| `isOnionUrl(url)` | Tor address check |
|
| `isOnionUrl(url)` | Tor address check |
|
||||||
|
|
@ -370,7 +320,6 @@ Profile fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`,
|
||||||
| `isIPAddress(url)` | IP address check |
|
| `isIPAddress(url)` | IP address check |
|
||||||
| `normalizeRelayUrl(url)` | Normalize to standard wss:// format |
|
| `normalizeRelayUrl(url)` | Normalize to standard wss:// format |
|
||||||
| `displayRelayUrl(url)` | Strip protocol and trailing slash |
|
| `displayRelayUrl(url)` | Strip protocol and trailing slash |
|
||||||
| `displayRelayProfile(profile?, fallback?)` | Get display name for relay |
|
|
||||||
|
|
||||||
### Zaps (NIP-57)
|
### Zaps (NIP-57)
|
||||||
|
|
||||||
|
|
@ -379,9 +328,6 @@ Profile fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`,
|
||||||
| `getLnUrl(address)` | Convert lightning address or URL to LNURL; returns `undefined` if invalid |
|
| `getLnUrl(address)` | Convert lightning address or URL to LNURL; returns `undefined` if invalid |
|
||||||
| `getInvoiceAmount(bolt11)` | Extract millisatoshi amount from BOLT11 invoice |
|
| `getInvoiceAmount(bolt11)` | Extract millisatoshi amount from BOLT11 invoice |
|
||||||
| `hrpToMillisat(hrpString)` | Convert human-readable BTC amount to millisats (`bigint`) |
|
| `hrpToMillisat(hrpString)` | Convert human-readable BTC amount to millisats (`bigint`) |
|
||||||
| `zapFromEvent(response, zapper)` | Validate zap receipt and return `Zap` or `undefined` |
|
|
||||||
| `Zapper` type | `{ lnurl, pubkey?, callback?, minSendable?, maxSendable?, nostrPubkey?, allowsNostr? }` |
|
|
||||||
| `Zap` type | `{ request: TrustedEvent, response: TrustedEvent, invoiceAmount: number }` |
|
|
||||||
|
|
||||||
### Wallet
|
### Wallet
|
||||||
|
|
||||||
|
|
@ -502,29 +448,14 @@ Only do this for events you persisted yourself after they were validated. Never
|
||||||
### Working with tags
|
### Working with tags
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import {tagValue, tagValues, hexTags, relayTags, tagSpec, topicTags} from '@welshman/util'
|
||||||
getTagValue,
|
|
||||||
getTagValues,
|
|
||||||
getPubkeyTagValues,
|
|
||||||
getTopicTagValues,
|
|
||||||
getRelayTagValues,
|
|
||||||
getReplyTags,
|
|
||||||
uniqTags,
|
|
||||||
} from '@welshman/util'
|
|
||||||
|
|
||||||
// getTagValue and getTagValues: types argument FIRST, then the tags array
|
// Specs come first, then the tags array. The spec says which keys to match and how to
|
||||||
const title = getTagValue('title', event.tags) // string | undefined
|
// validate the value, so malformed tags are skipped rather than silently returned.
|
||||||
const urls = getTagValues('r', event.tags) // string[]
|
const title = tagValue(tagSpec('title'), event.tags) // string | undefined
|
||||||
|
const urls = tagValues(relayTags('r'), event.tags) // string[], valid relay urls only
|
||||||
// Multiple types at once
|
const ids = tagValues(hexTags(['e', 'a']), event.tags) // string[], 32-byte hex only
|
||||||
const ids = getTagValues(['e', 'a'], event.tags) // string[]
|
const topics = tagValues(topicTags('t'), event.tags) // string[], normalized
|
||||||
|
|
||||||
const mentions = getPubkeyTagValues(event.tags) // string[]
|
|
||||||
const topics = getTopicTagValues(event.tags) // string[]
|
|
||||||
const relays = getRelayTagValues(event.tags) // string[]
|
|
||||||
|
|
||||||
// NIP-10 thread context
|
|
||||||
const { roots, replies, mentions: threadMentions } = getReplyTags(event.tags)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Matching and building filters
|
### Matching and building filters
|
||||||
|
|
@ -566,24 +497,11 @@ const parsed = Address.fromNaddr('naddr1...')
|
||||||
const addressStr = getAddress(event) // '30023:deadbeef:my-slug'
|
const addressStr = getAddress(event) // '30023:deadbeef:my-slug'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Profiles
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { readProfile, displayProfile, displayPubkey, editProfile } from '@welshman/util'
|
|
||||||
|
|
||||||
const profile = readProfile(kind0Event)
|
|
||||||
console.log(displayProfile(profile, 'Anonymous')) // name or fallback
|
|
||||||
console.log(displayPubkey(pubkey)) // 'npub1abc...xyz'
|
|
||||||
|
|
||||||
// Update profile
|
|
||||||
const updatedEvent = editProfile({ ...profile, name: 'New Name', about: 'Updated bio' })
|
|
||||||
// sign and publish updatedEvent
|
|
||||||
```
|
|
||||||
|
|
||||||
### Zap flow
|
### Zap flow
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { getLnUrl, makeEvent, ZAP_REQUEST, zapFromEvent } from '@welshman/util'
|
import { getLnUrl, makeEvent, ZAP_REQUEST } from '@welshman/util'
|
||||||
|
import { Zappers } from '@welshman/app'
|
||||||
|
|
||||||
// Step 1: resolve LNURL
|
// Step 1: resolve LNURL
|
||||||
const lnurl = getLnUrl('satoshi@getalby.com')
|
const lnurl = getLnUrl('satoshi@getalby.com')
|
||||||
|
|
@ -604,7 +522,7 @@ const zapRequest = makeEvent(ZAP_REQUEST, {
|
||||||
// Step 3: sign, send to LNURL callback, pay invoice...
|
// Step 3: sign, send to LNURL callback, pay invoice...
|
||||||
|
|
||||||
// Step 4: validate receipt (kind 9735)
|
// Step 4: validate receipt (kind 9735)
|
||||||
const zap = zapFromEvent(zapReceipt, { nostrPubkey: zapperPubkey, allowsNostr: true, lnurl })
|
const zap = await app.use(Zappers).validateZapReceipt(zapReceipt, zappedEvent)
|
||||||
if (zap) {
|
if (zap) {
|
||||||
console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content)
|
console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content)
|
||||||
}
|
}
|
||||||
|
|
@ -646,9 +564,9 @@ await fetch('https://api.example.com/upload', {
|
||||||
|
|
||||||
- **`@welshman/net`** — uses `TrustedEvent`, `Filter`, `SignedEvent` from this package as the wire types for relay connections and subscriptions.
|
- **`@welshman/net`** — uses `TrustedEvent`, `Filter`, `SignedEvent` from this package as the wire types for relay connections and subscriptions.
|
||||||
- **`@welshman/store`** — provides Svelte stores over repositories built on `TrustedEvent`; relies on `isReplaceable`, `getAddress`, etc. for deduplication.
|
- **`@welshman/store`** — provides Svelte stores over repositories built on `TrustedEvent`; relies on `isReplaceable`, `getAddress`, etc. for deduplication.
|
||||||
- **`@welshman/app`** — high-level application layer; wraps net/store/router and uses profile, list, zap, and handler helpers from this package.
|
- **`@welshman/app`** — high-level application layer; wraps net/store/domain and resolves this package's `RelaySelection` DSL through `app.use(Router)`.
|
||||||
- **`@welshman/router`** — uses `RelayMode` and relay URL helpers when computing relay selections.
|
- **`@welshman/domain`** — builds its typed readers on the tag specs (`tagValue`, `hexTags`, `addressTags`) defined here.
|
||||||
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here; the `Encrypt` function type used by `Encryptable` is typically provided by a signer.
|
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here, and supplies the encryption used when writing encrypted list kinds.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -658,11 +576,9 @@ await fetch('https://api.example.com/upload', {
|
||||||
|
|
||||||
- **Replaceable event identity**: Use `getIdOrAddress` rather than `event.id` when referencing events that may be addressable — the address string is stable across updates, the id is not.
|
- **Replaceable event identity**: Use `getIdOrAddress` rather than `event.id` when referencing events that may be addressable — the address string is stable across updates, the id is not.
|
||||||
|
|
||||||
- **`getAncestors` handles two protocols**: Kind 1111 (comment/NIP-22) uses uppercase `E`/`A` for roots and lowercase for replies, returning `{ roots, replies }`. All other kinds use NIP-10 positional rules, returning `{ roots, replies, mentions }` where `mentions` is always present but may be an empty array. You do not need to branch on this; `getAncestors`, `getParentIdOrAddr`, and `isChildOf` handle it automatically.
|
|
||||||
|
|
||||||
- **List mutations return `Encryptable`**: `addToListPrivately`, `removeFromList`, etc. do not return an event directly. Call `.reconcile(encryptFn)` on the result to get the final `EventTemplate` ready to sign.
|
|
||||||
|
|
||||||
- **`zapFromEvent` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result.
|
- **`validateZapReceipt` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. For a reactive list of a parent's valid zaps, use `app.use(Zappers).validZapReceipts(receipts, parent)`, which re-validates as each recipient's zapper loads.
|
||||||
|
|
||||||
- **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else.
|
- **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,14 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http
|
||||||
|
|
||||||
| Package | Description |
|
| Package | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `@welshman/util` | Core nostr types, event helpers, filters, and NIP implementations |
|
| `@welshman/util` | Core nostr types, event helpers, filters, tag specs, NIPs, and the `RelaySelection` routing DSL |
|
||||||
| `@welshman/lib` | General-purpose utilities: LRU cache, event emitter, deferred promises, task queue |
|
| `@welshman/lib` | General-purpose utilities: LRU cache, event emitter, deferred promises, task queue |
|
||||||
| `@welshman/net` | Relay connections, request/publish lifecycle, and auth handling |
|
| `@welshman/net` | Relay connections, request/publish lifecycle, and auth handling |
|
||||||
| `@welshman/router` | Relay selection strategies for reads and writes |
|
| `@welshman/domain` | A typed Reader/Writer pair per event kind, so you never hand-parse tags |
|
||||||
| `@welshman/store` | Svelte stores and a Repository for indexing/querying nostr events client-side |
|
| `@welshman/store` | Svelte stores and a Repository for indexing/querying nostr events client-side |
|
||||||
| `@welshman/signer` | Signing and login methods: NIP-01 (privkey), NIP-07 (extension), NIP-46 (bunker), NIP-55 (app), NIP-59 (gift wrap) |
|
| `@welshman/signer` | Signing and login methods: NIP-01 (privkey), NIP-07 (extension), NIP-46 (bunker), NIP-55 (app), NIP-59 (gift wrap) |
|
||||||
| `@welshman/feeds` | Dynamic feed construction, filtering, and composition |
|
| `@welshman/feeds` | Dynamic feed construction, filtering, and composition |
|
||||||
| `@welshman/app` | High-level Svelte stores that compose net, router, store, signer, and feeds into a full application framework |
|
| `@welshman/app` | The `App` instance and its plugin registry, composing net, store, domain, signer, and feeds into a full application framework |
|
||||||
| `@welshman/content` | Parser and renderer for nostr note content (links, mentions, media, custom formatting) |
|
| `@welshman/content` | Parser and renderer for nostr note content (links, mentions, media, custom formatting) |
|
||||||
| `@welshman/editor` | Batteries-included Svelte rich-text editor component with mention and embed support |
|
| `@welshman/editor` | Batteries-included Svelte rich-text editor component with mention and embed support |
|
||||||
|
|
||||||
|
|
@ -27,25 +27,43 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http
|
||||||
Packages are layered so lower-level ones have no welshman dependencies:
|
Packages are layered so lower-level ones have no welshman dependencies:
|
||||||
|
|
||||||
- **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util`
|
- **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util`
|
||||||
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/router`, `@welshman/store`, `@welshman/signer`
|
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/store`, `@welshman/signer`, `@welshman/domain`
|
||||||
- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/app`
|
- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/app`
|
||||||
- **UI-focused** (largely independent, UI rendering concerns): `@welshman/content`, `@welshman/editor`
|
- **UI-focused** (largely independent, UI rendering concerns): `@welshman/content`, `@welshman/editor`
|
||||||
|
|
||||||
For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-signer`).
|
For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-domain`).
|
||||||
|
|
||||||
## Getting started
|
## The App instance
|
||||||
|
|
||||||
Install only what you need:
|
Everything in the framework hangs off one `App`. An app owns the primitives a single identity
|
||||||
|
needs — repository, socket pool, tracker, wrap manager — so data never bleeds across sessions.
|
||||||
|
|
||||||
```bash
|
```typescript
|
||||||
# Full application framework (includes app, net, router, store, signer, feeds)
|
import {createApp, Network, Profiles, User} from "@welshman/app"
|
||||||
npm i @welshman/app
|
|
||||||
|
|
||||||
# Or assemble manually for more control
|
// `createApp` = `new App` plus the default policies (ingest, relay stats, gift-wrap unwrapping)
|
||||||
npm i @welshman/util @welshman/net @welshman/signer
|
const app = createApp({
|
||||||
|
user: await User.fromSigner(signer), // omit for a signed-out app
|
||||||
|
config: {
|
||||||
|
getDefaultRelays: () => ["wss://relay.example.com"],
|
||||||
|
getIndexerRelays: () => ["wss://indexer.example.com"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use(Profiles).load(pubkey) // plugins are per-app singletons, constructed on demand
|
||||||
|
app.use(Network).load({relays, filters})
|
||||||
```
|
```
|
||||||
|
|
||||||
If you're building a conventional nostr web client, use `@welshman/app` for batteries-included functionality. For more advanced usage, use the lower-level modules without `app` for more control.
|
Three rules follow from this design:
|
||||||
|
|
||||||
|
1. **`app.use(Plugin)` is memoized and cheap** — call it inline rather than caching the result.
|
||||||
|
2. **An app is scoped to one identity.** Logging in means building a *new* app and calling
|
||||||
|
`cleanup()` on the old one, not attaching a user to the existing one.
|
||||||
|
3. **Side effects live in policies**, not in the data classes. An `AppPolicy` is
|
||||||
|
`(app) => Unsubscriber`, applied once at construction and torn down by `cleanup()`.
|
||||||
|
|
||||||
|
Svelte apps typically wrap `app` in a store so plugin reads re-subscribe when login swaps the
|
||||||
|
instance. That binding layer is app-specific and deliberately not part of welshman.
|
||||||
|
|
||||||
## Key nostr concepts
|
## Key nostr concepts
|
||||||
|
|
||||||
|
|
@ -61,91 +79,67 @@ If you're building a conventional nostr web client, use `@welshman/app` for batt
|
||||||
|
|
||||||
| Goal | Package(s) to use |
|
| Goal | Package(s) to use |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Fetch notes from relays | `@welshman/net` (low-level) or `@welshman/app` (high-level) |
|
| Fetch notes from relays | `app.use(Network)`, or `@welshman/net` directly for low-level control |
|
||||||
| Select which relays to use | `@welshman/router` |
|
| Select which relays to use | `RelaySelection` helpers in `@welshman/util` + `app.use(Router)` |
|
||||||
| Sign and publish events | `@welshman/signer` + `@welshman/net` |
|
| Read or write a specific kind | `@welshman/domain` via `app.use(Domain)` |
|
||||||
| Build a feed UI | `@welshman/feeds` + `@welshman/app` |
|
| Sign and publish events | `@welshman/signer` + `Command` from `@welshman/app` |
|
||||||
|
| Build a feed UI | `@welshman/feeds` + `app.use(Feeds)` |
|
||||||
| Parse note text and media | `@welshman/content` |
|
| Parse note text and media | `@welshman/content` |
|
||||||
| Embed a composer / editor | `@welshman/editor` |
|
| Embed a composer / editor | `@welshman/editor` |
|
||||||
| Cache nostr events client-side | `@welshman/store` |
|
| Cache nostr events client-side | `@welshman/store` + `app.repository` |
|
||||||
| Core event/filter utilities | `@welshman/util` |
|
| Core event/filter/tag utilities | `@welshman/util` |
|
||||||
| Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` |
|
| Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` |
|
||||||
|
|
||||||
### App Example
|
## App example
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import "@welshman/app" // side effects: wires pool → repository + tracker + router
|
import {createApp, Domain, Profiles, User} from "@welshman/app"
|
||||||
|
import {Note} from "@welshman/domain"
|
||||||
|
import {Nip07Signer} from "@welshman/signer"
|
||||||
|
|
||||||
import { openDB } from "idb"
|
// 1. Build an app for the signed-in user
|
||||||
import { batch, on } from "@welshman/lib"
|
const signer = new Nip07Signer()
|
||||||
import { verifiedSymbol } from "@welshman/util"
|
const app = createApp({
|
||||||
import { repository, tracker, loginWithNip07, publishThunk, userProfile, loadUserProfile } from "@welshman/app"
|
user: await User.fromSigner(signer),
|
||||||
import { routerContext } from "@welshman/router"
|
config: {
|
||||||
import { load } from "@welshman/net"
|
getDefaultRelays: () => ["wss://relay.example.com"],
|
||||||
import type { TrustedEvent } from "@welshman/util"
|
getIndexerRelays: () => ["wss://indexer.example.com"],
|
||||||
import type { RepositoryUpdate } from "@welshman/net"
|
|
||||||
|
|
||||||
// 1. Configure fallback relays
|
|
||||||
routerContext.getDefaultRelays = () => ["wss://relay.example.com", "wss://relay2.example.com"]
|
|
||||||
routerContext.getIndexerRelays = () => ["wss://indexer.example.com"]
|
|
||||||
|
|
||||||
// 2. Open IndexedDB and hydrate the repository
|
|
||||||
const db = await openDB("my-app", 1, {
|
|
||||||
upgrade(db) {
|
|
||||||
db.createObjectStore("events", { keyPath: "id" })
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const stored: TrustedEvent[] = await db.getAll("events")
|
// 2. Read the user's profile (loads from their write relays if not cached)
|
||||||
for (const e of stored) e[verifiedSymbol] = true
|
const profile = await app.use(Profiles).load(app.user!.pubkey)
|
||||||
repository.load(stored)
|
|
||||||
|
|
||||||
// Flush new events to IndexedDB
|
console.log("Hello,", profile?.display())
|
||||||
on(repository, "update", batch(3000, async (updates: RepositoryUpdate[]) => {
|
|
||||||
const tx = db.transaction("events", "readwrite")
|
|
||||||
for (const { added, removed } of updates) {
|
|
||||||
for (const e of added) tx.store.put(e)
|
|
||||||
for (const id of removed) tx.store.delete(id)
|
|
||||||
}
|
|
||||||
await tx.done
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 3. Log in
|
// 3. Publish a note — build a writer, wrap it in a command, publish it
|
||||||
const pk = await window.nostr.getPublicKey()
|
const writer = app.use(Domain).writer(Note).setContent("Hello, Nostr!")
|
||||||
loginWithNip07(pk)
|
const command = await app.use(Domain).command(writer)
|
||||||
|
|
||||||
// 4. Load user's profile reactively (triggers network fetch if not cached)
|
await command.publish().waitForError()
|
||||||
await loadUserProfile()
|
|
||||||
|
|
||||||
userProfile.subscribe($profile => {
|
|
||||||
if ($profile) console.log("Hello,", $profile.name)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. Publish a note
|
|
||||||
import { makeEvent } from "@welshman/util"
|
|
||||||
import { Router } from "@welshman/router"
|
|
||||||
|
|
||||||
const thunk = publishThunk({
|
|
||||||
event: makeEvent(1, { content: "Hello, Nostr!", tags: [] }),
|
|
||||||
relays: Router.get().FromUser().getUrls(),
|
|
||||||
})
|
|
||||||
|
|
||||||
await thunk.complete
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lower-level Example
|
Publishing goes through a `Command`, which owns the rendered event and its resolved relays:
|
||||||
|
`command.publish()`, `.publishToRelays(urls)`, or `.publishAsRelay(url)`. Plugin mutators
|
||||||
|
(`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`) already return a `Command`,
|
||||||
|
so `.then(publish)` is usually all you need.
|
||||||
|
|
||||||
|
## Lower-level example
|
||||||
|
|
||||||
|
The net layer takes an explicit context, so it can be used without an `App` at all.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { AbstractAdapter, ClientMessage, NetContext, isClientEvent, netContext, publish, request } from '@welshman/net'
|
import {AbstractAdapter, isClientEvent, publish, request} from "@welshman/net"
|
||||||
import { call, sleep } from '@welshman/lib'
|
import type {ClientMessage, NetContext} from "@welshman/net"
|
||||||
import { Nip01Signer } from '@welshman/signer'
|
import {call, sleep} from "@welshman/lib"
|
||||||
import { makeEvent, NOTE } from '@welshman/util'
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
|
import {makeEvent, NOTE} from "@welshman/util"
|
||||||
|
|
||||||
const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
|
const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
|
||||||
const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
|
const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
|
||||||
const RELAY_URL = "bogus.relay"
|
const RELAY_URL = "bogus.relay"
|
||||||
|
|
||||||
// Create an adapter for our relay url which just prints the content
|
// An adapter for our relay url which just prints the content
|
||||||
export class PrintAdapter extends AbstractAdapter {
|
export class PrintAdapter extends AbstractAdapter {
|
||||||
get sockets() { return [] }
|
get sockets() { return [] }
|
||||||
get urls() { return [] }
|
get urls() { return [] }
|
||||||
|
|
@ -157,37 +151,32 @@ export class PrintAdapter extends AbstractAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure net context to use our custom adapter
|
// Context is passed explicitly. An `App` supplies its own via `app.netContext`; here we build one.
|
||||||
netContext.getAdapter = (url: string, context: NetContext) => {
|
const context: NetContext = {
|
||||||
if (url === RELAY_URL) {
|
getAdapter: (url: string) => (url === RELAY_URL ? new PrintAdapter() : undefined),
|
||||||
return new PrintAdapter()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loop, sending off pings every so often
|
|
||||||
call(async () => {
|
call(async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
await sleep(1000)
|
await sleep(1000)
|
||||||
|
|
||||||
const ping = await pingSigner.sign(
|
const ping = await pingSigner.sign(makeEvent(NOTE, {content: "ping"}))
|
||||||
makeEvent(NOTE, {content: 'ping'})
|
|
||||||
)
|
|
||||||
|
|
||||||
await publish({event: ping, relays: [RELAY_URL]})
|
await publish({event: ping, relays: [RELAY_URL], context})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Meanwhile, listen for pings and quote-note with a pong
|
|
||||||
call(async () => {
|
call(async () => {
|
||||||
request({
|
request({
|
||||||
relays: [RELAY_URL],
|
relays: [RELAY_URL],
|
||||||
filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}],
|
filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}],
|
||||||
|
context,
|
||||||
onEvent: async (ping, url) => {
|
onEvent: async (ping, url) => {
|
||||||
const pong = await pongSigner.sign(
|
const pong = await pongSigner.sign(
|
||||||
makeEvent(NOTE, {content: 'pong', tags: [["q", ping.id, RELAY_URL, ping.pubkey]]})
|
makeEvent(NOTE, {content: "pong", tags: [["q", ping.id, RELAY_URL, ping.pubkey]]}),
|
||||||
)
|
)
|
||||||
|
|
||||||
await publish({event: pong, relays: [RELAY_URL]})
|
await publish({event: pong, relays: [RELAY_URL], context})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
53
AGENTS.md
53
AGENTS.md
|
|
@ -45,8 +45,9 @@ Example:
|
||||||
```typescript
|
```typescript
|
||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
import {throttle} from "throttle-debounce"
|
import {throttle} from "throttle-debounce"
|
||||||
|
import {Profiles} from "@welshman/app"
|
||||||
import {Dialog} from "$lib/components"
|
import {Dialog} from "$lib/components"
|
||||||
import {repository} from "$app/core/state"
|
import {app} from "@app/core"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cleanup Pass
|
## Cleanup Pass
|
||||||
|
|
@ -67,9 +68,19 @@ Conventions below.
|
||||||
**Core Principles:**
|
**Core Principles:**
|
||||||
|
|
||||||
- Use Svelte 4 **stores** for all state (NOT runes outside UI components)
|
- Use Svelte 4 **stores** for all state (NOT runes outside UI components)
|
||||||
- Most global state flows through Welshman's `repository` (unidirectional)
|
- Everything hangs off the single `App` instance in `app/core.ts`. There are no welshman globals —
|
||||||
- Query state using `deriveEventsMapped` or `deriveProfile` etc
|
reach features with `app.use(Plugin)`, which is memoized per app and cheap to call inline.
|
||||||
- Update state by publishing events via `publishThunk`
|
- `app/core.ts` also exports `pubkey`, `signer`, `user` and `session` stores (all with `.get()`),
|
||||||
|
`login`, and `deriveUserItem(plugin)` for the current user's entry in a keyed collection.
|
||||||
|
- Most global state flows through the app's `repository` (unidirectional)
|
||||||
|
- Query state with a plugin's `one(key)` / `index` / `all`, or `deriveEventsById` /
|
||||||
|
`deriveItemsByKey` from `@welshman/store` against `app.repository`
|
||||||
|
- Update state by building a domain writer and publishing the resulting `Command`
|
||||||
|
|
||||||
|
**Projections:**
|
||||||
|
|
||||||
|
A `Projection<T>` is `{get(): T, $: Readable<T>}` — bind `.$` in markup, call `.get()` in
|
||||||
|
callbacks and hot paths.
|
||||||
|
|
||||||
**Thunks:**
|
**Thunks:**
|
||||||
|
|
||||||
|
|
@ -82,12 +93,12 @@ Conventions below.
|
||||||
|
|
||||||
**Welshman Library Suite:**
|
**Welshman Library Suite:**
|
||||||
|
|
||||||
- `@welshman/app` - High-level state (pubkey, signer, repository, tracker)
|
- `@welshman/app` - The `App` instance and its plugins (Profiles, Rooms, Thunks, Router, …)
|
||||||
- `@welshman/net` - Network layer (Pool, Socket, load, pull, request)
|
- `@welshman/domain` - A typed Reader/Writer pair per event kind, plus `Relay` and `Zapper`
|
||||||
- `@welshman/store` - Svelte integration (deriveEventsMapped, etc.)
|
- `@welshman/net` - Network layer (Pool, Socket, adapters, request/publish/pull)
|
||||||
- `@welshman/util` - Event utilities (kinds, tags, validation)
|
- `@welshman/store` - Svelte integration (deriveEventsById, deriveItemsByKey, etc.)
|
||||||
|
- `@welshman/util` - Event utilities (kinds, tags, validation, the RelaySelection routing DSL)
|
||||||
- `@welshman/signer` - Signing abstraction (NIP-01, NIP-07, NIP-46)
|
- `@welshman/signer` - Signing abstraction (NIP-01, NIP-07, NIP-46)
|
||||||
- `@welshman/router` - Relay routing (inbox/outbox model)
|
|
||||||
- `@welshman/editor` - Rich text editor with Nostr
|
- `@welshman/editor` - Rich text editor with Nostr
|
||||||
- `@welshman/content` - Content parsing
|
- `@welshman/content` - Content parsing
|
||||||
- `@welshman/feeds` - Feed management
|
- `@welshman/feeds` - Feed management
|
||||||
|
|
@ -124,12 +135,13 @@ Conventions below.
|
||||||
- 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.
|
- 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, use `getTagValue(tagName, event.tags)` (or `getTagValues` for all matches) rather than reaching into the tag array yourself — that means no `getTag(tagName, event.tags)?.[1] || ""` and no `event.tags.find(nthEq(0, tagName))?.[1]`. `getTagValue` is exactly the latter, so the replacement is behavior-preserving, `undefined` included. Reserve `nthEq` for cases with no `getTag*` equivalent, such as `partition(nthEq(0, "imeta"), tags)`.
|
- To read a tag, prefer the domain reader's getter (`note.content()`, `roomMeta.name()`) over touching tags at all. Where there's no reader, use `tagValue(spec, tags)` / `tagValues(spec, tags)` from `@welshman/util` rather than reaching into the tag array yourself — that means no `tags.find(nthEq(0, name))?.[1]`. Build the spec with the narrowest helper that fits: `hexTags("p")`, `relayTags(["r", "relay"])`, `addressTags("a")`, `kindTags("k")`, `topicTags("t")`, or plain `tagSpec("h")` when the value needs no validation. Reserve `nthEq` for cases with no spec equivalent, such as `partition(nthEq(0, "imeta"), tags)`.
|
||||||
- Do not render a profile's `about` directly (e.g. `profile.about`); use the `ProfileAbout` component instead.
|
- Do not render a profile's `about` directly (e.g. `profile.about()`); use the `ProfileAbout` component instead.
|
||||||
- Use `type Props` instead of interface when defining props for svelte components.
|
- Use `type Props` instead of interface when defining props for svelte components.
|
||||||
- When a component's value/prop shape mirrors a subset of an existing type, derive it with `Pick`/`Partial` and `export` that type from the component's `<script module>` (e.g. a `Values` type) for callers to import, instead of re-enumerating its sub-properties.
|
- When a component's value/prop shape mirrors a subset of an existing type, derive it with `Pick`/`Partial` and `export` that type from the component's `<script module>` (e.g. a `Values` type) for callers to import, instead of re-enumerating its sub-properties.
|
||||||
- Avoid pass-through functions except when the wrapper is part of an abstraction. `x => y()` is not ok, but `x => this.impl.y()` is ok, for example.
|
- 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)`.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|
@ -161,20 +173,23 @@ Conventions below.
|
||||||
|
|
||||||
### Loading Data from Network
|
### Loading Data from Network
|
||||||
|
|
||||||
1. Use utilities from `app/core/requests.ts`
|
1. Prefer a plugin's `one(key)` / `load(key)`, which handle outbox routing and caching
|
||||||
2. Or create derived stores in `app/core/state.ts`
|
2. For anything else use `app.use(Network).load/request` or `app.use(Sync).pull/push` — the bare
|
||||||
3. Use `load`, `pull`, or `request` from `@welshman/net`
|
`load`/`request`/`publish` from `@welshman/net` throw without a context
|
||||||
|
|
||||||
### Publishing Events
|
### Publishing Events
|
||||||
|
|
||||||
1. Create `make*` function to build event template
|
1. Build a writer: `app.use(Domain).writer(Kind, reader?)`, then chain its setters
|
||||||
2. Create `publish*` function using `publishThunk`
|
2. Wrap it: `const command = await app.use(Domain).command(writer)`
|
||||||
3. Display thunk status to user (for cancel/error handling)
|
3. Publish it: `command.publish()`, `.publishToRelays(urls)`, or `.publishAsRelay(url)`
|
||||||
4. These go in in `app/core/commands.ts`
|
4. Display thunk status to user (for cancel/error handling)
|
||||||
|
|
||||||
|
Plugin mutators (`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`, …) already
|
||||||
|
return a `Command`, so `.then(publish)` is usually all you need.
|
||||||
|
|
||||||
### Managing Modals/Toasts
|
### Managing Modals/Toasts
|
||||||
|
|
||||||
- Import from `app/util/modal.ts` or `app/util/toast.ts`
|
- Import from `app/modal.ts` or `app/toast.ts`
|
||||||
- Pass component objects with parameters
|
- Pass component objects with parameters
|
||||||
- Use `$state.snapshot` if calling component might unmount
|
- Use `$state.snapshot` if calling component might unmount
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
||||||
val session = state.optJSONObject("session") ?: return SessionInfo("anonymous", "", JSONObject())
|
val session = state.optJSONObject("session") ?: return SessionInfo("anonymous", "", JSONObject())
|
||||||
return SessionInfo(
|
return SessionInfo(
|
||||||
session.optString("method", "anonymous"),
|
session.optString("method", "anonymous"),
|
||||||
session.optString("pubkey", ""),
|
state.optString("pubkey", ""),
|
||||||
session,
|
session.optJSONObject("data") ?: JSONObject(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -537,7 +537,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
||||||
private data class SessionInfo(
|
private data class SessionInfo(
|
||||||
val method: String,
|
val method: String,
|
||||||
val pubkey: String,
|
val pubkey: String,
|
||||||
val session: JSONObject,
|
val data: JSONObject,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class Subscription(
|
private data class Subscription(
|
||||||
|
|
@ -699,24 +699,23 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tryNip01Auth(webSocket: WebSocket, challenge: String): Boolean {
|
private fun tryNip01Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||||
val secret = sessionInfo.session.optString("secret", "")
|
val secret = sessionInfo.data.optString("secret", "")
|
||||||
if (secret.isEmpty()) return false
|
if (secret.isEmpty()) return false
|
||||||
val signed = signWithNip01Secret(secret, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
val signed = signWithNip01Secret(secret, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
||||||
return sendAuthMessage(webSocket, signed)
|
return sendAuthMessage(webSocket, signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tryNip55Auth(webSocket: WebSocket, challenge: String): Boolean {
|
private fun tryNip55Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||||
val signerPackage = sessionInfo.session.optString("signer", "")
|
val signerPackage = sessionInfo.data.optString("signer", "")
|
||||||
if (signerPackage.isEmpty()) return false
|
if (signerPackage.isEmpty()) return false
|
||||||
val signed = signWithNip55ContentResolver(signerPackage, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
val signed = signWithNip55ContentResolver(signerPackage, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
||||||
return sendAuthMessage(webSocket, signed)
|
return sendAuthMessage(webSocket, signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tryNip46Auth(webSocket: WebSocket, challenge: String): Boolean {
|
private fun tryNip46Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||||
val handler = sessionInfo.session.optJSONObject("handler") ?: return false
|
val clientSecret = sessionInfo.data.optString("clientSecret", "")
|
||||||
val clientSecret = sessionInfo.session.optString("secret", "")
|
val signerPubkey = sessionInfo.data.optString("signerPubkey", "")
|
||||||
val signerPubkey = handler.optString("pubkey", "")
|
val relays = sessionInfo.data.optJSONArray("relays")
|
||||||
val relays = handler.optJSONArray("relays")
|
|
||||||
|
|
||||||
if (clientSecret.isEmpty() || signerPubkey.isEmpty() || relays == null || relays.length() == 0) return false
|
if (clientSecret.isEmpty() || signerPubkey.isEmpty() || relays == null || relays.length() == 0) return false
|
||||||
|
|
||||||
|
|
|
||||||
22
package.json
22
package.json
|
|
@ -63,7 +63,7 @@
|
||||||
"@getalby/sdk": "^5.1.2",
|
"@getalby/sdk": "^5.1.2",
|
||||||
"@hono/node-server": "^2.0.0",
|
"@hono/node-server": "^2.0.0",
|
||||||
"@noble/curves": "^1.9.7",
|
"@noble/curves": "^1.9.7",
|
||||||
"@pomade/core": "^0.3.1",
|
"@pomade/core": "^0.3.3",
|
||||||
"@poppanator/sveltekit-svg": "^7.0.0",
|
"@poppanator/sveltekit-svg": "^7.0.0",
|
||||||
"@svelte-plugins/datepicker": "^1.0.11",
|
"@svelte-plugins/datepicker": "^1.0.11",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
|
@ -73,16 +73,16 @@
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vite-pwa/sveltekit": "^1.1.0",
|
"@vite-pwa/sveltekit": "^1.1.0",
|
||||||
"@welshman/app": "^0.8.16",
|
"@welshman/app": "^0.9.0-pre2",
|
||||||
"@welshman/content": "^0.8.16",
|
"@welshman/content": "^0.9.0-pre2",
|
||||||
"@welshman/editor": "^0.8.16",
|
"@welshman/domain": "^0.9.0-pre2",
|
||||||
"@welshman/feeds": "^0.8.16",
|
"@welshman/editor": "^0.9.0-pre2",
|
||||||
"@welshman/lib": "^0.8.16",
|
"@welshman/feeds": "^0.9.0-pre2",
|
||||||
"@welshman/net": "^0.8.16",
|
"@welshman/lib": "^0.9.0-pre2",
|
||||||
"@welshman/router": "^0.8.16",
|
"@welshman/net": "^0.9.0-pre2",
|
||||||
"@welshman/signer": "^0.8.16",
|
"@welshman/signer": "^0.9.0-pre2",
|
||||||
"@welshman/store": "^0.8.16",
|
"@welshman/store": "^0.9.0-pre2",
|
||||||
"@welshman/util": "^0.8.16",
|
"@welshman/util": "^0.9.0-pre2",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"compressorjs-next": "^1.1.2",
|
"compressorjs-next": "^1.1.2",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
|
|
|
||||||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -11,9 +11,19 @@ minimumReleaseAgeExclude:
|
||||||
- '@welshman/feeds'
|
- '@welshman/feeds'
|
||||||
- '@welshman/lib'
|
- '@welshman/lib'
|
||||||
- '@welshman/net'
|
- '@welshman/net'
|
||||||
- '@welshman/router'
|
- '@welshman/domain'
|
||||||
- '@welshman/signer'
|
- '@welshman/signer'
|
||||||
- '@welshman/store'
|
- '@welshman/store'
|
||||||
- '@welshman/util'
|
- '@welshman/util'
|
||||||
overrides:
|
overrides:
|
||||||
|
'@welshman/app': link:../welshman/packages/app
|
||||||
|
'@welshman/content': link:../welshman/packages/content
|
||||||
|
'@welshman/domain': link:../welshman/packages/domain
|
||||||
|
'@welshman/editor': link:../welshman/packages/editor
|
||||||
|
'@welshman/feeds': link:../welshman/packages/feeds
|
||||||
|
'@welshman/lib': link:../welshman/packages/lib
|
||||||
|
'@welshman/net': link:../welshman/packages/net
|
||||||
|
'@welshman/signer': link:../welshman/packages/signer
|
||||||
|
'@welshman/store': link:../welshman/packages/store
|
||||||
|
'@welshman/util': link:../welshman/packages/util
|
||||||
sharp: ^0.35.3
|
sharp: ^0.35.3
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,28 @@
|
||||||
import {derived, get, writable, type Readable} from "svelte/store"
|
import {derived, writable, type Readable} from "svelte/store"
|
||||||
import {dissoc, fromPairs, last, poll, randomId, sleep, tryCatch} from "@welshman/lib"
|
import {dissoc, fromPairs, last, poll, randomId, sleep, tryCatch} from "@welshman/lib"
|
||||||
import {AuthStatus, Pool, request, SocketStatus} from "@welshman/net"
|
import {AuthStatus, SocketStatus} from "@welshman/net"
|
||||||
|
import type {Socket} from "@welshman/net"
|
||||||
import {
|
import {
|
||||||
displayRelayUrl,
|
displayRelayUrl,
|
||||||
getTagValue,
|
|
||||||
isRelayUrl,
|
isRelayUrl,
|
||||||
makeEvent,
|
makeEvent,
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
|
FOLLOWS,
|
||||||
|
MESSAGING_RELAYS,
|
||||||
|
PROFILE,
|
||||||
RELAY_INVITE,
|
RELAY_INVITE,
|
||||||
RELAY_JOIN,
|
RELAYS,
|
||||||
RELAY_LEAVE,
|
|
||||||
ROOM_JOIN,
|
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import {publishThunk, sign, waitForThunkError} from "@welshman/app"
|
import {RelayInvite, RelayJoin, RelayLeave, RoomJoin} from "@welshman/domain"
|
||||||
|
import {Sync, User, publish} from "@welshman/app"
|
||||||
import {stripPrefix} from "@lib/util"
|
import {stripPrefix} from "@lib/util"
|
||||||
|
import {app, command, network, reader, roomLists, thunks, writer} from "@app/core"
|
||||||
import {PLATFORM_URL} from "@app/env"
|
import {PLATFORM_URL} from "@app/env"
|
||||||
import {addSpace, userSpaceUrls} from "@app/groups"
|
|
||||||
import {relaysMostlyRestricted} from "@app/policies"
|
import {relaysMostlyRestricted} from "@app/policies"
|
||||||
import {broadcastUserData} from "@app/profiles"
|
|
||||||
import {Push} from "@app/push"
|
import {Push} from "@app/push"
|
||||||
import {syncApplicationData} from "@app/sync"
|
|
||||||
import {notificationSettings, setSpaceNotifications} from "@app/settings"
|
|
||||||
import {deriveSocket} from "@app/relays"
|
import {deriveSocket} from "@app/relays"
|
||||||
|
import {notificationSettings, setSpaceNotifications} from "@app/settings"
|
||||||
|
import {syncApplicationData} from "@app/sync"
|
||||||
|
|
||||||
export const ROOM_CREATE_INVITE = 9009
|
export const ROOM_CREATE_INVITE = 9009
|
||||||
|
|
||||||
|
|
@ -32,11 +33,6 @@ export type InviteData = {
|
||||||
code?: string
|
code?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type JoinRequestParams = {
|
|
||||||
url: string
|
|
||||||
claim: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isNetworkAuthError = (error: string) => {
|
export const isNetworkAuthError = (error: string) => {
|
||||||
const lower = error.toLowerCase()
|
const lower = error.toLowerCase()
|
||||||
|
|
||||||
|
|
@ -104,32 +100,35 @@ export const deriveRelayAuthError = (url: string) =>
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const makeJoinRequest = (params: JoinRequestParams) =>
|
export const publishJoinRequest = (url: string, claim = "") =>
|
||||||
makeEvent(RELAY_JOIN, {tags: [["claim", params.claim]]})
|
command(writer(RelayJoin).forceRelays(url).setClaim(claim)).then(publish)
|
||||||
|
|
||||||
export const publishJoinRequest = (params: JoinRequestParams) =>
|
export const publishLeaveRequest = (url: string) =>
|
||||||
publishThunk({event: makeJoinRequest(params), relays: [params.url]})
|
command(writer(RelayLeave).forceRelays(url)).then(publish)
|
||||||
|
|
||||||
export type LeaveRequestParams = {
|
export const publishRoomJoinRequest = (url: string, h: string, code?: string) => {
|
||||||
url: string
|
const eventWriter = writer(RoomJoin).setRoom(url, h)
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
eventWriter.setClaim(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return command(eventWriter).then(publish)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const publishLeaveRequest = (params: LeaveRequestParams) =>
|
|
||||||
publishThunk({event: makeEvent(RELAY_LEAVE), relays: [params.url]})
|
|
||||||
|
|
||||||
export const publishRoomInvite = async (url: string, h: string) => {
|
export const publishRoomInvite = async (url: string, h: string) => {
|
||||||
const code = randomId()
|
const code = randomId()
|
||||||
const error = await waitForThunkError(
|
const event = makeEvent(ROOM_CREATE_INVITE, {
|
||||||
publishThunk({
|
|
||||||
event: makeEvent(ROOM_CREATE_INVITE, {
|
|
||||||
tags: [
|
tags: [
|
||||||
["h", h],
|
["h", h],
|
||||||
["code", code],
|
["code", code],
|
||||||
],
|
],
|
||||||
}),
|
})
|
||||||
relays: [url],
|
|
||||||
}),
|
const error = await thunks
|
||||||
)
|
.get()
|
||||||
|
.publish({event, relays: [url]})
|
||||||
|
.waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return {error, code: undefined}
|
return {error, code: undefined}
|
||||||
|
|
@ -138,19 +137,6 @@ export const publishRoomInvite = async (url: string, h: string) => {
|
||||||
return {error: undefined, code}
|
return {error: undefined, code}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const publishRoomJoinRequest = (url: string, h: string, code?: string) => {
|
|
||||||
const tags = [["h", h]]
|
|
||||||
|
|
||||||
if (code) {
|
|
||||||
tags.push(["code", code])
|
|
||||||
}
|
|
||||||
|
|
||||||
return publishThunk({
|
|
||||||
event: makeEvent(ROOM_JOIN, {tags}),
|
|
||||||
relays: [url],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const authTerminalStatuses = [
|
const authTerminalStatuses = [
|
||||||
AuthStatus.None,
|
AuthStatus.None,
|
||||||
AuthStatus.Ok,
|
AuthStatus.Ok,
|
||||||
|
|
@ -158,7 +144,7 @@ const authTerminalStatuses = [
|
||||||
AuthStatus.DeniedSignature,
|
AuthStatus.DeniedSignature,
|
||||||
]
|
]
|
||||||
|
|
||||||
const waitForAuth = async (socket: ReturnType<ReturnType<typeof Pool.get>["get"]>) => {
|
const waitForAuth = async (socket: Socket) => {
|
||||||
await poll({
|
await poll({
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
condition: () => authTerminalStatuses.includes(socket.auth.status),
|
condition: () => authTerminalStatuses.includes(socket.auth.status),
|
||||||
|
|
@ -180,7 +166,7 @@ const formatAuthError = (status: AuthStatus, details?: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const attemptRelayAccess = async (url: string, claim = "") => {
|
export const attemptRelayAccess = async (url: string, claim = "") => {
|
||||||
const socket = Pool.get().get(url)
|
const socket = app.get().pool.get(url)
|
||||||
|
|
||||||
socket.attemptToOpen()
|
socket.attemptToOpen()
|
||||||
|
|
||||||
|
|
@ -199,7 +185,7 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
|
||||||
})
|
})
|
||||||
|
|
||||||
for (let i = 0; i < 3 && !authTerminalStatuses.includes(socket.auth.status); i++) {
|
for (let i = 0; i < 3 && !authTerminalStatuses.includes(socket.auth.status); i++) {
|
||||||
await socket.auth.retryAuth(sign)
|
await socket.auth.retryAuth(User.require(app.get()).sign)
|
||||||
await waitForAuth(socket)
|
await waitForAuth(socket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,7 +193,8 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
|
||||||
return formatAuthError(socket.auth.status, socket.auth.details)
|
return formatAuthError(socket.auth.status, socket.auth.details)
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = await waitForThunkError(publishJoinRequest({url, claim}))
|
const thunk = await publishJoinRequest(url, claim)
|
||||||
|
const error = await thunk.waitForError()
|
||||||
|
|
||||||
if (shouldIgnoreError(error)) return
|
if (shouldIgnoreError(error)) return
|
||||||
|
|
||||||
|
|
@ -292,10 +279,21 @@ export class Access {
|
||||||
|
|
||||||
async completeJoin(notifications: boolean) {
|
async completeJoin(notifications: boolean) {
|
||||||
await this.configureNotifications(notifications)
|
await this.configureNotifications(notifications)
|
||||||
await addSpace(this.url)
|
await roomLists.get().addRelay(this.url).then(publish)
|
||||||
this.clearRestricted()
|
this.clearRestricted()
|
||||||
syncApplicationData()
|
syncApplicationData()
|
||||||
broadcastUserData([this.url])
|
app
|
||||||
|
.get()
|
||||||
|
.use(Sync)
|
||||||
|
.push({
|
||||||
|
relays: [this.url],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
kinds: [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE],
|
||||||
|
authors: [User.require(app.get()).pubkey],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async joinSpace({
|
async joinSpace({
|
||||||
|
|
@ -321,7 +319,8 @@ export class Access {
|
||||||
async joinRoom(h: string, code?: string) {
|
async joinRoom(h: string, code?: string) {
|
||||||
if (!code) return
|
if (!code) return
|
||||||
|
|
||||||
const message = await waitForThunkError(publishRoomJoinRequest(this.url, h, code))
|
const thunk = await publishRoomJoinRequest(this.url, h, code)
|
||||||
|
const message = await thunk.waitForError()
|
||||||
|
|
||||||
if (message && !message.startsWith("duplicate:")) {
|
if (message && !message.startsWith("duplicate:")) {
|
||||||
return message
|
return message
|
||||||
|
|
@ -329,11 +328,11 @@ export class Access {
|
||||||
}
|
}
|
||||||
|
|
||||||
async acceptInvite(data: InviteData, notifications: boolean) {
|
async acceptInvite(data: InviteData, notifications: boolean) {
|
||||||
const alreadyInSpace = get(userSpaceUrls).includes(data.url)
|
const spaceUrls = roomLists.get().urls(User.require(app.get()).pubkey).get()
|
||||||
const error = await this.joinSpace({
|
const error = await this.joinSpace({
|
||||||
claim: data.claim,
|
claim: data.claim,
|
||||||
notifications,
|
notifications,
|
||||||
alreadyJoined: alreadyInSpace,
|
alreadyJoined: spaceUrls.includes(data.url),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -351,7 +350,7 @@ export class Access {
|
||||||
// A request that times out or hits a closed socket resolves with no events, so an
|
// 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.
|
// empty result is the only signal we get that the relay never answered.
|
||||||
const [events, roomInviteResult] = await Promise.all([
|
const [events, roomInviteResult] = await Promise.all([
|
||||||
request({
|
network.get().request({
|
||||||
relays: [this.url],
|
relays: [this.url],
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
|
|
@ -362,7 +361,13 @@ export class Access {
|
||||||
sleep(300),
|
sleep(300),
|
||||||
])
|
])
|
||||||
|
|
||||||
this.claim.set(getTagValue("claim", events[0]?.tags || []) || "")
|
if (events[0]) {
|
||||||
|
const eventReader = reader(RelayInvite)
|
||||||
|
const invite = await eventReader(events[0])
|
||||||
|
|
||||||
|
this.claim.set(invite.claim() ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
this.claimFailed.set(events.length === 0)
|
this.claimFailed.set(events.length === 0)
|
||||||
|
|
||||||
if (h) {
|
if (h) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import {derived} from "svelte/store"
|
||||||
|
import {first, groupBy, removeUndefined, spec} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
REPORT,
|
REPORT,
|
||||||
ROOM_ADD_MEMBER,
|
ROOM_ADD_MEMBER,
|
||||||
|
|
@ -5,28 +7,48 @@ import {
|
||||||
ROOM_LEAVE,
|
ROOM_LEAVE,
|
||||||
ROOM_MEMBERS,
|
ROOM_MEMBERS,
|
||||||
ROOM_REMOVE_MEMBER,
|
ROOM_REMOVE_MEMBER,
|
||||||
getPubkeyTagValues,
|
hexTags,
|
||||||
getTagValue,
|
sortEventsAsc,
|
||||||
sortEventsDesc,
|
sortEventsDesc,
|
||||||
|
tagSpec,
|
||||||
|
tagValue,
|
||||||
|
tagValues,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {first, groupBy, removeUndefined} from "@welshman/lib"
|
|
||||||
import {derived} from "svelte/store"
|
|
||||||
import {deriveEventsForUrl} from "@app/repository"
|
import {deriveEventsForUrl} from "@app/repository"
|
||||||
import {getRoomMembers} from "@app/members"
|
|
||||||
|
// A room's membership replayed in order — each member list is a snapshot that resets
|
||||||
|
// the set, with add/remove events applied on top.
|
||||||
|
const getRoomMembers = (events: TrustedEvent[]) => {
|
||||||
|
const members = new Set<string>()
|
||||||
|
|
||||||
|
for (const event of sortEventsAsc(events)) {
|
||||||
|
const pubkeys = tagValues(hexTags("p"), event.tags)
|
||||||
|
|
||||||
|
if (event.kind === ROOM_MEMBERS) {
|
||||||
|
members.clear()
|
||||||
|
pubkeys.forEach(pubkey => members.add(pubkey))
|
||||||
|
} else if (event.kind === ROOM_ADD_MEMBER) {
|
||||||
|
pubkeys.forEach(pubkey => members.add(pubkey))
|
||||||
|
} else if (event.kind === ROOM_REMOVE_MEMBER) {
|
||||||
|
pubkeys.forEach(pubkey => members.delete(pubkey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return members
|
||||||
|
}
|
||||||
|
|
||||||
// Action items (admin review queue)
|
// Action items (admin review queue)
|
||||||
|
|
||||||
export const deriveSpaceActionItems = (url: string) =>
|
export const deriveSpaceActionItems = (url: string) =>
|
||||||
derived(
|
derived(
|
||||||
deriveEventsForUrl(url, [
|
deriveEventsForUrl(url, [
|
||||||
{
|
{kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]},
|
||||||
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
|
|
||||||
},
|
|
||||||
]),
|
]),
|
||||||
$events => {
|
$events => {
|
||||||
const getRoomId = (e: TrustedEvent) =>
|
const getRoomId = (e: TrustedEvent) =>
|
||||||
getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
|
tagValue(tagSpec(e.kind === ROOM_MEMBERS ? "d" : "h"), e.tags)
|
||||||
const reports = $events.filter(e => e.kind === REPORT)
|
const reports = $events.filter(spec({kind: REPORT}))
|
||||||
const pendingJoins: TrustedEvent[] = []
|
const pendingJoins: TrustedEvent[] = []
|
||||||
|
|
||||||
// Room-level join requests — most recent per pubkey+h
|
// Room-level join requests — most recent per pubkey+h
|
||||||
|
|
@ -53,7 +75,7 @@ export const deriveSpaceActionItems = (url: string) =>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents))
|
const roomMembers = getRoomMembers(roomMembershipEvents)
|
||||||
|
|
||||||
pendingJoins.push(
|
pendingJoins.push(
|
||||||
...removeUndefined(
|
...removeUndefined(
|
||||||
|
|
@ -72,7 +94,7 @@ export const deriveSpaceActionItems = (url: string) =>
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return getPubkeyTagValues(event.tags).includes(pubkey)
|
return tagValues(hexTags("p"), event.tags).includes(pubkey)
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,15 @@ import {
|
||||||
type AudioCaptureOptions,
|
type AudioCaptureOptions,
|
||||||
} from "livekit-client"
|
} from "livekit-client"
|
||||||
import {derived, get, writable} from "svelte/store"
|
import {derived, get, writable} from "svelte/store"
|
||||||
import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
|
import {first, not, nthEq, reject, uniqBy} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import {makeHttpAuth, makeHttpAuthHeader, sortEventsDesc, tagSpec, tagValues} from "@welshman/util"
|
||||||
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
import {deriveDeduplicated} from "@welshman/store"
|
||||||
import {signer} from "@welshman/app"
|
import {makeRoomKey} from "@welshman/app"
|
||||||
import {load} from "@welshman/net"
|
import type {Room} from "@welshman/app"
|
||||||
import {getLivekitEndpoint} from "$lib/livekit"
|
import {getLivekitEndpoint} from "$lib/livekit"
|
||||||
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
||||||
import {deriveLatestEventForUrl} from "@app/repository"
|
import {network, user} from "@app/core"
|
||||||
import {deriveRoom, makeRoomId, type Room} from "@app/groups"
|
import {deriveEventsForUrl} from "@app/repository"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
export const LIVEKIT_PARTICIPANTS = 39004
|
export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
|
|
@ -135,7 +135,7 @@ export const joinVoiceRoom = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
abortJoinVoiceRoom()
|
abortJoinVoiceRoom()
|
||||||
|
|
||||||
callTargetRoom.set(get(deriveRoom(url, h)))
|
callTargetRoom.set({url, h, id: makeRoomKey(url, h)})
|
||||||
callState.set(CallState.Joining)
|
callState.set(CallState.Joining)
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
@ -384,7 +384,7 @@ export const toggleVideoPrimaryTile = (key: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadCallParticipants = (url: string, h: string) =>
|
export const loadCallParticipants = (url: string, h: string) =>
|
||||||
load({
|
network.get().load({
|
||||||
relays: [url],
|
relays: [url],
|
||||||
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
|
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
|
||||||
})
|
})
|
||||||
|
|
@ -395,25 +395,22 @@ export const deriveCallParticipants = (url: string, h: string) =>
|
||||||
[
|
[
|
||||||
participantMediaState,
|
participantMediaState,
|
||||||
callTargetRoom,
|
callTargetRoom,
|
||||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
deriveDeduplicated(
|
||||||
|
deriveEventsForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||||
|
events => first(sortEventsDesc(events)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
([$participantMediaState, $callTargetRoom, $publishedParticipantList]) => {
|
([$participantMediaState, $callTargetRoom, $publishedParticipantList]) => {
|
||||||
const inCall = $participantMediaState.size > 0 && $callTargetRoom?.id === makeRoomId(url, h)
|
const inCall = $participantMediaState.size > 0 && $callTargetRoom?.id === makeRoomKey(url, h)
|
||||||
|
|
||||||
if (inCall) {
|
const identities = inCall
|
||||||
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
? [...$participantMediaState.keys()]
|
||||||
return uniqBy((p: CallParticipant) => participantKey(p), participants)
|
: tagValues(tagSpec("participant"), $publishedParticipantList?.tags ?? [])
|
||||||
} else {
|
|
||||||
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
return uniqBy(
|
||||||
if (!latestEvent) return []
|
(p: CallParticipant) => participantKey(p),
|
||||||
const participants = removeUndefined(
|
identities.map(participantFromLiveKitIdentity),
|
||||||
map(
|
|
||||||
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
|
|
||||||
getTags("participant", latestEvent.tags),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return uniqBy((p: CallParticipant) => participantKey(p), participants)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -527,18 +524,15 @@ const resyncAfterReconnect = (livekit: LiveKitRoom) => {
|
||||||
|
|
||||||
const fetchLivekitToken = async (
|
const fetchLivekitToken = async (
|
||||||
url: string,
|
url: string,
|
||||||
groupId: string,
|
roomId: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<{server_url: string; participant_token: string}> => {
|
): Promise<{server_url: string; participant_token: string}> => {
|
||||||
const endpoint = getLivekitEndpoint(url, groupId)
|
const endpoint = getLivekitEndpoint(url, roomId)
|
||||||
|
|
||||||
const $signer = signer.get()
|
|
||||||
if (!$signer) throw new Error("No signer available")
|
|
||||||
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
||||||
|
|
||||||
const template = await makeHttpAuth(endpoint, "GET")
|
const template = await makeHttpAuth(endpoint, "GET")
|
||||||
const signedEvent = await $signer.sign(template)
|
const signedEvent = await user.get().signer.sign(template)
|
||||||
const authHeader = makeHttpAuthHeader(signedEvent)
|
const authHeader = makeHttpAuthHeader(signedEvent)
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import {DELETE, PROFILE, getPubkeyTagValues} from "@welshman/util"
|
import {derived, readable} from "svelte/store"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {append, call, on, reject, remove, sort, sortBy, spec, uniq, uniqBy} from "@welshman/lib"
|
import {append, call, on, reject, remove, sort, sortBy, spec, uniq, uniqBy} from "@welshman/lib"
|
||||||
import type {Override} from "@welshman/lib"
|
import type {Override} from "@welshman/lib"
|
||||||
import {createSearch, displayProfileByPubkey, pubkey, repository} from "@welshman/app"
|
import {DELETE, PROFILE, hexTags, tagValues} from "@welshman/util"
|
||||||
import {derived, readable} from "svelte/store"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {DM_KINDS} from "@app/content"
|
|
||||||
import type {RepositoryUpdate} from "@welshman/net"
|
import type {RepositoryUpdate} from "@welshman/net"
|
||||||
import {makeDeriveItem, throttled} from "@welshman/store"
|
import {makeDeriveItem, throttled} from "@welshman/store"
|
||||||
|
import {createSearch} from "@welshman/app"
|
||||||
|
import {app, profiles, user} from "@app/core"
|
||||||
|
import {DM_KINDS} from "@app/content"
|
||||||
|
|
||||||
export type Chat = {
|
export type Chat = {
|
||||||
id: string
|
id: string
|
||||||
pubkeys: string[]
|
pubkeys: string[]
|
||||||
|
|
@ -15,13 +17,13 @@ export type Chat = {
|
||||||
search_text: string
|
search_text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(pubkey.get()!, pubkeys)))
|
export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(user.get().pubkey, pubkeys)))
|
||||||
|
|
||||||
export const getChatPubkeysFromEvent = (event: TrustedEvent) =>
|
export const getChatPubkeysFromEvent = (event: TrustedEvent) =>
|
||||||
getChatPubkeys(getPubkeyTagValues(event.tags).concat(event.pubkey))
|
getChatPubkeys(tagValues(hexTags("p"), event.tags).concat(event.pubkey))
|
||||||
|
|
||||||
export const makeChatId = (pubkeys: string[]) => {
|
export const makeChatId = (pubkeys: string[]) => {
|
||||||
const userPubkey = pubkey.get()!
|
const userPubkey = user.get().pubkey
|
||||||
const otherPubkeys = remove(userPubkey, uniq(pubkeys))
|
const otherPubkeys = remove(userPubkey, uniq(pubkeys))
|
||||||
const visiblePubkeys = otherPubkeys.length === 0 ? [userPubkey] : otherPubkeys
|
const visiblePubkeys = otherPubkeys.length === 0 ? [userPubkey] : otherPubkeys
|
||||||
|
|
||||||
|
|
@ -34,11 +36,13 @@ export const chatsById = call(() => {
|
||||||
const chatsById = new Map<string, Chat>()
|
const chatsById = new Map<string, Chat>()
|
||||||
const chatsByPubkey = new Map<string, string[]>()
|
const chatsByPubkey = new Map<string, string[]>()
|
||||||
|
|
||||||
|
const displayProfile = (pubkey: string) => profiles.get().display(pubkey).get()
|
||||||
|
|
||||||
const addSearchText = (chat: Override<Chat, {search_text?: string}>) => {
|
const addSearchText = (chat: Override<Chat, {search_text?: string}>) => {
|
||||||
chat.search_text =
|
chat.search_text =
|
||||||
chat.pubkeys.length === 1
|
chat.pubkeys.length === 1
|
||||||
? displayProfileByPubkey(chat.pubkeys[0]) + " note to self"
|
? displayProfile(chat.pubkeys[0]) + " note to self"
|
||||||
: remove(pubkey.get()!, chat.pubkeys).map(displayProfileByPubkey).join(" ")
|
: remove(user.get().pubkey, chat.pubkeys).map(displayProfile).join(" ")
|
||||||
|
|
||||||
return chat as Chat
|
return chat as Chat
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +95,7 @@ export const chatsById = call(() => {
|
||||||
let dirty = false
|
let dirty = false
|
||||||
|
|
||||||
for (const id of removed) {
|
for (const id of removed) {
|
||||||
const event = repository.getEvent(id)
|
const event = app.get().repository.getEvent(id)
|
||||||
|
|
||||||
if (event && DM_KINDS.includes(event.kind)) {
|
if (event && DM_KINDS.includes(event.kind)) {
|
||||||
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
|
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
|
||||||
|
|
@ -110,10 +114,10 @@ export const chatsById = call(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addEvents(repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
|
addEvents(app.get().repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
|
||||||
|
|
||||||
const unsubscribers = [
|
const unsubscribers = [
|
||||||
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
|
on(app.get().repository, "update", ({added, removed}: RepositoryUpdate) => {
|
||||||
// Do this async so that profiles are populated
|
// Do this async so that profiles are populated
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
addEvents(added)
|
addEvents(added)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {COMMENT, makeEvent} from "@welshman/util"
|
|
||||||
import {publishThunk, tagEventForComment} from "@welshman/app"
|
|
||||||
|
|
||||||
export type CommentParams = {
|
|
||||||
event: TrustedEvent
|
|
||||||
content: string
|
|
||||||
tags?: string[][]
|
|
||||||
url?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeComment = ({url, event, content, tags = []}: CommentParams) =>
|
|
||||||
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event, url)]})
|
|
||||||
|
|
||||||
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
|
|
||||||
publishThunk({event: makeComment({url: relays[0], ...params}), relays})
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {pubkey} from "@welshman/app"
|
|
||||||
import Dialog from "@lib/components/Dialog.svelte"
|
import Dialog from "@lib/components/Dialog.svelte"
|
||||||
import Landing from "@app/components/Landing.svelte"
|
import Landing from "@app/components/Landing.svelte"
|
||||||
import Toast from "@app/components/Toast.svelte"
|
import Toast from "@app/components/Toast.svelte"
|
||||||
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
||||||
|
import {app} from "@app/core"
|
||||||
import {modal} from "@app/modal"
|
import {modal} from "@app/modal"
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
children: Snippet
|
children: Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
{#if $pubkey}
|
{#if $app.user?.pubkey}
|
||||||
<PrimaryNav>
|
<PrimaryNav>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</PrimaryNav>
|
</PrimaryNav>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
|
import type {PinboardReader} from "@welshman/domain"
|
||||||
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 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 EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import {type PublishedBoard} from "@app/pinboards"
|
|
||||||
import {shareEventToChat} from "@app/share"
|
import {shareEventToChat} from "@app/share"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
board: PublishedBoard
|
board: PinboardReader
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {getTagValue, getAddress} from "@welshman/util"
|
import {getAddress, tagSpec, tagValue} from "@welshman/util"
|
||||||
import {pubkey} from "@welshman/app"
|
|
||||||
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
|
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import RoomName from "@app/components/RoomName.svelte"
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
|
import {publishReaction, retractReaction} from "@app/reactions"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||||
import EventActivity from "@app/components/EventActivity.svelte"
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
import EventActions from "@app/components/EventActions.svelte"
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
||||||
import {publishDelete} from "@app/deletes"
|
import {user} from "@app/core"
|
||||||
import {publishReaction} from "@app/reactions"
|
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
import {makeCalendarPath, makeSpacePath} from "@app/routes"
|
import {makeCalendarPath, makeSpacePath} from "@app/routes"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
|
@ -27,17 +25,14 @@
|
||||||
|
|
||||||
const {url, event, showRoom, showActivity}: Props = $props()
|
const {url, event, showRoom, showActivity}: Props = $props()
|
||||||
|
|
||||||
const h = getTagValue("h", event.tags)
|
const h = tagValue(tagSpec("h"), event.tags)
|
||||||
const path = makeCalendarPath(url, getAddress(event))
|
const path = makeCalendarPath(url, getAddress(event))
|
||||||
const shouldProtect = canEnforceNip70(url)
|
|
||||||
|
|
||||||
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
|
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
|
||||||
|
|
||||||
const deleteReaction = async (event: TrustedEvent) =>
|
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
|
||||||
|
|
||||||
const createReaction = async (template: EventContent) =>
|
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
|
|
@ -53,7 +48,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
<EventActions {url} {event} noun="Event">
|
<EventActions {url} {event} noun="Event">
|
||||||
{#snippet customActions()}
|
{#snippet customActions()}
|
||||||
{#if event.pubkey === $pubkey}
|
{#if event.pubkey === $user.pubkey}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={editEvent}>
|
<Button onclick={editEvent}>
|
||||||
<Icon size={4} icon={Pen2} />
|
<Icon size={4} icon={Pen2} />
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Create an Event</ModalTitle>
|
<ModalTitle>Create an Event</ModalTitle>
|
||||||
<ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle>
|
<ModalSubtitle>Invite other room members to events online or in real life.</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CalendarEventForm>
|
</CalendarEventForm>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {fromPairs, LOCALE, secondsToDate} from "@welshman/lib"
|
import {LOCALE, secondsToDate} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {TimeEvent} from "@welshman/domain"
|
||||||
|
import {reader} from "@app/core"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
const {event}: Props = $props()
|
const {event}: Props = $props()
|
||||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
|
||||||
const start = $derived(parseInt(meta.start))
|
const timeEvent = reader(TimeEvent)(event)
|
||||||
|
|
||||||
|
const start = timeEvent.start()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !isNaN(start)}
|
{#if start}
|
||||||
{@const startDate = secondsToDate(start)}
|
{@const startDate = secondsToDate(start)}
|
||||||
<div
|
<div
|
||||||
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-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-2xl bg-surface-more p-2 sm:flex">
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getTagValue} from "@welshman/util"
|
import {TimeEvent} from "@welshman/domain"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import {reader} from "@app/core"
|
||||||
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
|
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -13,21 +14,27 @@
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
const initialValues = {
|
const timeEvent = reader(TimeEvent)(event)
|
||||||
d: getTagValue("d", event.tags)!,
|
|
||||||
title: getTagValue("title", event.tags)!,
|
const initialValues = $derived(
|
||||||
location: getTagValue("location", event.tags)!,
|
timeEvent && {
|
||||||
start: parseInt(getTagValue("start", event.tags)!),
|
d: timeEvent.identifier() ?? "",
|
||||||
end: parseInt(getTagValue("end", event.tags)!),
|
title: timeEvent.title() ?? "",
|
||||||
content: event.content,
|
location: timeEvent.location() ?? "",
|
||||||
}
|
start: timeEvent.start(),
|
||||||
|
end: timeEvent.end(),
|
||||||
|
content: timeEvent.content(),
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarEventForm {url} {initialValues}>
|
{#if initialValues}
|
||||||
|
<CalendarEventForm {url} {initialValues}>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Edit this Event</ModalTitle>
|
<ModalTitle>Edit this Event</ModalTitle>
|
||||||
<ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle>
|
<ModalSubtitle>Invite other room members to events online or in real life.</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CalendarEventForm>
|
</CalendarEventForm>
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@
|
||||||
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} from "@welshman/lib"
|
||||||
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
import {publish} from "@welshman/app"
|
||||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
import {TimeEvent} from "@welshman/domain"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import {daysBetween} from "@lib/util"
|
|
||||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||||
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
|
@ -18,11 +17,11 @@
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import DateTimeRangeInput from "@lib/components/DateTimeRangeInput.svelte"
|
import DateTimeRangeInput from "@lib/components/DateTimeRangeInput.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
import {publishRoomQuote} from "@app/rooms"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {DraftKey} from "@app/drafts"
|
import {DraftKey} from "@app/drafts"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
import {command, relays, writer} from "@app/core"
|
||||||
|
|
||||||
type Values = {
|
type Values = {
|
||||||
d: string
|
d: string
|
||||||
|
|
@ -49,7 +48,7 @@
|
||||||
initialValues = draftKey.get()
|
initialValues = draftKey.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = $relays.hasNip(url, 70)
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
|
||||||
|
|
@ -83,32 +82,29 @@
|
||||||
|
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
const tags = [
|
|
||||||
["d", d],
|
|
||||||
["title", title],
|
|
||||||
["location", location],
|
|
||||||
["start", start.toString()],
|
|
||||||
["end", end.toString()],
|
|
||||||
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
|
||||||
...ed.storage.nostr.getEditorTags(),
|
|
||||||
]
|
|
||||||
|
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const protect = await shouldProtect
|
const protect = await shouldProtect
|
||||||
|
const eventWriter = writer(TimeEvent)
|
||||||
if (protect) {
|
.setIdentifier(d)
|
||||||
tags.push(PROTECTED)
|
.setTitle(title)
|
||||||
}
|
.setLocation(location)
|
||||||
|
.setStart(start)
|
||||||
|
.setEnd(end)
|
||||||
|
.setContent(content)
|
||||||
|
.setProtected(protect)
|
||||||
|
.addTags(...ed.storage.nostr.getEditorTags())
|
||||||
|
|
||||||
if (h) {
|
if (h) {
|
||||||
tags.push(["h", h])
|
eventWriter.setRoom(url, h)
|
||||||
|
} else {
|
||||||
|
eventWriter.forceRelays(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = makeEvent(EVENT_TIME, {content, tags})
|
const calendarThunk = await command(eventWriter).then(publish)
|
||||||
const calendarThunk = publishThunk({event, relays: [url]})
|
const error = await calendarThunk.waitForError()
|
||||||
const error = await waitForThunkError(calendarThunk)
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return pushToast({theme: "error", message: error})
|
return pushToast({theme: "error", message: error})
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/lib"
|
||||||
fromPairs,
|
|
||||||
formatTimestamp,
|
|
||||||
formatTimestampAsDate,
|
|
||||||
formatTimestampAsTime,
|
|
||||||
} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {tagSpec, tagValue} from "@welshman/util"
|
||||||
|
import {TimeEvent} from "@welshman/domain"
|
||||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import {reader} from "@app/core"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
const {event}: Props = $props()
|
const {event}: Props = $props()
|
||||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
|
||||||
const start = $derived(parseInt(meta.start))
|
const timeEvent = reader(TimeEvent)(event)
|
||||||
const end = $derived(parseInt(meta.end))
|
|
||||||
|
// NIP-52 settled on `title`, but events from before that still carry `name`.
|
||||||
|
const title = $derived(timeEvent.title() ?? tagValue(tagSpec("name"), event.tags))
|
||||||
|
const start = timeEvent.start()
|
||||||
|
const end = timeEvent.end()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col justify-between gap-1">
|
<div class="flex flex-col justify-between gap-1">
|
||||||
<p class="text-lg">{meta.title || meta.name}</p>
|
<p class="text-lg">{title}</p>
|
||||||
{#if !isNaN(start) && !isNaN(end)}
|
{#if start && end}
|
||||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
{@const isSingleDay = formatTimestampAsDate(start) === formatTimestampAsDate(end)}
|
||||||
{@const endDateDisplay = formatTimestampAsDate(end)}
|
|
||||||
{@const isSingleDay = startDateDisplay === endDateDisplay}
|
|
||||||
<div class="flex flex-wrap gap-2 text-xs">
|
<div class="flex flex-wrap gap-2 text-xs">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Icon icon={ClockCircle} size={4} />
|
<Icon icon={ClockCircle} size={4} />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getTagValue, getAddress} from "@welshman/util"
|
import {getAddress, tagValue, tagSpec} from "@welshman/util"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
||||||
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
const h = getTagValue("h", event.tags)
|
const h = tagValue(tagSpec("h"), event.tags)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {fromPairs} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {TimeEvent} from "@welshman/domain"
|
||||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||||
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import {reader} from "@app/core"
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -12,7 +13,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const {event, url}: Props = $props()
|
const {event, url}: Props = $props()
|
||||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
|
||||||
|
const timeEvent = reader(TimeEvent)(event)
|
||||||
|
|
||||||
|
const location = timeEvent.location()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
|
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
|
||||||
|
|
@ -20,10 +24,10 @@
|
||||||
<Icon icon={UserCircle} size={4} />
|
<Icon icon={UserCircle} size={4} />
|
||||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||||
</span>
|
</span>
|
||||||
{#if meta.location}
|
{#if location}
|
||||||
<span class="flex items-start gap-1">
|
<span class="flex items-start gap-1">
|
||||||
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
||||||
<span class="wrap-break-word">{meta.location}</span>
|
<span class="wrap-break-word">{location}</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,22 +18,9 @@
|
||||||
formatTimestampAsDate,
|
formatTimestampAsDate,
|
||||||
} from "@welshman/lib"
|
} from "@welshman/lib"
|
||||||
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
|
||||||
|
import {makeEvent, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util"
|
||||||
import {parse, isLink} from "@welshman/content"
|
import {parse, isLink} from "@welshman/content"
|
||||||
import {
|
import {MessagingRelayLists, Thunks} from "@welshman/app"
|
||||||
makeEvent,
|
|
||||||
tagsFromIMeta,
|
|
||||||
getTags,
|
|
||||||
DIRECT_MESSAGE,
|
|
||||||
DIRECT_MESSAGE_FILE,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import {
|
|
||||||
pubkey,
|
|
||||||
tagPubkey,
|
|
||||||
sendWrapped,
|
|
||||||
mergeThunks,
|
|
||||||
loadMessagingRelayList,
|
|
||||||
messagingRelayListsByPubkey,
|
|
||||||
} from "@welshman/app"
|
|
||||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||||
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
|
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
@ -52,12 +39,12 @@
|
||||||
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
|
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
|
||||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
|
import {app, deletes, user, wraps} from "@app/core"
|
||||||
import {userSettingsValues} from "@app/settings"
|
import {userSettingsValues} from "@app/settings"
|
||||||
import {deriveChat, makeChatId} from "@app/chats"
|
import {deriveChat, makeChatId} from "@app/chats"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {DraftKey} from "@app/drafts"
|
import {DraftKey} from "@app/drafts"
|
||||||
import {makeDelete} from "@app/deletes"
|
import {prependParent} from "@app/rooms"
|
||||||
import {prependParent} from "@app/groups"
|
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -70,8 +57,9 @@
|
||||||
const chatId = makeChatId(pubkeys)
|
const chatId = makeChatId(pubkeys)
|
||||||
const chat = deriveChat(chatId)
|
const chat = deriveChat(chatId)
|
||||||
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`)
|
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`)
|
||||||
const others = remove($pubkey!, pubkeys)
|
const others = remove($user.pubkey, pubkeys)
|
||||||
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
const messagingRelayLists = $app.use(MessagingRelayLists).index.$
|
||||||
|
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk)))
|
||||||
|
|
||||||
const showMembers = () =>
|
const showMembers = () =>
|
||||||
others.length === 1
|
others.length === 1
|
||||||
|
|
@ -95,28 +83,26 @@
|
||||||
|
|
||||||
const onSubmit = async (params: EventContent) => {
|
const onSubmit = async (params: EventContent) => {
|
||||||
try {
|
try {
|
||||||
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
|
const ptags = others.map(pk => ["p", pk])
|
||||||
|
|
||||||
// Remove p tags since they result in forking the conversation
|
// Remove p tags since they result in forking the conversation
|
||||||
params.tags = params.tags.filter(nthNe(0, "p"))
|
params.tags = params.tags.filter(nthNe(0, "p"))
|
||||||
|
|
||||||
// Add our reply quote to content
|
// Add our reply quote to content
|
||||||
params = prependParent(parent, params)
|
params = await prependParent(parent, params)
|
||||||
|
|
||||||
if (eventToEdit) {
|
if (eventToEdit) {
|
||||||
if (eventToEdit.content === params.content) {
|
if (eventToEdit.content === params.content) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendWrapped({
|
const command = await $deletes.deleteEvent(eventToEdit)
|
||||||
event: makeDelete({event: eventToEdit, protect: false}),
|
|
||||||
recipients: pubkeys,
|
await $wraps.publish({event: command.event, recipients: pubkeys, pow: 16})
|
||||||
pow: 16,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
|
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
|
||||||
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
|
const imetas = imetaTags.map(tag => tag.slice(1).map(entry => entry.split(" ")))
|
||||||
const templates: EventTemplate[] = []
|
const templates: EventTemplate[] = []
|
||||||
const buffer = []
|
const buffer = []
|
||||||
|
|
||||||
|
|
@ -141,11 +127,7 @@
|
||||||
|
|
||||||
if (isLink(p) && imeta) {
|
if (isLink(p) && imeta) {
|
||||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||||
addTemplate(
|
addTemplate(DIRECT_MESSAGE_FILE, p.value.url.toString(), imeta.filter(nthNe(0, "url")))
|
||||||
DIRECT_MESSAGE_FILE,
|
|
||||||
p.value.url.toString(),
|
|
||||||
imeta.slice(1).filter(nthNe(0, "url")),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
buffer.push(p.raw)
|
buffer.push(p.raw)
|
||||||
}
|
}
|
||||||
|
|
@ -157,7 +139,7 @@
|
||||||
// Sleep 1 second between each one to make sure timestamps are distinct
|
// Sleep 1 second between each one to make sure timestamps are distinct
|
||||||
const thunks = await Promise.all(
|
const thunks = await Promise.all(
|
||||||
Array.from(enumerate(templates)).map(([i, event]) =>
|
Array.from(enumerate(templates)).map(([i, event]) =>
|
||||||
sendWrapped({
|
$wraps.publish({
|
||||||
event,
|
event,
|
||||||
recipients: pubkeys,
|
recipients: pubkeys,
|
||||||
delay: $userSettingsValues.send_delay + ms(i),
|
delay: $userSettingsValues.send_delay + ms(i),
|
||||||
|
|
@ -170,7 +152,7 @@
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
children: {
|
children: {
|
||||||
component: ThunkToast,
|
component: ThunkToast,
|
||||||
props: {thunk: mergeThunks(thunks)},
|
props: {thunk: $app.use(Thunks).merge(thunks)},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -185,7 +167,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const canEditEvent = (event: TrustedEvent) =>
|
const canEditEvent = (event: TrustedEvent) =>
|
||||||
event.pubkey === $pubkey &&
|
event.pubkey === $user.pubkey &&
|
||||||
event.kind === DIRECT_MESSAGE &&
|
event.kind === DIRECT_MESSAGE &&
|
||||||
event.created_at >= ago(500, MINUTE)
|
event.created_at >= ago(500, MINUTE)
|
||||||
|
|
||||||
|
|
@ -233,7 +215,7 @@
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
for (const pubkey of others) {
|
for (const pubkey of others) {
|
||||||
loadMessagingRelayList(pubkey)
|
$app.use(MessagingRelayLists).load(pubkey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -252,8 +234,8 @@
|
||||||
<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="flex gap-2">
|
||||||
<ProfileCircle pubkey={$pubkey!} size={5} />
|
<ProfileCircle pubkey={$user.pubkey} size={5} />
|
||||||
<ProfileName pubkey={$pubkey!} />
|
<ProfileName pubkey={$user.pubkey} />
|
||||||
</div>
|
</div>
|
||||||
{:else if others.length === 1}
|
{:else if others.length === 1}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|
@ -294,7 +276,7 @@
|
||||||
{#each missingRelayLists as pubkey (pubkey)}
|
{#each missingRelayLists as pubkey (pubkey)}
|
||||||
<ProfileLink {pubkey} />
|
<ProfileLink {pubkey} />
|
||||||
{/each}
|
{/each}
|
||||||
to enable direct messaging by opening this conversation in their app.
|
to enable direct messaging by opening this conversation in their $app.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {displayProfileByPubkey} from "@welshman/app"
|
|
||||||
import {slide} from "@lib/transition"
|
import {slide} from "@lib/transition"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||||
|
import {profiles} from "@app/core"
|
||||||
|
|
||||||
const {
|
type Props = {
|
||||||
verb,
|
|
||||||
event,
|
|
||||||
clear,
|
|
||||||
}: {
|
|
||||||
verb: string
|
verb: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
clear: () => void
|
clear: () => void
|
||||||
} = $props()
|
}
|
||||||
|
|
||||||
|
const {verb, event, clear}: Props = $props()
|
||||||
|
|
||||||
|
const display = $profiles.display(event.pubkey).$
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="bg-surface-more relative border-l-2 border-solid px-2 py-1 pr-8"
|
class="bg-surface-more relative border-l-2 border-solid px-2 py-1 pr-8"
|
||||||
style="border-color: var(--primary)"
|
style="border-color: var(--primary)"
|
||||||
transition:slide>
|
transition:slide>
|
||||||
<p class="text-primary text-xs">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
|
<p class="text-primary text-xs">{verb} @{$display}</p>
|
||||||
{#key event.id}
|
{#key event.id}
|
||||||
<NoteContentMinimal trimParent {event} />
|
<NoteContentMinimal trimParent {event} />
|
||||||
{/key}
|
{/key}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
<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 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"
|
||||||
|
|
@ -12,6 +10,7 @@
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import {deriveUserItem, messagingRelayLists, relayLists} from "@app/core"
|
||||||
import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/env"
|
import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/env"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
|
@ -21,7 +20,7 @@
|
||||||
|
|
||||||
const {next}: Props = $props()
|
const {next}: Props = $props()
|
||||||
|
|
||||||
let loading = $state(false)
|
const userRelayList = deriveUserItem($app => $relayLists)
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
|
|
@ -29,8 +28,14 @@
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (getRelaysFromList($userRelayList).length === 0) {
|
const relayUrls = $userRelayList?.urls() ?? []
|
||||||
const error = await waitForThunkError(await setRelays(DEFAULT_RELAYS.map(r => ["r", r])))
|
|
||||||
|
if (relayUrls.length === 0) {
|
||||||
|
const command = await $relayLists.update(writer =>
|
||||||
|
writer.setReadUrls(DEFAULT_RELAYS).setWriteUrls(DEFAULT_RELAYS),
|
||||||
|
)
|
||||||
|
|
||||||
|
const error = await command.publish().waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
|
|
@ -38,7 +43,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = await waitForThunkError(await setMessagingRelays(DEFAULT_MESSAGING_RELAYS))
|
const command = await $messagingRelayLists.setUrls(DEFAULT_MESSAGING_RELAYS)
|
||||||
|
const error = await command.publish().waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
|
|
@ -50,6 +56,8 @@
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let loading = $state(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal tag="form" onsubmit={preventDefault(enable)}>
|
<Modal tag="form" onsubmit={preventDefault(enable)}>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
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"
|
||||||
|
|
@ -12,6 +11,7 @@
|
||||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
import {makeChatPath, goToChat} from "@app/routes"
|
import {makeChatPath, goToChat} from "@app/routes"
|
||||||
import {notifications} from "@app/notifications"
|
import {notifications} from "@app/notifications"
|
||||||
|
import {messagingRelayLists, user} from "@app/core"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -22,14 +22,14 @@
|
||||||
|
|
||||||
const {...props}: Props = $props()
|
const {...props}: Props = $props()
|
||||||
|
|
||||||
const others = uniq(remove($pubkey!, props.pubkeys))
|
const others = uniq(remove($user.pubkey, props.pubkeys))
|
||||||
const active = $derived($page.params.chat === props.id)
|
const active = $derived($page.params.chat === props.id)
|
||||||
const path = makeChatPath(props.pubkeys)
|
const path = makeChatPath(props.pubkeys)
|
||||||
const openChat = () => goToChat(props.pubkeys)
|
const openChat = () => goToChat(props.pubkeys)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
for (const pk of others) {
|
for (const pk of others) {
|
||||||
loadMessagingRelayList(pk)
|
$messagingRelayLists.load(pk)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
{#if others.length === 0}
|
{#if others.length === 0}
|
||||||
<ProfileCircle pubkey={$pubkey!} size={5} />
|
<ProfileCircle pubkey={$user.pubkey} size={5} />
|
||||||
Note to self
|
Note to self
|
||||||
{:else if others.length === 1}
|
{:else if others.length === 1}
|
||||||
<ProfileCircle pubkey={others[0]} size={5} />
|
<ProfileCircle pubkey={others[0]} size={5} />
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
</div>
|
</div>
|
||||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
<p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||||
<span class="opacity-70">
|
<span class="opacity-70">
|
||||||
{#if props.messages[0].pubkey === $pubkey}
|
{#if props.messages[0].pubkey === $user.pubkey}
|
||||||
You:
|
You:
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import Profile from "@app/components/Profile.svelte"
|
import Profile from "@app/components/Profile.svelte"
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
pubkeys: string[]
|
pubkeys: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import {type Instance} from "tippy.js"
|
import {type Instance} from "tippy.js"
|
||||||
import {hash, formatTimestampAsTime} from "@welshman/lib"
|
import {hash, formatTimestampAsTime} from "@welshman/lib"
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {thunks, mergeThunks, pubkey, deriveProfileDisplay, sendWrapped} from "@welshman/app"
|
import {Thunks} from "@welshman/app"
|
||||||
import {isMobile} from "@lib/html"
|
import {isMobile} from "@lib/html"
|
||||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
@ -11,15 +11,15 @@
|
||||||
import Tippy from "@lib/components/Tippy.svelte"
|
import Tippy from "@lib/components/Tippy.svelte"
|
||||||
import TapTarget from "@lib/components/TapTarget.svelte"
|
import TapTarget from "@lib/components/TapTarget.svelte"
|
||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
|
import {publishWrappedReaction, retractWrappedReaction} from "@app/reactions"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
import ThunkFailure from "@app/components/ThunkFailure.svelte"
|
import ThunkFailure from "@app/components/ThunkFailure.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
|
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
|
||||||
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
|
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
|
||||||
|
import {app, profiles, user} from "@app/core"
|
||||||
import {colors} from "@app/theme"
|
import {colors} from "@app/theme"
|
||||||
import {makeDelete} from "@app/deletes"
|
|
||||||
import {makeReaction} from "@app/reactions"
|
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -33,23 +33,18 @@
|
||||||
|
|
||||||
const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props()
|
const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props()
|
||||||
|
|
||||||
const isOwn = event.pubkey === $pubkey
|
const isOwn = event.pubkey === $user.pubkey
|
||||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
const profileDisplay = $profiles.display(event.pubkey).$
|
||||||
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
|
const thunks = $app.use(Thunks).history
|
||||||
|
const thunk = $app.use(Thunks).merge($thunks.filter(t => t.event.id === event.id))
|
||||||
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||||
|
|
||||||
const reply = () => replyTo(event)
|
const reply = () => replyTo(event)
|
||||||
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
|
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
|
||||||
|
|
||||||
const deleteReaction = (event: TrustedEvent) =>
|
const deleteReaction = (reaction: TrustedEvent) => retractWrappedReaction(reaction, pubkeys)
|
||||||
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys, pow: 16})
|
|
||||||
|
|
||||||
const createReaction = (template: EventContent) =>
|
const createReaction = (values: EventContent) => publishWrappedReaction(event, values, pubkeys)
|
||||||
sendWrapped({
|
|
||||||
event: makeReaction({event, protect: false, ...template}),
|
|
||||||
recipients: pubkeys,
|
|
||||||
pow: 16,
|
|
||||||
})
|
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
||||||
|
|
||||||
|
|
@ -67,9 +62,7 @@
|
||||||
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={cx("group flex items-center justify-end gap-1 px-2", {"flex-row-reverse": !isOwn})}>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,23 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {sendWrapped} from "@welshman/app"
|
|
||||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||||
import {makeReaction} from "@app/reactions"
|
import {reactions, wraps} from "@app/core"
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
pubkeys: string[]
|
pubkeys: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const {event, pubkeys}: Props = $props()
|
const {event, pubkeys}: Props = $props()
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) =>
|
const onEmoji = async (emoji: NativeEmoji) => {
|
||||||
sendWrapped({
|
const reaction = await $reactions.react(event, emoji.unicode)
|
||||||
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
|
||||||
recipients: pubkeys,
|
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
|
||||||
pow: 16,
|
}
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EmojiButton {onEmoji} class="button button-neutral button-xs join-item">
|
<EmojiButton {onEmoji} class="button button-neutral button-xs join-item">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {sendWrapped} from "@welshman/app"
|
|
||||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||||
|
|
@ -13,7 +12,7 @@
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import {makeReaction} from "@app/reactions"
|
import {reactions, wraps} from "@app/core"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {clip} from "@app/toast"
|
import {clip} from "@app/toast"
|
||||||
|
|
||||||
|
|
@ -26,14 +25,13 @@
|
||||||
|
|
||||||
const {event, pubkeys, reply, edit}: Props = $props()
|
const {event, pubkeys, reply, edit}: Props = $props()
|
||||||
|
|
||||||
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
|
const onEmoji = async (emoji: NativeEmoji) => {
|
||||||
history.back()
|
history.back()
|
||||||
sendWrapped({
|
|
||||||
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
const reaction = await $reactions.react(event, emoji.unicode)
|
||||||
recipients: pubkeys,
|
|
||||||
pow: 16,
|
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
|
||||||
})
|
}
|
||||||
}).bind(undefined, event, pubkeys)
|
|
||||||
|
|
||||||
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {loadMessagingRelayList} from "@welshman/app"
|
|
||||||
import {decodePubkey} from "@lib/util"
|
import {decodePubkey} from "@lib/util"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
|
@ -18,6 +17,7 @@
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
||||||
import {goToChat} from "@app/routes"
|
import {goToChat} from "@app/routes"
|
||||||
|
import {messagingRelayLists} from "@app/core"
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
let pubkeys: string[] = $state([])
|
let pubkeys: string[] = $state([])
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
pubkeys.forEach(pubkey => loadMessagingRelayList(pubkey))
|
pubkeys.forEach(pubkey => $messagingRelayLists.load(pubkey))
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,26 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {getTagValue, getTagValues, getAddress} from "@welshman/util"
|
import {getAddress} from "@welshman/util"
|
||||||
import {pubkey} from "@welshman/app"
|
import {Classified} from "@welshman/domain"
|
||||||
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
|
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
|
||||||
import {normalizeTopic} from "@lib/util"
|
import {normalizeTopic} from "@lib/util"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import RoomName from "@app/components/RoomName.svelte"
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
|
import {publishReaction, retractReaction} from "@app/reactions"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
import ClassifiedStatus from "@app/components/ClassifiedStatus.svelte"
|
import ClassifiedStatus from "@app/components/ClassifiedStatus.svelte"
|
||||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||||
import EventActivity from "@app/components/EventActivity.svelte"
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
import EventActions from "@app/components/EventActions.svelte"
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
|
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
|
||||||
import {publishDelete} from "@app/deletes"
|
import {reader, user} from "@app/core"
|
||||||
import {publishReaction} from "@app/reactions"
|
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
import {makeClassifiedPath, makeSpacePath} from "@app/routes"
|
import {makeClassifiedPath, makeSpacePath} from "@app/routes"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
showRoom?: boolean
|
showRoom?: boolean
|
||||||
|
|
@ -30,18 +29,17 @@
|
||||||
|
|
||||||
const {url, event, showRoom, showActivity}: Props = $props()
|
const {url, event, showRoom, showActivity}: Props = $props()
|
||||||
|
|
||||||
const h = getTagValue("h", event.tags)
|
const classified = reader(Classified)(event)
|
||||||
const topics = getTagValues("t", event.tags)
|
|
||||||
|
const h = classified.room()
|
||||||
|
const topics = classified.topics() ?? []
|
||||||
const path = makeClassifiedPath(url, getAddress(event))
|
const path = makeClassifiedPath(url, getAddress(event))
|
||||||
const shouldProtect = canEnforceNip70(url)
|
|
||||||
|
|
||||||
const editClassified = () => pushModal(ClassifiedEdit, {url, event})
|
const editClassified = () => pushModal(ClassifiedEdit, {url, event})
|
||||||
|
|
||||||
const deleteReaction = async (event: TrustedEvent) =>
|
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
|
||||||
|
|
||||||
const createReaction = async (template: EventContent) =>
|
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
|
|
@ -66,7 +64,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
<EventActions {url} {event} noun="Listing">
|
<EventActions {url} {event} noun="Listing">
|
||||||
{#snippet customActions()}
|
{#snippet customActions()}
|
||||||
{#if event.pubkey === $pubkey}
|
{#if event.pubkey === $user.pubkey}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={editClassified}>
|
<Button onclick={editClassified}>
|
||||||
<Icon size={4} icon={Pen2} />
|
<Icon size={4} icon={Pen2} />
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {fromPairs} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getTag, getTagValues} from "@welshman/util"
|
import {Classified} from "@welshman/domain"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import {reader} from "@app/core"
|
||||||
import ClassifiedForm from "@app/components/ClassifiedForm.svelte"
|
import ClassifiedForm from "@app/components/ClassifiedForm.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -14,19 +14,29 @@
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
const {content} = event
|
const classified = reader(Classified)(event)
|
||||||
const {d, title, status} = fromPairs(event.tags)
|
|
||||||
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
|
const initialValues = $derived(
|
||||||
const images = getTagValues("image", event.tags)
|
classified && {
|
||||||
const topics = getTagValues("t", event.tags)
|
d: classified.identifier() ?? "",
|
||||||
const initialValues = {d, title, status, content, price: Number(price), currency, images, topics}
|
title: classified.title() ?? "",
|
||||||
|
status: classified.status() ?? "",
|
||||||
|
content: classified.content(),
|
||||||
|
price: classified.price()?.amount ?? 0,
|
||||||
|
currency: classified.price()?.currency ?? "SAT",
|
||||||
|
images: classified.images(),
|
||||||
|
topics: classified.topics(),
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ClassifiedForm {url} {initialValues}>
|
{#if initialValues}
|
||||||
|
<ClassifiedForm {url} {initialValues}>
|
||||||
{#snippet header()}
|
{#snippet header()}
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Edit this Listing</ModalTitle>
|
<ModalTitle>Edit this Listing</ModalTitle>
|
||||||
<ModalSubtitle>Advertise a job, sale, or need.</ModalSubtitle>
|
<ModalSubtitle>Advertise a job, sale, or need.</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ClassifiedForm>
|
</ClassifiedForm>
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {removeUndefined, randomId, uniq} from "@welshman/lib"
|
import {removeUndefined, randomId, uniq} from "@welshman/lib"
|
||||||
import {makeEvent, CLASSIFIED} from "@welshman/util"
|
import {publish} from "@welshman/app"
|
||||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
import {Classified} from "@welshman/domain"
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
import {normalizeTopic} from "@lib/util"
|
import {normalizeTopic} from "@lib/util"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
|
@ -17,11 +17,11 @@
|
||||||
import CurrencyInput from "@app/components/CurrencyInput.svelte"
|
import CurrencyInput from "@app/components/CurrencyInput.svelte"
|
||||||
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
|
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
|
import {command, relays, writer} from "@app/core"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
import {publishRoomQuote} from "@app/rooms"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {DraftKey} from "@app/drafts"
|
import {DraftKey} from "@app/drafts"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
import {compressFileForUpload, uploadFile} from "@app/uploads"
|
import {compressFileForUpload, uploadFile} from "@app/uploads"
|
||||||
|
|
||||||
type Values = {
|
type Values = {
|
||||||
|
|
@ -51,8 +51,6 @@
|
||||||
initialValues = draftKey.get()
|
initialValues = draftKey.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
|
@ -76,32 +74,11 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = [
|
const imageUrls: string[] = []
|
||||||
["d", d],
|
|
||||||
["title", title],
|
|
||||||
["summary", content],
|
|
||||||
["price", String(price), currency],
|
|
||||||
["status", status],
|
|
||||||
...ed.storage.nostr.getEditorTags(),
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const topic of topics) {
|
|
||||||
tags.push(["t", topic])
|
|
||||||
}
|
|
||||||
|
|
||||||
const protect = await shouldProtect
|
|
||||||
|
|
||||||
if (protect) {
|
|
||||||
tags.push(PROTECTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (h) {
|
|
||||||
tags.push(["h", h])
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
if (typeof image === "string") {
|
if (typeof image === "string") {
|
||||||
tags.push(["image", image])
|
imageUrls.push(image)
|
||||||
} else {
|
} else {
|
||||||
const {result, error} = await uploadFile(await compressFileForUpload(image), {url})
|
const {result, error} = await uploadFile(await compressFileForUpload(image), {url})
|
||||||
|
|
||||||
|
|
@ -113,17 +90,33 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
tags.push(["image", result.url])
|
imageUrls.push(result.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const classifiedThunk = publishThunk({
|
const protect = await $relays.hasNip(url, 70)
|
||||||
relays: [url],
|
|
||||||
event: makeEvent(CLASSIFIED, {content, tags}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const error = await waitForThunkError(classifiedThunk)
|
const eventWriter = writer(Classified)
|
||||||
|
.setIdentifier(d)
|
||||||
|
.setContent(content)
|
||||||
|
.setTitle(title)
|
||||||
|
.setSummary(content)
|
||||||
|
.setPrice(price, currency)
|
||||||
|
.setStatus(status)
|
||||||
|
.setTopics(topics)
|
||||||
|
.setImages(imageUrls)
|
||||||
|
.setProtected(protect)
|
||||||
|
.addTags(...ed.storage.nostr.getEditorTags())
|
||||||
|
|
||||||
|
if (h) {
|
||||||
|
eventWriter.setRoom(url, h)
|
||||||
|
} else {
|
||||||
|
eventWriter.forceRelays(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const classifiedThunk = await command(eventWriter).then(publish)
|
||||||
|
const error = await classifiedThunk.waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return pushToast({theme: "error", message: error})
|
return pushToast({theme: "error", message: error})
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {formatTimestamp} from "@welshman/lib"
|
import {formatTimestamp} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getTag, getAddress, getTagValue, getTagValues} from "@welshman/util"
|
import {getAddress} from "@welshman/util"
|
||||||
|
import {Classified} from "@welshman/domain"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
|
import CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
|
||||||
|
import {reader} from "@app/core"
|
||||||
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
|
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
|
@ -18,10 +20,12 @@
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
const title = getTagValue("title", event.tags)
|
const classified = reader(Classified)(event)
|
||||||
const h = getTagValue("h", event.tags)
|
|
||||||
const images = new Set(getTagValues("image", event.tags))
|
const title = classified.title()
|
||||||
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
|
const h = classified.room()
|
||||||
|
const images = new Set(classified.images())
|
||||||
|
const price = classified.price()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -31,7 +35,7 @@
|
||||||
<div class="flex w-full items-center justify-between gap-2">
|
<div class="flex w-full items-center justify-between gap-2">
|
||||||
<p class="text-xl">
|
<p class="text-xl">
|
||||||
{title} —
|
{title} —
|
||||||
<CurrencySymbol code={currency} />{price}
|
<CurrencySymbol code={price?.currency ?? "SAT"} />{price?.amount ?? 0}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm opacity-75">
|
<p class="text-sm opacity-75">
|
||||||
{formatTimestamp(event.created_at)}
|
{formatTimestamp(event.created_at)}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getTagValue} from "@welshman/util"
|
import {Classified} from "@welshman/domain"
|
||||||
import {ucFirst} from "@lib/util"
|
import {ucFirst} from "@lib/util"
|
||||||
import Badge from "@lib/components/Badge.svelte"
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
|
import {reader} from "@app/core"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
|
|
@ -10,7 +11,9 @@
|
||||||
|
|
||||||
const {event}: Props = $props()
|
const {event}: Props = $props()
|
||||||
|
|
||||||
const status = getTagValue("status", event.tags)
|
const classified = reader(Classified)(event)
|
||||||
|
|
||||||
|
const status = classified.status()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if status}
|
{#if status}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
|
import {tagSpec, tagValue} from "@welshman/util"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
|
import {publishReaction, retractReaction} from "@app/reactions"
|
||||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||||
import EventActivity from "@app/components/EventActivity.svelte"
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
import EventActions from "@app/components/EventActions.svelte"
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
import {publishDelete} from "@app/deletes"
|
|
||||||
import {publishReaction} from "@app/reactions"
|
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
segment: string
|
segment: string
|
||||||
|
|
@ -18,15 +17,13 @@
|
||||||
|
|
||||||
const {url, event, segment, showActivity = false}: Props = $props()
|
const {url, event, segment, showActivity = false}: Props = $props()
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const h = tagValue(tagSpec("h"), event.tags)
|
||||||
|
|
||||||
const path = makeSpacePath(url, segment, event.id)
|
const path = makeSpacePath(url, segment, event.id)
|
||||||
|
|
||||||
const deleteReaction = async (event: TrustedEvent) =>
|
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
|
||||||
|
|
||||||
const createReaction = async (template: EventContent) =>
|
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {fromNostrURI, getTagValue} from "@welshman/util"
|
import {fromNostrURI, tagSpec, tagValue} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
parse,
|
parse,
|
||||||
truncate,
|
truncate,
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
import {entityLink} from "@app/env"
|
import {entityLink} from "@app/env"
|
||||||
import {userSettingsValues} from "@app/settings"
|
import {userSettingsValues} from "@app/settings"
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
event: any
|
event: any
|
||||||
minLength?: number
|
minLength?: number
|
||||||
maxLength?: number
|
maxLength?: number
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
const warning = $derived(
|
const warning = $derived(
|
||||||
warningDismissed
|
warningDismissed
|
||||||
? undefined
|
? undefined
|
||||||
: $userSettingsValues.hide_sensitive && getTagValue("content-warning", event.tags),
|
: $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>) => {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {call, ellipsize, displayUrl} from "@welshman/lib"
|
import {call, ellipsize, displayUrl} from "@welshman/lib"
|
||||||
import {isRelayUrl, getTagValue} from "@welshman/util"
|
import {isRelayUrl, tagSpec, tagValue} from "@welshman/util"
|
||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
|
import {PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
|
||||||
import {IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/content"
|
import {IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/content"
|
||||||
import {isRoomId} from "@app/groups"
|
import {isRoomId} from "@app/rooms"
|
||||||
|
|
||||||
const {value, event} = $props()
|
const {value, event} = $props()
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
return [url, true]
|
return [url, true]
|
||||||
})
|
})
|
||||||
|
|
||||||
const fileType = getTagValue("file-type", event.tags) || ""
|
const fileType = tagValue(tagSpec("file-type"), event.tags) || ""
|
||||||
|
|
||||||
const getVideoPoster = (videoUrl: string): string | undefined => {
|
const getVideoPoster = (videoUrl: string): string | undefined => {
|
||||||
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
|
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
|
||||||
|
|
@ -62,7 +62,6 @@
|
||||||
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
||||||
{:else}
|
{:else}
|
||||||
<Link {external} {href} class="my-2 block">
|
<Link {external} {href} class="my-2 block">
|
||||||
<div class="overflow-hidden rounded-2xl">
|
|
||||||
{#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
|
||||||
|
|
@ -83,14 +82,14 @@
|
||||||
</div>
|
</div>
|
||||||
{:then preview}
|
{:then preview}
|
||||||
<div
|
<div
|
||||||
class="border border-solid flex max-w-xl flex-col leading-normal rounded-2xl"
|
class="border border-solid flex max-w-xl flex-col overflow-hidden leading-normal rounded-2xl"
|
||||||
style="border-color: var(--line)">
|
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 rounded-t-2xl object-contain object-center" />
|
class="bg-surface max-h-72 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"
|
||||||
|
|
@ -100,12 +99,11 @@
|
||||||
</div>
|
</div>
|
||||||
{:catch}
|
{:catch}
|
||||||
<p
|
<p
|
||||||
class="border border-solid p-12 text-center leading-normal"
|
class="border border-solid p-12 text-center leading-normal rounded-2xl"
|
||||||
style="border-color: var(--line)">
|
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}
|
||||||
|
|
|
||||||
|
|
@ -2,34 +2,35 @@
|
||||||
import {onMount, onDestroy} from "svelte"
|
import {onMount, onDestroy} from "svelte"
|
||||||
import {displayUrl, once} from "@welshman/lib"
|
import {displayUrl, once} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
getTags,
|
|
||||||
getBlob,
|
getBlob,
|
||||||
decryptFile,
|
decryptFile,
|
||||||
getTagValue,
|
|
||||||
tagsFromIMeta,
|
|
||||||
makeBlossomAuthEvent,
|
makeBlossomAuthEvent,
|
||||||
|
matchTags,
|
||||||
|
tagSpec,
|
||||||
|
tagValue,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import {signer} from "@welshman/app"
|
|
||||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import {user} from "@app/core"
|
||||||
|
|
||||||
const {value, event, ...props} = $props()
|
const {value, event, ...props} = $props()
|
||||||
|
|
||||||
const url = value.url.toString()
|
const url = value.url.toString()
|
||||||
|
// An imeta tag packs its own tags into space-separated values, so unpack the one for this url.
|
||||||
const meta =
|
const meta =
|
||||||
getTags("imeta", event.tags)
|
matchTags(tagSpec("imeta"), event.tags)
|
||||||
.map(tagsFromIMeta)
|
.map(([, ...values]: string[]) => values.map(value => value.split(" ")))
|
||||||
.find(meta => getTagValue("url", meta) === url) || event.tags
|
.find(meta => tagValue(tagSpec("url"), meta) === url) || event.tags
|
||||||
|
|
||||||
// Fallback to filename if hash was omitted from the message for interoperability
|
// Fallback to filename if hash was omitted from the message for interoperability
|
||||||
const hash = getTagValue("x", meta) || url.split(/[\/\.]/).slice(-2)[0]
|
const hash = tagValue(tagSpec("x"), meta) || url.split(/[\/\.]/).slice(-2)[0]
|
||||||
const key = getTagValue("decryption-key", meta)
|
const key = tagValue(tagSpec("decryption-key"), meta)
|
||||||
const nonce = getTagValue("decryption-nonce", meta)
|
const nonce = tagValue(tagSpec("decryption-nonce"), meta)
|
||||||
const algorithm = getTagValue("encryption-algorithm", meta)
|
const algorithm = tagValue(tagSpec("encryption-algorithm"), meta)
|
||||||
const mime = getTagValue("m", meta)
|
const mime = tagValue(tagSpec("m"), meta)
|
||||||
const fileName =
|
const fileName =
|
||||||
getTagValue("filename", meta) ||
|
tagValue(tagSpec("filename"), meta) ||
|
||||||
getTagValue("name", meta) ||
|
tagValue(tagSpec("name"), meta) ||
|
||||||
decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).at(-1) || "image")
|
decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).at(-1) || "image")
|
||||||
|
|
||||||
const revokeSrc = () => {
|
const revokeSrc = () => {
|
||||||
|
|
@ -45,10 +46,10 @@
|
||||||
|
|
||||||
const onError = once(async () => {
|
const onError = once(async () => {
|
||||||
// If the image failed to load, try authenticating
|
// If the image failed to load, try authenticating
|
||||||
if (hash && $signer) {
|
if (hash) {
|
||||||
const server = new URL(url).origin
|
const server = new URL(url).origin
|
||||||
const template = makeBlossomAuthEvent({action: "get", server, hashes: [hash]})
|
const template = makeBlossomAuthEvent({action: "get", server, hashes: [hash]})
|
||||||
const authEvent = await $signer.sign(template)
|
const authEvent = await $user.signer.sign(template)
|
||||||
const res = await getBlob(server, hash, {authEvent})
|
const res = await getBlob(server, hash, {authEvent})
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayUrl} from "@welshman/lib"
|
import {displayUrl} from "@welshman/lib"
|
||||||
import {getTagValue} from "@welshman/util"
|
import {tagSpec, tagValue} from "@welshman/util"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
const {value, event} = $props()
|
const {value, event} = $props()
|
||||||
|
|
||||||
const url = value.url.toString()
|
const url = value.url.toString()
|
||||||
const fileType = getTagValue("file-type", event.tags) || ""
|
const fileType = tagValue(tagSpec("file-type"), event.tags) || ""
|
||||||
|
|
||||||
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {call, displayUrl} from "@welshman/lib"
|
import {call, displayUrl} from "@welshman/lib"
|
||||||
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {splitRoomKey} from "@welshman/app"
|
||||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import RoomName from "@app/components/RoomName.svelte"
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import {PLATFORM_URL} from "@app/env"
|
import {PLATFORM_URL} from "@app/env"
|
||||||
import {isRoomId, splitRoomId} from "@app/groups"
|
import {isRoomId} from "@app/rooms"
|
||||||
import {makeRoomPath, makeSpacePath} from "@app/routes"
|
import {makeRoomPath, makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
const {
|
type Props = {
|
||||||
url,
|
|
||||||
class: className = "",
|
|
||||||
}: {
|
|
||||||
url: string
|
url: string
|
||||||
class?: string
|
class?: string
|
||||||
} = $props()
|
}
|
||||||
|
|
||||||
|
const {url, class: className = ""}: Props = $props()
|
||||||
|
|
||||||
const roomReference = call(() => {
|
const roomReference = call(() => {
|
||||||
if (!isRoomId(url)) {
|
if (!isRoomId(url)) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const [roomUrl, h] = splitRoomId(url)
|
const [roomUrl, h] = splitRoomKey(url)
|
||||||
|
|
||||||
if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
|
if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
|
||||||
return undefined
|
return undefined
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {removeUndefined} from "@welshman/lib"
|
|
||||||
import type {ProfilePointer} from "@welshman/content"
|
import type {ProfilePointer} from "@welshman/content"
|
||||||
import {deriveProfileDisplay} from "@welshman/app"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
import {profiles} from "@app/core"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: ProfilePointer
|
value: ProfilePointer
|
||||||
|
|
@ -13,7 +12,7 @@
|
||||||
|
|
||||||
const {value, url}: Props = $props()
|
const {value, url}: Props = $props()
|
||||||
|
|
||||||
const display = deriveProfileDisplay(value.pubkey, removeUndefined([url]))
|
const display = $profiles.display(value.pubkey).$
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import {fromNostrURI, getTagValue} from "@welshman/util"
|
import {fromNostrURI, tagValue, tagSpec} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
parse,
|
parse,
|
||||||
truncate,
|
truncate,
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
const warning = $derived(
|
const warning = $derived(
|
||||||
warningDismissed
|
warningDismissed
|
||||||
? undefined
|
? undefined
|
||||||
: $userSettingsValues.hide_sensitive && getTagValue("content-warning", event.tags),
|
: $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>) => {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {Router} from "@welshman/router"
|
import {derived, writable} from "svelte/store"
|
||||||
|
import {removeUndefined} from "@welshman/lib"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {Address, MESSAGE} from "@welshman/util"
|
import {Address, MESSAGE, eventOutbox, 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 {router} from "@app/core"
|
||||||
import {deriveEvent} from "@app/repository"
|
import {deriveEvent} from "@app/repository"
|
||||||
import {entityLink} from "@app/env"
|
import {entityLink} from "@app/env"
|
||||||
import {goToEvent} from "@app/routes"
|
import {goToEvent} from "@app/routes"
|
||||||
|
|
@ -22,22 +25,37 @@
|
||||||
|
|
||||||
const {id, identifier, kind, pubkey, relays = []} = value
|
const {id, identifier, kind, pubkey, relays = []} = value
|
||||||
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
|
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
|
||||||
const mergedRelays = Router.get().Quote(event, idOrAddress, relays).getUrls()
|
const ref = {id, pubkey, kind, identifier, relays}
|
||||||
|
|
||||||
if (url) {
|
// Start with the hints we were handed, then widen to everything the router can work out:
|
||||||
mergedRelays.push(url)
|
// 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.
|
||||||
|
const mergedRelays = writable(removeUndefined([...relays, url]))
|
||||||
|
|
||||||
const quote = deriveEvent(idOrAddress, mergedRelays)
|
$router.resolver
|
||||||
const entity = id
|
.relays([
|
||||||
? nip19.neventEncode({id, relays: mergedRelays})
|
...relaySelections(removeUndefined([url])),
|
||||||
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
|
seen(ref),
|
||||||
|
eventOutbox(ref),
|
||||||
|
seen(event, 0.5),
|
||||||
|
])
|
||||||
|
.then(urls => mergedRelays.set(urls))
|
||||||
|
|
||||||
|
const quote = derived(mergedRelays, ($mergedRelays, set: (event: Maybe<TrustedEvent>) => void) =>
|
||||||
|
deriveEvent(idOrAddress, $mergedRelays).subscribe(set),
|
||||||
|
)
|
||||||
|
|
||||||
|
const entity = derived(mergedRelays, $mergedRelays =>
|
||||||
|
id
|
||||||
|
? nip19.neventEncode({id, relays: $mergedRelays})
|
||||||
|
: new Address(kind, pubkey, identifier, $mergedRelays).toNaddr(),
|
||||||
|
)
|
||||||
|
|
||||||
const onclick = () => {
|
const onclick = () => {
|
||||||
if ($quote) {
|
if ($quote) {
|
||||||
goToEvent($quote)
|
goToEvent($quote)
|
||||||
} else {
|
} else {
|
||||||
window.open(entityLink(entity))
|
window.open(entityLink($entity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {spec} from "@welshman/lib"
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import type {Writable} from "svelte/store"
|
import type {Writable} from "svelte/store"
|
||||||
|
|
@ -50,7 +51,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currency = $derived(currencyOptions.find(c => c.code === value))
|
const currency = $derived(currencyOptions.find(spec({code: value})))
|
||||||
|
|
||||||
let wrapper: Element | undefined = $state()
|
let wrapper: Element | undefined = $state()
|
||||||
let popover: Instance | undefined = $state()
|
let popover: Instance | undefined = $state()
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const error = await setFeaturedContent(url, content)
|
const thunk = await setFeaturedContent(url, content)
|
||||||
|
const error = await thunk.waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
|
|
@ -39,6 +40,9 @@
|
||||||
pushToast({message: "Featured content updated!"})
|
pushToast({message: "Featured content updated!"})
|
||||||
back()
|
back()
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
pushToast({theme: "error", message: "Failed to update featured content."})
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,18 @@
|
||||||
import type {Instance} from "tippy.js"
|
import type {Instance} from "tippy.js"
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {tagSpec, tagValue} from "@welshman/util"
|
||||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Tippy from "@lib/components/Tippy.svelte"
|
import Tippy from "@lib/components/Tippy.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import ZapButton from "@app/components/ZapButton.svelte"
|
|
||||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||||
|
import ZapButton from "@app/components/ZapButton.svelte"
|
||||||
import EventMenu from "@app/components/EventMenu.svelte"
|
import EventMenu from "@app/components/EventMenu.svelte"
|
||||||
|
import {reactions, relays} from "@app/core"
|
||||||
import {ENABLE_ZAPS} from "@app/env"
|
import {ENABLE_ZAPS} from "@app/env"
|
||||||
import {publishReaction} from "@app/reactions"
|
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -26,20 +26,27 @@
|
||||||
|
|
||||||
const {url, noun, event, hideZap, customActions}: Props = $props()
|
const {url, noun, event, hideZap, customActions}: Props = $props()
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = $relays.hasNip(url, 70)
|
||||||
|
|
||||||
const showPopover = () => popover?.show()
|
const showPopover = () => popover?.show()
|
||||||
|
|
||||||
const hidePopover = () => popover?.hide()
|
const hidePopover = () => popover?.hide()
|
||||||
|
|
||||||
const onEmoji = async (emoji: NativeEmoji) =>
|
const onEmoji = async (emoji: NativeEmoji) => {
|
||||||
publishReaction({
|
const protect = await shouldProtect
|
||||||
event,
|
const room = tagValue(tagSpec("h"), event.tags)
|
||||||
content: emoji.unicode,
|
|
||||||
relays: [url],
|
const command = await $reactions.react(event, emoji.unicode, writer => {
|
||||||
protect: await shouldProtect,
|
writer.setProtected(protect)
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
writer.setRoom(url, room)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return command.publishToRelays([url])
|
||||||
|
}
|
||||||
|
|
||||||
let popover: Instance | undefined = $state()
|
let popover: Instance | undefined = $state()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,22 @@
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {max, gt, formatTimestampRelative} from "@welshman/lib"
|
import {max, gt, formatTimestampRelative} from "@welshman/lib"
|
||||||
import {COMMENT} from "@welshman/util"
|
import {COMMENT} from "@welshman/util"
|
||||||
import {load} from "@welshman/net"
|
|
||||||
import {deriveArray, deriveEventsById} from "@welshman/store"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {repository} from "@welshman/app"
|
|
||||||
import {deriveChecked} from "@app/notifications"
|
|
||||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import {deriveEvents} from "@app/repository"
|
||||||
|
import {network} from "@app/core"
|
||||||
|
import {deriveChecked} from "@app/notifications"
|
||||||
|
|
||||||
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props()
|
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props()
|
||||||
|
|
||||||
const checked = deriveChecked(path)
|
const checked = deriveChecked(path)
|
||||||
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
|
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
|
||||||
const replies = deriveArray(deriveEventsById({repository, filters}))
|
const replies = deriveEvents(filters)
|
||||||
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
load({relays: [url], filters})
|
$network.load({relays: [url], filters})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import {publishDelete} from "@app/deletes"
|
import {deletes, relays} from "@app/core"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
import {clearModals} from "@app/modal"
|
import {clearModals} from "@app/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -12,10 +11,13 @@
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = $relays.hasNip(url, 70)
|
||||||
|
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
await publishDelete({event, relays: [url], protect: await shouldProtect})
|
const protect = await shouldProtect
|
||||||
|
const command = await $deletes.deleteEvent(event, writer => writer.setProtected(protect))
|
||||||
|
|
||||||
|
command.publishToRelays([url])
|
||||||
|
|
||||||
clearModals()
|
clearModals()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
<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, toNostrURI} from "@welshman/util"
|
import {displayRelayUrl, seen, toNostrURI} from "@welshman/util"
|
||||||
import {tracker} from "@welshman/app"
|
|
||||||
import FileText from "@assets/icons/file-text.svg?dataurl"
|
import FileText from "@assets/icons/file-text.svg?dataurl"
|
||||||
import Copy from "@assets/icons/copy.svg?dataurl"
|
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||||
|
|
@ -18,6 +17,7 @@
|
||||||
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,9 +27,7 @@
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
const seenOn = $app.tracker.getRelays(event.id)
|
||||||
const nevent1 = toNostrURI(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)
|
||||||
|
|
@ -41,6 +39,14 @@
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {COMMENT, ManagementMethod} from "@welshman/util"
|
import {COMMENT} from "@welshman/util"
|
||||||
import {pubkey, repository, manageRelay} from "@welshman/app"
|
|
||||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||||
|
|
@ -17,9 +16,10 @@
|
||||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||||
import PinboardSelect from "@app/components/PinboardSelect.svelte"
|
import PinboardSelect from "@app/components/PinboardSelect.svelte"
|
||||||
import {shareEventToChat} from "@app/share"
|
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"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -49,16 +49,13 @@
|
||||||
title: `Delete ${noun}`,
|
title: `Delete ${noun}`,
|
||||||
message: `Are you sure you want to delete this ${noun.toLowerCase()} from the space?`,
|
message: `Are you sure you want to delete this ${noun.toLowerCase()} from the space?`,
|
||||||
confirm: async () => {
|
confirm: async () => {
|
||||||
const {error} = await manageRelay(url, {
|
const {error} = await $relayManagement.forUrl(url).banEvent(event.id)
|
||||||
method: ManagementMethod.BanEvent,
|
|
||||||
params: [event.id],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
} else {
|
} else {
|
||||||
pushToast({message: "Event has successfully been deleted!"})
|
pushToast({message: "Event has successfully been deleted!"})
|
||||||
repository.removeEvent(event.id)
|
$app.repository.removeEvent(event.id)
|
||||||
history.back()
|
history.back()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -93,7 +90,7 @@
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
{@render customActions?.()}
|
{@render customActions?.()}
|
||||||
{#if event.pubkey === $pubkey}
|
{#if event.pubkey === $user.pubkey}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={showDelete} class="text-error">
|
<Button onclick={showDelete} class="text-error">
|
||||||
<Icon size={4} icon={TrashBin2} />
|
<Icon size={4} icon={TrashBin2} />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
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"
|
||||||
|
|
@ -10,12 +12,11 @@
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||||
import {publishComment} from "@app/comments"
|
import {prependParent} from "@app/rooms"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
import {PROTECTED, prependParent} from "@app/groups"
|
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {DraftKey} from "@app/drafts"
|
import {DraftKey} from "@app/drafts"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
import {command, relays, writer} from "@app/core"
|
||||||
|
|
||||||
type Values = {
|
type Values = {
|
||||||
content?: string | object
|
content?: string | object
|
||||||
|
|
@ -33,7 +34,6 @@
|
||||||
const {url, event, parent, onClose, onClearParent, onSubmit}: Props = $props()
|
const {url, event, parent, onClose, onClearParent, onSubmit}: Props = $props()
|
||||||
const draftKey = new DraftKey<Values>(`reply:${event.id}:${parent?.id || ""}`)
|
const draftKey = new DraftKey<Values>(`reply:${event.id}:${parent?.id || ""}`)
|
||||||
const initialValues = draftKey.get()
|
const initialValues = draftKey.get()
|
||||||
const shouldProtect = canEnforceNip70(url)
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
const autofocus = !isMobile
|
const autofocus = !isMobile
|
||||||
|
|
||||||
|
|
@ -46,10 +46,6 @@
|
||||||
let content = ed.getText({blockSeparator: "\n"}).trim()
|
let content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
let tags = ed.storage.nostr.getEditorTags()
|
let tags = ed.storage.nostr.getEditorTags()
|
||||||
|
|
||||||
if (await shouldProtect) {
|
|
||||||
tags.push(PROTECTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
|
|
@ -58,11 +54,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
;({content, tags} = prependParent(parent, {content, tags}, url))
|
;({content, tags} = await prependParent(parent, {content, tags}, url))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventWriter = writer(Comment)
|
||||||
|
.setContent(content)
|
||||||
|
.addTags(...tags)
|
||||||
|
.setRootFromEvent(event)
|
||||||
|
.setParentFromEvent(event)
|
||||||
|
.setProtected(await $relays.hasNip(url, 70))
|
||||||
|
|
||||||
|
const thunk = await command(eventWriter).then(publishToRelays([url]))
|
||||||
|
|
||||||
draftKey.clear()
|
draftKey.clear()
|
||||||
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
onSubmit(thunk)
|
||||||
}
|
}
|
||||||
|
|
||||||
let form: HTMLElement
|
let form: HTMLElement
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,18 @@
|
||||||
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 RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
|
||||||
import {roomsByUrl} from "@app/groups"
|
|
||||||
import {makeRoomPath} from "@app/routes"
|
import {makeRoomPath} from "@app/routes"
|
||||||
|
import {rooms} from "@app/core"
|
||||||
|
|
||||||
const {url, noun, event}: {url: string; noun: string; event: TrustedEvent} = $props()
|
type Props = {
|
||||||
|
url: string
|
||||||
|
noun: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, noun, event}: Props = $props()
|
||||||
|
|
||||||
|
const spaceRooms = $rooms.forUrl(url).$
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
|
|
@ -40,14 +48,14 @@
|
||||||
<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-2 gap-2">
|
||||||
{#each $roomsByUrl.get(url) || [] as room (room.h)}
|
{#each $spaceRooms as room (room.h)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex justify-center button {selection === room.h
|
class="flex justify-center button {selection === room.h
|
||||||
? 'button-primary'
|
? 'button-primary'
|
||||||
: 'button-neutral'}"
|
: 'button-neutral'}"
|
||||||
onclick={() => toggleRoom(room.h)}>
|
onclick={() => toggleRoom(room.h)}>
|
||||||
<RoomNameWithImage {...room} />
|
<RoomNameWithImage {url} h={room.h} />
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {getTagValue} from "@welshman/util"
|
import {ZapGoal} from "@welshman/domain"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import {reader} from "@app/core"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
|
import {publishReaction, retractReaction} from "@app/reactions"
|
||||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||||
import EventActivity from "@app/components/EventActivity.svelte"
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
import EventActions from "@app/components/EventActions.svelte"
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
import RoomName from "@app/components/RoomName.svelte"
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import {publishDelete} from "@app/deletes"
|
|
||||||
import {publishReaction} from "@app/reactions"
|
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
import {makeGoalPath, makeSpacePath} from "@app/routes"
|
import {makeGoalPath, makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -22,14 +21,13 @@
|
||||||
const {url, event, showRoom, showActivity}: Props = $props()
|
const {url, event, showRoom, showActivity}: Props = $props()
|
||||||
|
|
||||||
const path = makeGoalPath(url, event.id)
|
const path = makeGoalPath(url, event.id)
|
||||||
const h = getTagValue("h", event.tags)
|
const goal = reader(ZapGoal)(event)
|
||||||
const shouldProtect = canEnforceNip70(url)
|
|
||||||
|
|
||||||
const deleteReaction = async (event: TrustedEvent) =>
|
const h = goal.room()
|
||||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
|
||||||
|
|
||||||
const createReaction = async (template: EventContent) =>
|
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url})
|
||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
|
||||||
|
const createReaction = (values: EventContent) => publishReaction(event, values, {url})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import {makeEvent, ZAP_GOAL} from "@welshman/util"
|
import {ZapGoal} from "@welshman/domain"
|
||||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
import {publish} from "@welshman/app"
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
|
|
@ -18,11 +18,11 @@
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
|
import {command, relays, writer} from "@app/core"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
import {publishRoomQuote} from "@app/rooms"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {DraftKey} from "@app/drafts"
|
import {DraftKey} from "@app/drafts"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
|
|
||||||
type Values = {
|
type Values = {
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
initialValues = draftKey.get()
|
initialValues = draftKey.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = $relays.hasNip(url, 70)
|
||||||
|
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
|
|
||||||
|
|
@ -73,32 +73,25 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = [
|
|
||||||
...ed.storage.nostr.getEditorTags(),
|
|
||||||
["summary", content],
|
|
||||||
["amount", String(amount)],
|
|
||||||
["relays", url],
|
|
||||||
]
|
|
||||||
|
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const protect = await shouldProtect
|
const protect = await shouldProtect
|
||||||
|
const eventWriter = writer(ZapGoal)
|
||||||
if (protect) {
|
.setTitle(title)
|
||||||
tags.push(PROTECTED)
|
.setSummary(content)
|
||||||
}
|
.setAmount(amount)
|
||||||
|
.setUrls([url])
|
||||||
|
.setProtected(protect)
|
||||||
|
.addTags(...ed.storage.nostr.getEditorTags())
|
||||||
|
.forceRelays(url)
|
||||||
|
|
||||||
if (h) {
|
if (h) {
|
||||||
tags.push(["h", h])
|
eventWriter.setRoom(url, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
const goalThunk = publishThunk({
|
const goalThunk = await command(eventWriter).then(publish)
|
||||||
relays: [url],
|
const error = await goalThunk.waitForError()
|
||||||
event: makeEvent(ZAP_GOAL, {content: title, tags}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const error = await waitForThunkError(goalThunk)
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return pushToast({theme: "error", message: error})
|
return pushToast({theme: "error", message: error})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getTagValue} from "@welshman/util"
|
import {ZapGoal} from "@welshman/domain"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import {reader} from "@app/core"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
import GoalActions from "@app/components/GoalActions.svelte"
|
import GoalActions from "@app/components/GoalActions.svelte"
|
||||||
|
|
@ -16,14 +17,17 @@
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
const summary = getTagValue("summary", event.tags)
|
const goal = reader(ZapGoal)(event)
|
||||||
const h = getTagValue("h", event.tags)
|
|
||||||
|
const title = goal.title()
|
||||||
|
const summary = goal.summary()
|
||||||
|
const h = goal.room()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
class="cv flex flex-col gap-2 card card-interactive w-full"
|
class="cv flex flex-col gap-2 card card-interactive w-full"
|
||||||
href={makeGoalPath(url, event.id)}>
|
href={makeGoalPath(url, event.id)}>
|
||||||
<p class="text-2xl">{event.content}</p>
|
<p class="text-2xl">{title}</p>
|
||||||
<Content
|
<Content
|
||||||
event={{content: summary, tags: event.tags}}
|
event={{content: summary, tags: event.tags}}
|
||||||
{url}
|
{url}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {derived} from "svelte/store"
|
||||||
import {now, DAY, uniq, sum} from "@welshman/lib"
|
import {now, DAY, uniq, sum} from "@welshman/lib"
|
||||||
import type {Zap, TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
|
import {fromMsats, ZAP_RECEIPT} from "@welshman/util"
|
||||||
import {deriveItemsByKey, deriveArray} from "@welshman/store"
|
import {ZapGoal} from "@welshman/domain"
|
||||||
import {repository, getValidZap} from "@welshman/app"
|
import type {Zap} from "@welshman/domain"
|
||||||
|
import {Zappers} from "@welshman/app"
|
||||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import {deriveEvents} from "@app/repository"
|
||||||
import ZapButton from "@app/components/ZapButton.svelte"
|
import ZapButton from "@app/components/ZapButton.svelte"
|
||||||
|
import {app, reader} from "@app/core"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url?: string
|
url?: string
|
||||||
|
|
@ -16,18 +20,23 @@
|
||||||
|
|
||||||
const {url, event, ...props}: Props = $props()
|
const {url, event, ...props}: Props = $props()
|
||||||
|
|
||||||
const zaps = deriveArray(
|
const receipts = deriveEvents([{kinds: [ZAP_RECEIPT], "#e": [event.id]}])
|
||||||
deriveItemsByKey<Zap>({
|
|
||||||
repository,
|
const zaps = derived<typeof receipts, Zap[]>(
|
||||||
getKey: zap => zap.response.id,
|
receipts,
|
||||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
($receipts, set) => $app.use(Zappers).validZapReceipts($receipts, event).$.subscribe(set),
|
||||||
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
[],
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const goalAmount = parseInt(getTagValue("amount", event.tags) || "0")
|
const goal = reader(ZapGoal)(event)
|
||||||
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
|
|
||||||
const contributorsCount = $derived(uniq($zaps.map(zap => zap.request.pubkey)).length)
|
const goalAmount = goal.amount() ?? 0
|
||||||
|
const closedAt = goal.closedAt()
|
||||||
|
|
||||||
|
// NIP-75: receipts published after closed_at don't count toward the goal.
|
||||||
|
const counted = $derived($zaps.filter(zap => !closedAt || zap.response.created_at <= closedAt))
|
||||||
|
const zapAmount = $derived(fromMsats(sum(counted.map(zap => zap.invoiceAmount))))
|
||||||
|
const contributorsCount = $derived(uniq(counted.map(zap => zap.request.pubkey)).length)
|
||||||
const daysOld = Math.ceil((now() - event.created_at) / DAY)
|
const daysOld = Math.ceil((now() - event.created_at) / DAY)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {session} from "@welshman/app"
|
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||||
|
|
@ -11,8 +10,10 @@
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import KeyRecoveryRequest from "@app/components/KeyRecoveryRequest.svelte"
|
import KeyRecoveryRequest from "@app/components/KeyRecoveryRequest.svelte"
|
||||||
|
import {isPomadeSession} from "@app/pomade"
|
||||||
import {PLATFORM_NAME} from "@app/env"
|
import {PLATFORM_NAME} from "@app/env"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
import {session} from "@app/core"
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
|
|
@ -36,7 +37,7 @@
|
||||||
<strong>public key</strong>, which acts as your user ID, and a
|
<strong>public key</strong>, which acts as your user ID, and a
|
||||||
<strong>private key</strong> which allows you to prove your identity.
|
<strong>private key</strong> which allows you to prove your identity.
|
||||||
</p>
|
</p>
|
||||||
{#if $session?.email}
|
{#if isPomadeSession($session)}
|
||||||
<p>
|
<p>
|
||||||
It's very important to keep private keys safe, but this can sometimes be tricky, which is
|
It's very important to keep private keys safe, but this can sometimes be tricky, which is
|
||||||
why {PLATFORM_NAME}
|
why {PLATFORM_NAME}
|
||||||
|
|
@ -47,7 +48,7 @@
|
||||||
<Button class="button button-primary" onclick={back}>Got it</Button>
|
<Button class="button button-primary" onclick={back}>Got it</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
{#if $session?.email}
|
{#if isPomadeSession($session)}
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="button button-link" onclick={back}>
|
<Button class="button button-link" onclick={back}>
|
||||||
<Icon icon={AltArrowLeft} />
|
<Icon icon={AltArrowLeft} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {deriveZapperForPubkey} from "@welshman/app"
|
import {Zappers} from "@welshman/app"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -9,10 +9,15 @@
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
import {app} from "@app/core"
|
||||||
|
|
||||||
const {pubkey} = $props()
|
type Props = {
|
||||||
|
pubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
const zapper = deriveZapperForPubkey(pubkey)
|
const {pubkey}: Props = $props()
|
||||||
|
|
||||||
|
const zapper = $app.use(Zappers).forPubkey(pubkey).$
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Client} from "@pomade/core"
|
import {Client} from "@pomade/core"
|
||||||
import {getPubkey} from "@welshman/util"
|
import {getPubkey} from "@welshman/util"
|
||||||
import type {SessionPomade} from "@welshman/app"
|
|
||||||
import {session} from "@welshman/app"
|
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
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"
|
||||||
|
|
@ -19,6 +17,8 @@
|
||||||
import KeyDownload from "@app/components/KeyDownload.svelte"
|
import KeyDownload from "@app/components/KeyDownload.svelte"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {pushModal, clearModals} from "@app/modal"
|
import {pushModal, clearModals} from "@app/modal"
|
||||||
|
import {session} from "@app/core"
|
||||||
|
import {requirePomadeSession} from "@app/pomade"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
peersByPrefix: Map<string, string>
|
peersByPrefix: Map<string, string>
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
const {
|
const {
|
||||||
email,
|
email,
|
||||||
clientOptions: {secret, peers},
|
clientOptions: {secret, peers},
|
||||||
} = $session as SessionPomade
|
} = requirePomadeSession($session).data
|
||||||
|
|
||||||
const confirmRecovery = async () => {
|
const confirmRecovery = async () => {
|
||||||
const request = await Client.recoverWithChallenge(email, peersByPrefix, otps)
|
const request = await Client.recoverWithChallenge(email, peersByPrefix, otps)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Client} from "@pomade/core"
|
import {Client} from "@pomade/core"
|
||||||
import type {SessionPomade} from "@welshman/app"
|
|
||||||
import {session} from "@welshman/app"
|
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
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"
|
||||||
|
|
@ -15,12 +13,14 @@
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
import {session} from "@app/core"
|
||||||
|
import {requirePomadeSession} from "@app/pomade"
|
||||||
import KeyRecoveryConfirm from "@app/components/KeyRecoveryConfirm.svelte"
|
import KeyRecoveryConfirm from "@app/components/KeyRecoveryConfirm.svelte"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
email,
|
email,
|
||||||
clientOptions: {peers},
|
clientOptions: {peers},
|
||||||
} = $session as SessionPomade
|
} = requirePomadeSession($session).data
|
||||||
|
|
||||||
const requestRecovery = async () => {
|
const requestRecovery = async () => {
|
||||||
const {peersByPrefix} = await Client.requestChallenge(email, peers)
|
const {peersByPrefix} = await Client.requestChallenge(email, peers)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
|
import {getNip07, getNip55, Nip55Signer} from "@welshman/signer"
|
||||||
import {addSession, type Session, makeNip07Session, makeNip55Session} from "@welshman/app"
|
import {nip07, nip55, toSession} from "@welshman/app"
|
||||||
|
import type {Session} from "@welshman/app"
|
||||||
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
||||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||||
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
|
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
import {PLATFORM_NAME, POMADE_SIGNERS} from "@app/env"
|
import {PLATFORM_NAME, POMADE_SIGNERS} from "@app/env"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {setChecked} from "@app/notifications"
|
import {setChecked} from "@app/notifications"
|
||||||
|
import {login} from "@app/core"
|
||||||
|
|
||||||
let signers: any[] = $state([])
|
let signers: any[] = $state([])
|
||||||
let loading: string | undefined = $state()
|
let loading: string | undefined = $state()
|
||||||
|
|
@ -35,7 +37,7 @@
|
||||||
const signUp = () => pushModal(SignUp)
|
const signUp = () => pushModal(SignUp)
|
||||||
|
|
||||||
const onSuccess = async (session: Session) => {
|
const onSuccess = async (session: Session) => {
|
||||||
addSession(session)
|
await login(session)
|
||||||
setChecked("*")
|
setChecked("*")
|
||||||
clearModals()
|
clearModals()
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +49,7 @@
|
||||||
const pubkey = await getNip07()?.getPublicKey()
|
const pubkey = await getNip07()?.getPublicKey()
|
||||||
|
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
await onSuccess(makeNip07Session(pubkey))
|
await onSuccess(toSession(nip07, {}))
|
||||||
} else {
|
} else {
|
||||||
pushToast({
|
pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
|
|
@ -59,15 +61,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginWithNip55 = async (app: any) => {
|
const loginWithNip55 = async (signerApp: any) => {
|
||||||
loading = "nip55"
|
loading = "nip55"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const signer = new Nip55Signer(app.packageName)
|
const signer = new Nip55Signer(signerApp.packageName)
|
||||||
const pubkey = await signer.getPubkey()
|
const pubkey = await signer.getPubkey()
|
||||||
|
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
await onSuccess(makeNip55Session(pubkey, app.packageName))
|
await onSuccess(toSession(nip55, {pubkey, signer: signerApp.packageName}))
|
||||||
} else {
|
} else {
|
||||||
pushToast({
|
pushToast({
|
||||||
theme: "error",
|
theme: "error",
|
||||||
|
|
@ -112,14 +114,14 @@
|
||||||
Log in with Extension
|
Log in with Extension
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#each signers as app}
|
{#each signers as signerApp}
|
||||||
<Button {disabled} class="button button-primary" onclick={() => loginWithNip55(app)}>
|
<Button {disabled} class="button button-primary" onclick={() => loginWithNip55(signerApp)}>
|
||||||
{#if loading === "nip55"}
|
{#if loading === "nip55"}
|
||||||
<Spinner size="sm" class="mr-3" />
|
<Spinner size="sm" class="mr-3" />
|
||||||
{:else}
|
{:else}
|
||||||
<img src={app.iconUrl} alt={app.name} width="20" height="20" />
|
<img src={signerApp.iconUrl} alt={signerApp.name} width="20" height="20" />
|
||||||
{/if}
|
{/if}
|
||||||
Log in with {app.name}
|
Log in with {signerApp.name}
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if hasPomade && !hasSigner}
|
{#if hasPomade && !hasSigner}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import type {Nip46ResponseWithResult} from "@welshman/signer"
|
import type {Nip46ResponseWithResult} from "@welshman/signer"
|
||||||
import {Nip46Broker} from "@welshman/signer"
|
import {Nip46Broker} from "@welshman/signer"
|
||||||
import {makeSecret} from "@welshman/util"
|
import {makeSecret} from "@welshman/util"
|
||||||
import {loginWithNip01, loginWithNip46} from "@welshman/app"
|
import {nip01, nip46, toSession} from "@welshman/app"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import BunkerConnect from "@app/components/BunkerConnect.svelte"
|
import BunkerConnect from "@app/components/BunkerConnect.svelte"
|
||||||
import BunkerUrl from "@app/components/BunkerUrl.svelte"
|
import BunkerUrl from "@app/components/BunkerUrl.svelte"
|
||||||
|
import {login} from "@app/core"
|
||||||
import {Nip46Controller} from "@app/nip46"
|
import {Nip46Controller} from "@app/nip46"
|
||||||
import {clearModals} from "@app/modal"
|
import {clearModals} from "@app/modal"
|
||||||
import {setChecked} from "@app/notifications"
|
import {setChecked} from "@app/notifications"
|
||||||
|
|
@ -36,16 +37,16 @@
|
||||||
|
|
||||||
const controller = new Nip46Controller({
|
const controller = new Nip46Controller({
|
||||||
onNostrConnect: async (response: Nip46ResponseWithResult) => {
|
onNostrConnect: async (response: Nip46ResponseWithResult) => {
|
||||||
const pubkey = await controller.broker.getPublicKey()
|
|
||||||
|
|
||||||
// Use the broker's current relays rather than the ones we started with, since
|
// Use the broker's current relays rather than the ones we started with, since
|
||||||
// the signer may have asked us to switch relays during the connection handshake.
|
// the signer may have asked us to switch relays during the connection handshake.
|
||||||
loginWithNip46(
|
await login(
|
||||||
pubkey,
|
toSession(nip46, {
|
||||||
controller.clientSecret,
|
clientSecret: controller.clientSecret,
|
||||||
response.event.pubkey,
|
signerPubkey: response.event.pubkey,
|
||||||
controller.broker.params.relays,
|
relays: controller.broker.params.relays,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
setChecked("*")
|
setChecked("*")
|
||||||
clearModals()
|
clearModals()
|
||||||
},
|
},
|
||||||
|
|
@ -86,7 +87,7 @@
|
||||||
controller.stop()
|
controller.stop()
|
||||||
|
|
||||||
// connect() may have switched relays, so persist the broker's current relays.
|
// connect() may have switched relays, so persist the broker's current relays.
|
||||||
loginWithNip46(pubkey, clientSecret, signerPubkey, broker.params.relays)
|
await login(toSession(nip46, {clientSecret, signerPubkey, relays: broker.params.relays}))
|
||||||
setChecked("*")
|
setChecked("*")
|
||||||
} else {
|
} else {
|
||||||
return pushToast({
|
return pushToast({
|
||||||
|
|
@ -128,7 +129,7 @@
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// For testing and for play store reviewers
|
// For testing and for play store reviewers
|
||||||
if ($bunker === "reviewkey") {
|
if ($bunker === "reviewkey") {
|
||||||
loginWithNip01(makeSecret())
|
login(toSession(nip01, {secret: makeSecret()}))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {uniq} from "@welshman/lib"
|
|
||||||
import {Client} from "@pomade/core"
|
import {Client} from "@pomade/core"
|
||||||
|
import {uniq} from "@welshman/lib"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
import {setChecked} from "@app/notifications"
|
import {setChecked} from "@app/notifications"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
email?: string
|
email?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
|
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
|
||||||
|
|
||||||
if (res.ok && clientOptions) {
|
if (res.ok && clientOptions) {
|
||||||
loginWithPomade(clientOptions, email)
|
await loginWithPomade(clientOptions, email)
|
||||||
deleteDeactivatedPomadeSessions()
|
deleteDeactivatedPomadeSessions()
|
||||||
setChecked("*")
|
setChecked("*")
|
||||||
clearModals()
|
clearModals()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {bytesToHex} from "@welshman/lib"
|
|
||||||
import {loginWithNip01} from "@welshman/app"
|
|
||||||
import {decrypt} from "nostr-tools/nip49"
|
import {decrypt} from "nostr-tools/nip49"
|
||||||
|
import {bytesToHex} from "@welshman/lib"
|
||||||
|
import {nip01, toSession} from "@welshman/app"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import {nsecDecode} from "@lib/util"
|
import {nsecDecode} from "@lib/util"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
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 ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import {login} from "@app/core"
|
||||||
import {clearModals} from "@app/modal"
|
import {clearModals} from "@app/modal"
|
||||||
import {setChecked} from "@app/notifications"
|
import {setChecked} from "@app/notifications"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
@ -56,7 +57,7 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
loginWithNip01(secret)
|
await login(toSession(nip01, {secret}))
|
||||||
setChecked("*")
|
setChecked("*")
|
||||||
clearModals()
|
clearModals()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
|
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
|
||||||
|
|
||||||
if (res.ok && clientOptions) {
|
if (res.ok && clientOptions) {
|
||||||
loginWithPomade(clientOptions, email)
|
await loginWithPomade(clientOptions, email)
|
||||||
deleteDeactivatedPomadeSessions()
|
deleteDeactivatedPomadeSessions()
|
||||||
setChecked("*")
|
setChecked("*")
|
||||||
clearModals()
|
clearModals()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {pubkey} from "@welshman/app"
|
|
||||||
import ServerPath from "@assets/icons/server-path.svg?dataurl"
|
import ServerPath from "@assets/icons/server-path.svg?dataurl"
|
||||||
import RemoteControlMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
|
import RemoteControlMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
|
||||||
import GalleryMinimalistic from "@assets/icons/gallery-minimalistic.svg?dataurl"
|
import GalleryMinimalistic from "@assets/icons/gallery-minimalistic.svg?dataurl"
|
||||||
|
|
@ -14,6 +13,7 @@
|
||||||
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"
|
||||||
import LogOut from "@app/components/LogOut.svelte"
|
import LogOut from "@app/components/LogOut.svelte"
|
||||||
|
import {user} from "@app/core"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {theme} from "@app/theme"
|
import {theme} from "@app/theme"
|
||||||
|
|
||||||
|
|
@ -24,11 +24,9 @@
|
||||||
<Modal>
|
<Modal>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div class="flex flex-col gap-8 items-center py-12 max-w-[16rem] m-auto w-full">
|
<div class="flex flex-col gap-8 items-center py-12 max-w-[16rem] m-auto w-full">
|
||||||
{#if $pubkey}
|
|
||||||
<Link replaceState href="/settings/profile">
|
<Link replaceState href="/settings/profile">
|
||||||
<Profile inert pubkey={$pubkey} />
|
<Profile inert pubkey={$user.pubkey} />
|
||||||
</Link>
|
</Link>
|
||||||
{/if}
|
|
||||||
<div class="grid grid-cols-3 gap-3 w-full">
|
<div class="grid grid-cols-3 gap-3 w-full">
|
||||||
<Link
|
<Link
|
||||||
replaceState
|
replaceState
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,7 @@
|
||||||
import {goToEvent} from "@app/routes"
|
import {goToEvent} from "@app/routes"
|
||||||
import {isEventMuted} from "@app/social"
|
import {isEventMuted} from "@app/social"
|
||||||
|
|
||||||
const {
|
type Props = {
|
||||||
event,
|
|
||||||
children,
|
|
||||||
minimal = false,
|
|
||||||
hideProfile = false,
|
|
||||||
url,
|
|
||||||
...restProps
|
|
||||||
}: {
|
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
children?: Snippet
|
children?: Snippet
|
||||||
minimal?: boolean
|
minimal?: boolean
|
||||||
|
|
@ -26,12 +19,16 @@
|
||||||
url?: string
|
url?: string
|
||||||
class?: string
|
class?: string
|
||||||
style?: string
|
style?: string
|
||||||
} = $props()
|
}
|
||||||
|
|
||||||
|
const {event, children, minimal = false, hideProfile = false, url, ...restProps}: Props = $props()
|
||||||
|
|
||||||
const ignoreMute = () => {
|
const ignoreMute = () => {
|
||||||
muted = false
|
muted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToNote = () => goToEvent(event)
|
||||||
|
|
||||||
let muted = $state($isEventMuted(event))
|
let muted = $state($isEventMuted(event))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -55,7 +52,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
class={cx("text-sm opacity-75 @max-sm:hidden", {"text-xs": minimal})}
|
class={cx("text-sm opacity-75 @max-sm:hidden", {"text-xs": minimal})}
|
||||||
onclick={() => goToEvent(event)}>
|
onclick={goToNote}>
|
||||||
{formatTimestamp(event.created_at)}
|
{formatTimestamp(event.created_at)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
|
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL, PIN, PINBOARD} from "@welshman/util"
|
||||||
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
||||||
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
||||||
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
import NoteContentPin from "@app/components/NoteContentPin.svelte"
|
import NoteContentPin from "@app/components/NoteContentPin.svelte"
|
||||||
import NoteContentBoard from "@app/components/NoteContentBoard.svelte"
|
import NoteContentBoard from "@app/components/NoteContentBoard.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import {PIN, BOARD} from "@app/pinboards"
|
|
||||||
|
|
||||||
const props: ComponentProps<typeof Content> = $props()
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -27,7 +26,7 @@
|
||||||
<NoteContentPoll {...props} />
|
<NoteContentPoll {...props} />
|
||||||
{:else if props.event.kind === PIN}
|
{:else if props.event.kind === PIN}
|
||||||
<NoteContentPin {...props} />
|
<NoteContentPin {...props} />
|
||||||
{:else if props.event.kind === BOARD}
|
{:else if props.event.kind === PINBOARD}
|
||||||
<NoteContentBoard {...props} />
|
<NoteContentBoard {...props} />
|
||||||
{:else}
|
{:else}
|
||||||
<Content {...props} />
|
<Content {...props} />
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
|
import {Pinboard} from "@welshman/domain"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import {readBoard} from "@app/pinboards"
|
import {reader} from "@app/core"
|
||||||
|
|
||||||
const props: ComponentProps<typeof Content> = $props()
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
|
|
||||||
const board = readBoard(props.event)
|
const board = reader(Pinboard)(props.event)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<strong class="text-lg">{board.title || "Untitled shelf"}</strong>
|
<strong class="text-lg">{board?.title() || "Untitled shelf"}</strong>
|
||||||
{#if board.description}
|
{#if board?.description()}
|
||||||
<span class="text-sm opacity-70">{board.description}</span>
|
<span class="text-sm opacity-70">{board.description()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {getTag, getTagValue, getTagValues} from "@welshman/util"
|
import {Classified} from "@welshman/domain"
|
||||||
import CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
|
import CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
|
||||||
|
import {reader} from "@app/core"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
|
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof Content> = $props()
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
|
|
||||||
const title = getTagValue("title", props.event.tags)
|
const classified = reader(Classified)(props.event)
|
||||||
const images = getTagValues("image", props.event.tags)
|
|
||||||
const [_, price = 0, currency = "SAT"] = getTag("price", props.event.tags) || []
|
const title = classified.title()
|
||||||
|
const images = classified.images() ?? []
|
||||||
|
const price = classified.price()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#if title}
|
{#if title}
|
||||||
<p class="text-xl">
|
<p class="text-xl">
|
||||||
{title} —
|
{title} —
|
||||||
<CurrencySymbol code={currency} />{price}
|
<CurrencySymbol code={price?.currency ?? "SAT"} />{price?.amount ?? 0}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if props.event.content}
|
{#if props.event.content}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {getTagValue} from "@welshman/util"
|
import {ZapGoal} from "@welshman/domain"
|
||||||
|
import {reader} from "@app/core"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import GoalSummary from "@app/components/GoalSummary.svelte"
|
import GoalSummary from "@app/components/GoalSummary.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof Content> = $props()
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
|
|
||||||
const content = getTagValue("summary", props.event.tags)
|
const goal = reader(ZapGoal)(props.event)
|
||||||
const fakeEvent = {content, tags: props.event.tags}
|
|
||||||
|
const title = goal.title()
|
||||||
|
const summaryEvent = $derived({content: goal.summary(), tags: props.event.tags})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<p class="text-2xl">{props.event.content}</p>
|
<p class="text-2xl">{title}</p>
|
||||||
<Content {...props} event={fakeEvent} expandMode="inline" minLength={50} maxLength={300} />
|
<Content {...props} event={summaryEvent} expandMode="inline" minLength={50} maxLength={300} />
|
||||||
<GoalSummary url={props.url} event={props.event} />
|
<GoalSummary url={props.url} event={props.event} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {getTagValue} from "@welshman/util"
|
import {Classified} from "@welshman/domain"
|
||||||
|
import {reader} from "@app/core"
|
||||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||||
|
|
||||||
const title = getTagValue("title", props.event.tags)
|
const classified = reader(Classified)(props.event)
|
||||||
|
|
||||||
|
const title = classified.title()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if title}
|
{#if title}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,43 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
|
import {derived} from "svelte/store"
|
||||||
import {sum} from "@welshman/lib"
|
import {sum} from "@welshman/lib"
|
||||||
import type {Zap, TrustedEvent} from "@welshman/util"
|
import {ZAP_RECEIPT, fromMsats} 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 ContentMinimal from "@app/components/ContentMinimal.svelte"
|
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||||
|
import {app, reader} from "@app/core"
|
||||||
|
import {deriveEvents} from "@app/repository"
|
||||||
|
|
||||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||||
|
|
||||||
const content = getTagValue("summary", props.event.tags)
|
const goal = reader(ZapGoal)(props.event)
|
||||||
const fakeEvent = {content, tags: props.event.tags}
|
|
||||||
|
|
||||||
const zaps = deriveArray(
|
const title = goal.title()
|
||||||
deriveItemsByKey<Zap>({
|
const summaryEvent = $derived({content: goal.summary(), tags: props.event.tags})
|
||||||
repository,
|
|
||||||
getKey: zap => zap.response.id,
|
const receipts = deriveEvents([{kinds: [ZAP_RECEIPT], "#e": [props.event.id]}])
|
||||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [props.event.id]}],
|
|
||||||
eventToItem: (response: TrustedEvent) => getValidZap(response, props.event),
|
const zaps = derived<typeof receipts, Zap[]>(
|
||||||
}),
|
receipts,
|
||||||
|
($receipts, set) => $app.use(Zappers).validZapReceipts($receipts, props.event).$.subscribe(set),
|
||||||
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const goalAmount = parseInt(getTagValue("amount", props.event.tags) || "0")
|
const goalAmount = goal.amount() ?? 0
|
||||||
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
|
const closedAt = goal.closedAt()
|
||||||
|
const counted = $derived($zaps.filter(zap => !closedAt || zap.response.created_at <= closedAt))
|
||||||
|
const zapAmount = $derived(fromMsats(sum(counted.map(zap => zap.invoiceAmount))))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-sm">{props.event.content}</span>
|
<span class="text-sm">{title}</span>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Icon icon={Bolt} size={4} />
|
<Icon icon={Bolt} size={4} />
|
||||||
{zapAmount}/{goalAmount} sats funded
|
{zapAmount}/{goalAmount} sats funded
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ContentMinimal {...props} event={fakeEvent} />
|
<ContentMinimal {...props} event={summaryEvent} />
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {derived} from "svelte/store"
|
|
||||||
import {POLL_RESPONSE} from "@welshman/util"
|
import {POLL_RESPONSE} from "@welshman/util"
|
||||||
|
import {Poll} from "@welshman/domain"
|
||||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||||
|
import {reader} from "@app/core"
|
||||||
import {deriveEvents} from "@app/repository"
|
import {deriveEvents} from "@app/repository"
|
||||||
import {getPollResults} from "@app/polls"
|
|
||||||
|
|
||||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||||
|
|
||||||
const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [props.event.id]}])
|
const responses = deriveEvents([{kinds: [POLL_RESPONSE], "#e": [props.event.id]}])
|
||||||
|
|
||||||
const results = derived(responses, $responses => getPollResults(props.event, $responses))
|
const voters = $derived(reader(Poll)(props.event).results($responses).voters)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-0">
|
<div class="flex flex-col gap-0">
|
||||||
<ContentMinimal {...props} />
|
<ContentMinimal {...props} />
|
||||||
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
|
<span class="text-xs opacity-50">{voters} voter{voters === 1 ? "" : "s"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {getTagValue} from "@welshman/util"
|
import {tagSpec, tagValue} from "@welshman/util"
|
||||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||||
|
|
||||||
const title = getTagValue("title", props.event.tags)
|
const title = tagValue(tagSpec("title"), props.event.tags)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if title}
|
{#if title}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,42 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import {Pin} from "@welshman/domain"
|
||||||
import {Address} from "@welshman/util"
|
|
||||||
import Badge from "@lib/components/Badge.svelte"
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import PinContentEvent from "@app/components/PinContentEvent.svelte"
|
import PinContentEvent from "@app/components/PinContentEvent.svelte"
|
||||||
import {readPin} from "@app/pinboards"
|
import {reader} from "@app/core"
|
||||||
|
import {pinToReference} from "@app/pinboards"
|
||||||
|
|
||||||
const props: ComponentProps<typeof Content> = $props()
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
|
|
||||||
const pin = readPin(props.event)
|
const loadPin = reader(Pin)(props.event)
|
||||||
|
|
||||||
// Encode nostr references as nostr: entities (i tags are already urls) so
|
|
||||||
// Content parses them into rich embeds rather than rendering raw ids/coords.
|
|
||||||
const content = $derived.by(() => {
|
|
||||||
const [type, data = ""] = pin.value
|
|
||||||
|
|
||||||
if (type === "e") return "nostr:" + nip19.neventEncode({id: data})
|
|
||||||
|
|
||||||
if (type === "p") return "nostr:" + nip19.nprofileEncode({pubkey: data})
|
|
||||||
|
|
||||||
if (type === "a") {
|
|
||||||
const {kind, pubkey, identifier} = Address.from(data)
|
|
||||||
|
|
||||||
return "nostr:" + nip19.naddrEncode({kind, pubkey, identifier})
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-2">
|
{#await loadPin then pin}
|
||||||
{#if pin.title}
|
{@const reference = pin.reference()}
|
||||||
<strong class="truncate min-w-0">{pin.title}</strong>
|
<div class="flex h-full flex-col gap-2">
|
||||||
|
{#if pin.title()}
|
||||||
|
<strong class="truncate min-w-0">{pin.title()}</strong>
|
||||||
{/if}
|
{/if}
|
||||||
{#if pin.description}
|
{#if pin.content()}
|
||||||
<Content event={{content: pin.description, tags: []}} />
|
<Content event={{content: pin.content(), tags: []}} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if pin.value[0] === "e" || pin.value[0] === "a"}
|
{#if reference?.type === "event"}
|
||||||
<PinContentEvent value={pin.value[1]} relays={pin.value[2] ? [pin.value[2]] : []} />
|
<PinContentEvent value={reference.id} relays={reference.relay ? [reference.relay] : []} />
|
||||||
|
{:else if reference?.type === "address"}
|
||||||
|
<PinContentEvent
|
||||||
|
value={reference.address}
|
||||||
|
relays={reference.relay ? [reference.relay] : []} />
|
||||||
{:else}
|
{:else}
|
||||||
<Content event={{content, tags: []}} />
|
<!-- External references are urls or bech32 entities, both of which Content parses. -->
|
||||||
|
<Content event={{content: pinToReference(pin), tags: []}} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if pin.topics.length > 0}
|
{#if pin.topics().length > 0}
|
||||||
<div class="mt-auto flex flex-wrap gap-1">
|
<div class="mt-auto flex flex-wrap gap-1">
|
||||||
{#each pin.topics as topic (topic)}
|
{#each pin.topics() as topic (topic)}
|
||||||
<Badge>#{topic}</Badge>
|
<Badge>#{topic}</Badge>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/await}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {getTagValue} from "@welshman/util"
|
import {tagSpec, tagValue} from "@welshman/util"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof Content> = $props()
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
|
|
||||||
const title = getTagValue("title", props.event.tags)
|
const title = tagValue(tagSpec("title"), props.event.tags)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,15 @@
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {Router} from "@welshman/router"
|
import {seen} from "@welshman/util"
|
||||||
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 NoteContent from "@app/components/NoteContent.svelte"
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
|
import {publishReaction, retractReaction} from "@app/reactions"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
import {publishDelete} from "@app/deletes"
|
import {router} from "@app/core"
|
||||||
import {publishReaction} from "@app/reactions"
|
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
|
|
@ -21,23 +20,15 @@
|
||||||
|
|
||||||
const {url, event, children}: Props = $props()
|
const {url, event, children}: Props = $props()
|
||||||
|
|
||||||
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
const getRelays = () => (url ? [url] : $router.resolver.relays([seen(event)]))
|
||||||
|
|
||||||
const shouldProtect = url ? canEnforceNip70(url) : false
|
const deleteReaction = async (reaction: TrustedEvent) =>
|
||||||
|
retractReaction(reaction, {url, urls: await getRelays()})
|
||||||
|
|
||||||
const deleteReaction = async (event: TrustedEvent) =>
|
const createReaction = async (values: EventContent) =>
|
||||||
publishDelete({relays, event, protect: await shouldProtect})
|
publishReaction(event, values, {url, urls: await getRelays()})
|
||||||
|
|
||||||
const createReaction = async (template: EventContent) =>
|
const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []})
|
||||||
publishReaction({...template, event, relays, protect: await shouldProtect})
|
|
||||||
|
|
||||||
const onEmoji = async (emoji: NativeEmoji) =>
|
|
||||||
publishReaction({
|
|
||||||
event,
|
|
||||||
relays,
|
|
||||||
content: emoji.unicode,
|
|
||||||
protect: await shouldProtect,
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<NoteCard {event} {url} class="cv card card-interactive">
|
<NoteCard {event} {url} class="cv card card-interactive">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Client} from "@pomade/core"
|
import {Client} from "@pomade/core"
|
||||||
import type {SessionPomade} from "@welshman/app"
|
|
||||||
import {session} from "@welshman/app"
|
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
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"
|
||||||
|
|
@ -16,6 +14,7 @@
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
|
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
|
||||||
import PasswordResetConfirm from "@app/components/PasswordResetConfirm.svelte"
|
import PasswordResetConfirm from "@app/components/PasswordResetConfirm.svelte"
|
||||||
|
import {session} from "@app/core"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {getPomadeClient} from "@app/pomade"
|
import {getPomadeClient} from "@app/pomade"
|
||||||
|
|
@ -26,7 +25,7 @@
|
||||||
|
|
||||||
const {peersByPrefix}: Props = $props()
|
const {peersByPrefix}: Props = $props()
|
||||||
|
|
||||||
const {email} = $session as SessionPomade
|
const {email} = $session?.data as {email: string}
|
||||||
|
|
||||||
const confirmRecovery = async () => {
|
const confirmRecovery = async () => {
|
||||||
const request = await Client.recoverWithChallenge(email, peersByPrefix, otps)
|
const request = await Client.recoverWithChallenge(email, peersByPrefix, otps)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Client} from "@pomade/core"
|
import {Client} from "@pomade/core"
|
||||||
import {session} from "@welshman/app"
|
|
||||||
import type {SessionPomade} from "@welshman/app"
|
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import Key from "@assets/icons/key.svg?dataurl"
|
import Key from "@assets/icons/key.svg?dataurl"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
|
@ -19,6 +17,8 @@
|
||||||
import {loginWithPomade, deleteCurrentPomadeSession} from "@app/pomade"
|
import {loginWithPomade, deleteCurrentPomadeSession} from "@app/pomade"
|
||||||
import {clearModals} from "@app/modal"
|
import {clearModals} from "@app/modal"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
import {session} from "@app/core"
|
||||||
|
import {requirePomadeSession} from "@app/pomade"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
userSecret: string
|
userSecret: string
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
|
|
||||||
const {userSecret}: Props = $props()
|
const {userSecret}: Props = $props()
|
||||||
|
|
||||||
const {email} = $session as SessionPomade
|
const {email} = requirePomadeSession($session).data
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {Pin} from "@welshman/domain"
|
||||||
|
import {publishAsRelay} from "@welshman/app"
|
||||||
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
|
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
|
||||||
import {createPin, referenceToPin} from "@app/pinboards"
|
import {command, writer} from "@app/core"
|
||||||
|
import {setPinReference} from "@app/pinboards"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -11,11 +14,17 @@
|
||||||
const {url, address, reference}: Props = $props()
|
const {url, address, reference}: Props = $props()
|
||||||
|
|
||||||
const submit = async ({title, topics, value, content}: PinFormValues) => {
|
const submit = async ({title, topics, value, content}: PinFormValues) => {
|
||||||
const params = referenceToPin(value)
|
const eventWriter = writer(Pin).setIdentifier().addBoard(address)
|
||||||
|
|
||||||
if (!params) return "Please enter a valid URL or nostr link."
|
if (!setPinReference(eventWriter, value)) {
|
||||||
|
return "Please enter a valid URL or nostr link."
|
||||||
|
}
|
||||||
|
|
||||||
return createPin(url, {...params, boards: [address], title, topics, description: content})
|
eventWriter.setTitle(title).setTopics(topics).setContent(content)
|
||||||
|
|
||||||
|
const thunk = await command(eventWriter).then(publishAsRelay(url))
|
||||||
|
|
||||||
|
return thunk.waitForError()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,30 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {Pin} from "@welshman/domain"
|
||||||
|
import {publishAsRelay} from "@welshman/app"
|
||||||
|
import type {PinReader} from "@welshman/domain"
|
||||||
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
|
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
|
||||||
import {createPin, referenceToPin, pinToReference, type PublishedPin} from "@app/pinboards"
|
import {command, writer} from "@app/core"
|
||||||
|
import {pinToReference, setPinReference} from "@app/pinboards"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
pin: PublishedPin
|
pin: PinReader
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, pin}: Props = $props()
|
const {url, pin}: Props = $props()
|
||||||
|
|
||||||
const submit = async ({title, topics, value, content}: PinFormValues) => {
|
const submit = async ({title, topics, value, content}: PinFormValues) => {
|
||||||
const params = referenceToPin(value)
|
const eventWriter = writer(Pin, pin)
|
||||||
|
|
||||||
if (!params) return "Please enter a valid URL or nostr link."
|
if (!setPinReference(eventWriter, value)) {
|
||||||
|
return "Please enter a valid URL or nostr link."
|
||||||
|
}
|
||||||
|
|
||||||
// Reuse the pin's identifier and boards so its addressable event is replaced.
|
eventWriter.setTitle(title).setTopics(topics).setContent(content)
|
||||||
return createPin(url, {
|
|
||||||
...params,
|
const thunk = await command(eventWriter).then(publishAsRelay(url))
|
||||||
identifier: pin.identifier,
|
|
||||||
boards: pin.boards,
|
return thunk.waitForError()
|
||||||
title,
|
|
||||||
topics,
|
|
||||||
description: content,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -32,9 +34,9 @@
|
||||||
action="Save changes"
|
action="Save changes"
|
||||||
successMessage="Link updated!"
|
successMessage="Link updated!"
|
||||||
values={{
|
values={{
|
||||||
title: pin.title,
|
title: pin.title(),
|
||||||
topics: pin.topics,
|
topics: pin.topics(),
|
||||||
value: pinToReference(pin),
|
value: pinToReference(pin),
|
||||||
content: pin.description,
|
content: pin.content(),
|
||||||
}}
|
}}
|
||||||
{submit} />
|
{submit} />
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,17 @@
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {removeUndefined} from "@welshman/lib"
|
import {removeUndefined} from "@welshman/lib"
|
||||||
import {Address} from "@welshman/util"
|
import {Address} from "@welshman/util"
|
||||||
|
import type {PinReader} from "@welshman/domain"
|
||||||
import Badge from "@lib/components/Badge.svelte"
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import PinContentEvent from "@app/components/PinContentEvent.svelte"
|
import PinContentEvent from "@app/components/PinContentEvent.svelte"
|
||||||
import PinMenu from "@app/components/PinMenu.svelte"
|
import PinMenu from "@app/components/PinMenu.svelte"
|
||||||
import {type PublishedPin} from "@app/pinboards"
|
import {pinToReference} from "@app/pinboards"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
pin: PublishedPin
|
pin: PinReader
|
||||||
minimal?: boolean
|
minimal?: boolean
|
||||||
class?: string
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
@ -20,20 +21,20 @@
|
||||||
|
|
||||||
// Encode nostr references as nostr: entities (i tags are already urls) so
|
// Encode nostr references as nostr: entities (i tags are already urls) so
|
||||||
// Content parses them into rich embeds rather than rendering raw ids/coords.
|
// Content parses them into rich embeds rather than rendering raw ids/coords.
|
||||||
|
const reference = $derived(pin.reference())
|
||||||
|
|
||||||
const content = $derived.by(() => {
|
const content = $derived.by(() => {
|
||||||
const [type, data = ""] = pin.value
|
if (reference?.type === "event") {
|
||||||
|
return "nostr:" + nip19.neventEncode({id: reference.id, relays: [url]})
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "e") return "nostr:" + nip19.neventEncode({id: data, relays: [url]})
|
if (reference?.type === "address") {
|
||||||
|
const {kind, pubkey, identifier} = Address.from(reference.address)
|
||||||
if (type === "p") return "nostr:" + nip19.nprofileEncode({pubkey: data, relays: [url]})
|
|
||||||
|
|
||||||
if (type === "a") {
|
|
||||||
const {kind, pubkey, identifier} = Address.from(data)
|
|
||||||
|
|
||||||
return "nostr:" + nip19.naddrEncode({kind, pubkey, identifier, relays: [url]})
|
return "nostr:" + nip19.naddrEncode({kind, pubkey, identifier, relays: [url]})
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return pinToReference(pin)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -48,20 +49,23 @@
|
||||||
componentProps={{url, pin}} />
|
componentProps={{url, pin}} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if pin.title}
|
{#if pin.title()}
|
||||||
<strong class="truncate min-w-0 pr-8">{pin.title}</strong>
|
<strong class="truncate min-w-0 pr-8">{pin.title()}</strong>
|
||||||
{/if}
|
{/if}
|
||||||
{#if pin.description}
|
{#if pin.content()}
|
||||||
<Content event={{content: pin.description, tags: []}} {url} />
|
<Content event={{content: pin.content(), tags: []}} {url} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if pin.value[0] === "e" || pin.value[0] === "a"}
|
{#if reference?.type === "event" || reference?.type === "address"}
|
||||||
<PinContentEvent {url} value={pin.value[1]} relays={removeUndefined([pin.value[2], url])} />
|
<PinContentEvent
|
||||||
|
{url}
|
||||||
|
value={reference.type === "event" ? reference.id : reference.address}
|
||||||
|
relays={removeUndefined([reference.relay, url])} />
|
||||||
{:else}
|
{:else}
|
||||||
<Content event={{content, tags: []}} {url} />
|
<Content event={{content, tags: []}} {url} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if !minimal && pin.topics.length > 0}
|
{#if !minimal && pin.topics().length > 0}
|
||||||
<div class="mt-auto flex flex-wrap gap-1">
|
<div class="mt-auto flex flex-wrap gap-1">
|
||||||
{#each pin.topics as topic (topic)}
|
{#each pin.topics() as topic (topic)}
|
||||||
<Badge>#{topic}</Badge>
|
<Badge>#{topic}</Badge>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,31 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
|
import type {PinReader} from "@welshman/domain"
|
||||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||||
|
import {errorMessage} from "@lib/util"
|
||||||
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 Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import PinEdit from "@app/components/PinEdit.svelte"
|
import PinEdit from "@app/components/PinEdit.svelte"
|
||||||
import {deriveSupportedMethods} from "@app/relays"
|
import {deletes} from "@app/core"
|
||||||
import {deletePin, type PublishedPin} from "@app/pinboards"
|
import {deriveUserIsSpaceAdmin} from "@app/management"
|
||||||
import {shareEventToChat} from "@app/share"
|
import {shareEventToChat} from "@app/share"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
pin: PublishedPin
|
pin: PinReader
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, pin, onClick}: Props = $props()
|
const {url, pin, onClick}: Props = $props()
|
||||||
|
|
||||||
const supportedMethods = deriveSupportedMethods(url)
|
const canManage = deriveUserIsSpaceAdmin(url)
|
||||||
const canManage = $derived($supportedMethods.some(m => (m as string) === "signevent"))
|
|
||||||
|
|
||||||
const showInfo = () => pushModal(EventInfo, {url, event: pin.event})
|
const showInfo = () => pushModal(EventInfo, {url, event: pin.event})
|
||||||
|
|
||||||
|
|
@ -32,19 +33,27 @@
|
||||||
|
|
||||||
const edit = () => pushModal(PinEdit, {url, pin})
|
const edit = () => pushModal(PinEdit, {url, pin})
|
||||||
|
|
||||||
const confirmDelete = () =>
|
const deletePin = async () => {
|
||||||
pushModal(Confirm, {
|
try {
|
||||||
title: "Delete Link",
|
const command = await $deletes.deleteEvent(pin.event)
|
||||||
message: "Delete this link?",
|
const error = await command.publishAsRelay(url).then(thunk => thunk.waitForError())
|
||||||
confirm: async () => {
|
|
||||||
const error = await deletePin(url, pin.id)
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
} else {
|
} else {
|
||||||
pushToast({message: "Link deleted!"})
|
pushToast({message: "Link deleted!"})
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
pushToast({theme: "error", message: errorMessage(e)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = () =>
|
||||||
|
pushModal(Confirm, {
|
||||||
|
title: "Delete Link",
|
||||||
|
message: "Delete this link?",
|
||||||
|
confirm: deletePin,
|
||||||
})
|
})
|
||||||
|
|
||||||
let ul: Element
|
let ul: Element
|
||||||
|
|
@ -67,7 +76,7 @@
|
||||||
Share to chat
|
Share to chat
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
{#if canManage}
|
{#if $canManage}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={edit}>
|
<Button onclick={edit}>
|
||||||
<Icon icon={Pen} />
|
<Icon icon={Pen} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
|
import {getAddress} from "@welshman/util"
|
||||||
|
import {publishAsRelay} from "@welshman/app"
|
||||||
|
import {Pinboard} from "@welshman/domain"
|
||||||
|
import type {PinboardReader} from "@welshman/domain"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -10,23 +15,17 @@
|
||||||
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 ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import {goto} from "$app/navigation"
|
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
import {editBoard, type Board} from "@app/pinboards"
|
import {command, writer} from "@app/core"
|
||||||
import {makeLibraryPath} from "@app/routes"
|
import {makeLibraryPath} from "@app/routes"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
board: Board
|
board?: PinboardReader
|
||||||
isNew?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, board, isNew = false}: Props = $props()
|
const {url, board}: Props = $props()
|
||||||
|
|
||||||
let title = $state(board.title)
|
|
||||||
let description = $state(board.description)
|
|
||||||
let loading = $state(false)
|
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
|
|
@ -34,30 +33,41 @@
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const error = await editBoard(url, {...board, title, description})
|
const eventWriter = writer(Pinboard, board).setTitle(title).setDescription(description)
|
||||||
|
|
||||||
|
if (!board) {
|
||||||
|
eventWriter.setIdentifier()
|
||||||
|
}
|
||||||
|
|
||||||
|
const thunk = await command(eventWriter).then(publishAsRelay(url))
|
||||||
|
const error = await thunk.waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
} else {
|
} else if (board) {
|
||||||
pushToast({message: isNew ? "Shelf created!" : "Shelf updated!"})
|
pushToast({message: "Shelf updated!"})
|
||||||
|
|
||||||
// Navigate to a freshly created board; otherwise just close the modal.
|
|
||||||
if (isNew) {
|
|
||||||
goto(makeLibraryPath(url, board.address))
|
|
||||||
} else {
|
|
||||||
back()
|
back()
|
||||||
|
} else {
|
||||||
|
pushToast({message: "Shelf created!"})
|
||||||
|
goto(makeLibraryPath(url, getAddress(thunk.event)))
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
pushToast({theme: "error", message: "This space refused to save the shelf."})
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let title = $state(board?.title() ?? "")
|
||||||
|
let description = $state(board?.description() ?? "")
|
||||||
|
let loading = $state(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal>
|
<Modal>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>{isNew ? "Create Shelf" : "Edit Shelf"}</ModalTitle>
|
<ModalTitle>{board ? "Edit Shelf" : "Create Shelf"}</ModalTitle>
|
||||||
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
|
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<Field>
|
<Field>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {Pins} from "@welshman/app"
|
||||||
|
import type {PinboardReader} from "@welshman/domain"
|
||||||
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"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||||
import PinItem from "@app/components/PinItem.svelte"
|
import PinItem from "@app/components/PinItem.svelte"
|
||||||
import BoardMenu from "@app/components/BoardMenu.svelte"
|
import BoardMenu from "@app/components/BoardMenu.svelte"
|
||||||
import {derivePins, type PublishedBoard} from "@app/pinboards"
|
import {app} from "@app/core"
|
||||||
import {makeLibraryPath} from "@app/routes"
|
import {makeLibraryPath} from "@app/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
board: PublishedBoard
|
board: PinboardReader
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, board}: Props = $props()
|
const {url, board}: Props = $props()
|
||||||
|
|
||||||
const pins = derivePins(url, board.address)
|
const pins = $app.use(Pins).forBoard(board.address()).$
|
||||||
const detailPath = makeLibraryPath(url, board.address)
|
const detailPath = makeLibraryPath(url, board.address())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card relative flex flex-col gap-2">
|
<div class="card relative flex flex-col gap-2">
|
||||||
|
|
@ -29,12 +31,12 @@
|
||||||
componentProps={{url, board}} />
|
componentProps={{url, board}} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col pr-8">
|
<div class="flex flex-col pr-8">
|
||||||
<strong class="text-lg">{board.title || "Untitled shelf"}</strong>
|
<strong class="text-lg">{board.title() || "Untitled shelf"}</strong>
|
||||||
{#if board.description}
|
{#if board.description()}
|
||||||
<span class="text-sm opacity-70">{board.description}</span>
|
<span class="text-sm opacity-70">{board.description()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#each $pins.slice(0, 1) as pin (pin.id)}
|
{#each $pins.slice(0, 1) as pin (pin.id())}
|
||||||
<PinItem minimal {url} {pin} />
|
<PinItem minimal {url} {pin} />
|
||||||
{/each}
|
{/each}
|
||||||
<Link href={detailPath} class="button button-primary">
|
<Link href={detailPath} class="button button-primary">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import type {PinboardReader} from "@welshman/domain"
|
||||||
|
import {Pinboards} from "@welshman/app"
|
||||||
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"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -11,7 +13,8 @@
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
import PinAdd from "@app/components/PinAdd.svelte"
|
import PinAdd from "@app/components/PinAdd.svelte"
|
||||||
import {deriveBoards, eventToReference, type PublishedBoard} from "@app/pinboards"
|
import {app, relays} from "@app/core"
|
||||||
|
import {eventToReference} from "@app/pinboards"
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
|
@ -22,11 +25,14 @@
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
const boards = deriveBoards(url)
|
const relay = $relays.one(url)
|
||||||
|
|
||||||
|
const boards = $derived($app.use(Pinboards).forAuthor($relay?.self ?? "").$)
|
||||||
|
|
||||||
const reference = eventToReference(event)
|
const reference = eventToReference(event)
|
||||||
|
|
||||||
const selectBoard = (board: PublishedBoard) =>
|
const selectBoard = (board: PinboardReader) =>
|
||||||
pushModal(PinAdd, {url, address: board.address, reference})
|
pushModal(PinAdd, {url, address: board.address(), reference})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal class="flex flex-col gap-2">
|
<Modal class="flex flex-col gap-2">
|
||||||
|
|
@ -43,14 +49,14 @@
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each $boards as board (board.address)}
|
{#each $boards as board (board.address())}
|
||||||
<Button
|
<Button
|
||||||
class="card card-sm card-interactive flex flex-row items-center justify-between gap-3"
|
class="card card-sm card-interactive flex flex-row items-center justify-between gap-3"
|
||||||
onclick={() => selectBoard(board)}>
|
onclick={() => selectBoard(board)}>
|
||||||
<span class="flex min-w-0 flex-col">
|
<span class="flex min-w-0 flex-col">
|
||||||
<strong class="truncate min-w-0">{board.title || "Untitled shelf"}</strong>
|
<strong class="truncate min-w-0">{board.title() || "Untitled shelf"}</strong>
|
||||||
{#if board.description}
|
{#if board.description()}
|
||||||
<span class="truncate min-w-0 text-sm opacity-70">{board.description}</span>
|
<span class="truncate min-w-0 text-sm opacity-70">{board.description()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<Icon size={4} icon={AltArrowRight} class="shrink-0" />
|
<Icon size={4} icon={AltArrowRight} class="shrink-0" />
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {getTagValue} from "@welshman/util"
|
import {tagSpec, tagValue} from "@welshman/util"
|
||||||
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 {publishDelete} from "@app/deletes"
|
|
||||||
import {publishReaction} from "@app/reactions"
|
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
import {makePollPath, makeSpacePath} from "@app/routes"
|
import {makePollPath, makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -21,15 +19,12 @@
|
||||||
|
|
||||||
const {url, event, showRoom, showActivity}: Props = $props()
|
const {url, event, showRoom, showActivity}: Props = $props()
|
||||||
|
|
||||||
const h = getTagValue("h", event.tags)
|
const h = tagValue(tagSpec("h"), event.tags)
|
||||||
const path = makePollPath(url, event.id)
|
const path = makePollPath(url, event.id)
|
||||||
const shouldProtect = canEnforceNip70(url)
|
|
||||||
|
|
||||||
const deleteReaction = async (event: TrustedEvent) =>
|
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
|
||||||
|
|
||||||
const createReaction = async (template: EventContent) =>
|
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex grow flex-wrap justify-end gap-2">
|
<div class="flex grow flex-wrap justify-end gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
|
import {insertAt, now, randomId, removeAt, removeUndefined, spec} from "@welshman/lib"
|
||||||
import {makeEvent, POLL} from "@welshman/util"
|
import {publish} from "@welshman/app"
|
||||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
import {Poll} from "@welshman/domain"
|
||||||
|
import type {PollType} from "@welshman/domain"
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
|
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
|
||||||
|
|
@ -19,11 +20,10 @@
|
||||||
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 {command, relays, writer} from "@app/core"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
import {publishRoomQuote} from "@app/rooms"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
|
||||||
import {DraftKey} from "@app/drafts"
|
import {DraftKey} from "@app/drafts"
|
||||||
import type {PollType} from "@app/polls"
|
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -47,8 +47,6 @@
|
||||||
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
|
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
|
||||||
const initialValues = draftKey.get()
|
const initialValues = draftKey.get()
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const addOption = () => {
|
const addOption = () => {
|
||||||
|
|
@ -68,8 +66,8 @@
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
|
const sourceIndex = options.findIndex(spec({id: draggedOptionId}))
|
||||||
const targetIndex = options.findIndex(option => option.id === targetId)
|
const targetIndex = options.findIndex(spec({id: targetId}))
|
||||||
|
|
||||||
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
|
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
|
||||||
return
|
return
|
||||||
|
|
@ -119,35 +117,32 @@
|
||||||
return pushToast({theme: "error", message: "End time must be in the future."})
|
return pushToast({theme: "error", message: "End time must be in the future."})
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags: string[][] = [
|
|
||||||
...nonEmptyOptions.map(option => ["option", randomId(), option]),
|
|
||||||
["polltype", pollType],
|
|
||||||
["relay", url],
|
|
||||||
]
|
|
||||||
|
|
||||||
if (endsAt) {
|
|
||||||
tags.push(["endsAt", String(endsAt)])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (h) {
|
|
||||||
tags.push(["h", h])
|
|
||||||
}
|
|
||||||
|
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const protect = await shouldProtect
|
const protect = await $relays.hasNip(url, 70)
|
||||||
|
const eventWriter = writer(Poll)
|
||||||
|
.setTitle(title.trim())
|
||||||
|
.setPollType(pollType)
|
||||||
|
.setUrls([url])
|
||||||
|
.setProtected(protect)
|
||||||
|
|
||||||
if (protect) {
|
for (const option of nonEmptyOptions) {
|
||||||
tags.push(PROTECTED)
|
eventWriter.addOption(option)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pollThunk = publishThunk({
|
if (endsAt) {
|
||||||
relays: [url],
|
eventWriter.setEndsAt(endsAt)
|
||||||
event: makeEvent(POLL, {content: title.trim(), tags}),
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const error = await waitForThunkError(pollThunk)
|
if (h) {
|
||||||
|
eventWriter.setRoom(url, h)
|
||||||
|
} else {
|
||||||
|
eventWriter.forceRelays(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollThunk = await command(eventWriter).then(publish)
|
||||||
|
const error = await pollThunk.waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return pushToast({theme: "error", message: error})
|
return pushToast({theme: "error", message: error})
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue