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
|
||||
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
|
||||
|
||||
```bash
|
||||
npm install @welshman/app
|
||||
# or
|
||||
pnpm add @welshman/app
|
||||
yarn add @welshman/app
|
||||
npm i @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. |
|
||||
| `tracker` | Singleton `Tracker`; maps event IDs to the relays they were seen on |
|
||||
| `app.user` | the signed-in `User`, or `undefined` |
|
||||
| `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 |
|
||||
| `session` | `Readable<Session \| undefined>` — derived from `pubkey` + `sessions` |
|
||||
| `sessions` | `Writable<Record<string, Session>>` — all loaded sessions |
|
||||
| `signer` | `Readable<ISigner \| undefined>` — signer for the active session |
|
||||
| `signerLog` | `WritableWithGetter<SignerLogEntry[]>` — writable store that the session layer appends signer-operation entries to (useful for UI feedback during remote signing) |
|
||||
| `SessionMethod` | Enum: `Nip01`, `Nip07`, `Nip46`, `Nip55`, `Pomade`, `Pubkey`, `Anonymous` |
|
||||
| `MapPlugin<T>` | a plain keyed map of non-event data (relay stats, NIP-11 info) |
|
||||
| `LoadableMapPlugin<T>` | a `MapPlugin` that knows how to `fetch(key)` from the network |
|
||||
| `DerivedPlugin<T>` | a keyed collection **derived from the repository** — the repository is the source of truth, never a duplicated map |
|
||||
| `RelayScopedDerivedPlugin<T>` | keyed by `getKey(item, url)` per relay, for data that only means something relative to a relay |
|
||||
| `RelaySignedDerivedPlugin<T>` | the same, but only accepts events authored by the relay's NIP-11 `self` pubkey (NIP-29 room state, relay membership/roles) |
|
||||
|
||||
**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
|
||||
loginWithNip01(secret: string): void
|
||||
loginWithNip07(pubkey: string): void
|
||||
loginWithNip46(pubkey, clientSecret, signerPubkey, relays): void
|
||||
loginWithNip55(pubkey: string, signerPackageName: string): void
|
||||
loginWithPomade(pubkey, email, clientOptions): void
|
||||
loginWithPubkey(pubkey: string): void // read-only
|
||||
import {User, createApp, nip07, toSession} from "@welshman/app"
|
||||
|
||||
const session = toSession(nip07, {pubkey})
|
||||
const user = await User.fromSession(session) // undefined if the handler can't build a signer
|
||||
|
||||
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
|
||||
addSession(session: Session): void // add and activate
|
||||
dropSession(pubkey: string): void // remove and clean up signer
|
||||
getSession(pubkey: string): Session | undefined
|
||||
updateSession(pubkey, fn): void
|
||||
clearSessions(): void
|
||||
nip46Perms: string // default NIP-46 permission string
|
||||
import {Domain, publish} from "@welshman/app"
|
||||
import {Note} from "@welshman/domain"
|
||||
|
||||
const writer = app.use(Domain).writer(Note).setContent("hello")
|
||||
const command = await app.use(Domain).command(writer)
|
||||
|
||||
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
|
||||
shouldUnwrap: Writable<boolean> // must be true to process incoming wraps
|
||||
wrapManager: WrapManager // tracks wrap↔rumor mappings
|
||||
unwrapAndStore(wrap: SignedEvent): Promise<void>
|
||||
await app.use(FollowLists).follow(["p", pubkey]).then(publish)
|
||||
await app.use(RelayLists).addWriteUrl(url).then(publish)
|
||||
```
|
||||
|
||||
### Publishing — Thunks
|
||||
Free-function forms exist for pipelines: `publish`, `publishToRelays(urls)`,
|
||||
`publishAsRelay(url)`, `signAsRelay(url)`.
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `publishThunk(options: ThunkOptions): Thunk` | Create, enqueue, and optimistically publish an event |
|
||||
| `Thunk` | Class representing a single in-flight publish |
|
||||
| `MergedThunk` | Aggregates multiple `Thunk`s (used by `sendWrapped`) |
|
||||
| `thunks` | `Writable<Thunk[]>` — all active thunks |
|
||||
| `abortThunk(thunk)` | Abort all constituent thunks |
|
||||
| `retryThunk(thunk)` | Re-publish with original options |
|
||||
| `mergeThunks(thunks[])` | Combine into a `MergedThunk` |
|
||||
### Thunks
|
||||
|
||||
**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
|
||||
thunkIsComplete(thunk): boolean
|
||||
getThunkError(thunk): string | undefined
|
||||
getThunkUrlsWithStatus(statuses, thunk): string[]
|
||||
getCompleteThunkUrls(thunk): string[]
|
||||
getFailedThunkUrls(thunk): string[]
|
||||
waitForThunkError(thunk): Promise<string>
|
||||
waitForThunkCompletion(thunk): Promise<void>
|
||||
const thunk = app.use(Thunks).publish({event, relays})
|
||||
|
||||
thunk.getUrlsWithStatus(PublishStatus.Success)
|
||||
thunk.getFailedUrls()
|
||||
thunk.isComplete()
|
||||
await thunk.waitForError() // "" when everything succeeded
|
||||
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
|
||||
type ThunkOptions = {
|
||||
event: EventTemplate // unsigned — will be signed lazily
|
||||
relays: string[]
|
||||
recipient?: string // if set, event is NIP-59 gift-wrapped
|
||||
delay?: number // ms to wait before sending (abort window)
|
||||
pow?: number // proof-of-work difficulty target
|
||||
timeout?: number // ms per relay before marking as timed out
|
||||
// PublishOptions callbacks: onSuccess, onFailure, onPending, onTimeout, onAborted, onComplete
|
||||
const network = app.use(Network)
|
||||
|
||||
network.load({relays, filters}) // batched, deduped, shared loader
|
||||
network.request({relays, filters, onEvent})
|
||||
network.publish({event, relays})
|
||||
network.loadUsingOutbox(pubkey, filter) // newest matching event from the author's write relays
|
||||
network.loadAllUsingOutbox(pubkey, filter) // every matching event
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
| 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
|
||||
## Web of trust
|
||||
|
||||
```typescript
|
||||
profilesByPubkey: Readable<Map<string, Profile>>
|
||||
profiles: Readable<Profile[]>
|
||||
getProfile(pubkey: string): Profile | undefined
|
||||
loadProfile(pubkey, relayHints?): Promise<void>
|
||||
forceLoadProfile(pubkey, relayHints?): Promise<void>
|
||||
deriveProfile(pubkey: string): Readable<Profile | undefined> // auto-loads
|
||||
deriveProfileDisplay(pubkey: string): Readable<string> // display name with fallback
|
||||
displayProfileByPubkey(pubkey: string): string // synchronous
|
||||
const wot = app.use(Wot)
|
||||
|
||||
wot.follows(pubkey).get()
|
||||
wot.followers(pubkey).get()
|
||||
wot.network(pubkey).$ // follows-of-follows
|
||||
wot.followsWhoFollow(pubkey, target).$
|
||||
wot.wotScore(pubkey, target).$
|
||||
```
|
||||
|
||||
### Follow / Mute / Pin Lists
|
||||
|
||||
Each list type follows the same pattern (`follow` shown, `mute` and `pin` are identical):
|
||||
## Feeds and sync
|
||||
|
||||
```typescript
|
||||
followListsByPubkey: Readable<Map<string, List>>
|
||||
followLists: Readable<List[]>
|
||||
getFollowList(pubkey): List | undefined
|
||||
loadFollowList(pubkey, relayHints?): Promise<void>
|
||||
forceLoadFollowList(pubkey, relayHints?): Promise<void>
|
||||
deriveFollowList(pubkey): Readable<List | undefined>
|
||||
app.use(Feeds).makeFeedController({feed, onEvent, ...})
|
||||
app.use(Feeds).getPubkeysForScope(scope)
|
||||
app.use(Feeds).forAuthor(pubkey).$
|
||||
|
||||
app.use(Sync).pull({relays, filters}) // negentropy: fetch what we're missing
|
||||
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
|
||||
// NIP-65 relay lists
|
||||
relayListsByPubkey / relayLists / getRelayList / loadRelayList / deriveRelayList
|
||||
// React
|
||||
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])
|
||||
|
||||
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
|
||||
import {userProfile, pubkey} from '@welshman/app'
|
||||
## Related skills
|
||||
|
||||
function ProfileHeader() {
|
||||
const profile = useReadable(userProfile)
|
||||
const [currentPubkey, setPubkey] = useWritable(pubkey)
|
||||
|
||||
return <div>{profile?.name ?? currentPubkey}</div>
|
||||
}
|
||||
```
|
||||
|
||||
### SolidJS
|
||||
|
||||
```typescript
|
||||
import {createSignal, onCleanup} from 'solid-js'
|
||||
import type {Readable, Writable} from 'svelte/store'
|
||||
|
||||
// Returns a SolidJS accessor (getter function); updates reactively.
|
||||
function useReadable<T>(store: Readable<T>): () => T {
|
||||
let initial!: T
|
||||
store.subscribe(v => { initial = v })() // sync capture then unsubscribe
|
||||
|
||||
const [value, setValue] = createSignal<T>(initial)
|
||||
onCleanup(store.subscribe(v => setValue(() => v)))
|
||||
return value
|
||||
}
|
||||
|
||||
// Returns [accessor, setter].
|
||||
function useWritable<T>(store: Writable<T>): [() => T, (value: T) => void] {
|
||||
return [useReadable(store), store.set]
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```tsx
|
||||
import {userProfile} from '@welshman/app'
|
||||
|
||||
function ProfileHeader() {
|
||||
const profile = useReadable(userProfile)
|
||||
return <div>{profile()?.name}</div>
|
||||
}
|
||||
```
|
||||
|
||||
### Vue
|
||||
|
||||
```typescript
|
||||
import {ref, onUnmounted} from 'vue'
|
||||
import type {Readable, Writable} from 'svelte/store'
|
||||
|
||||
function useReadable<T>(store: Readable<T>) {
|
||||
let initial!: T
|
||||
store.subscribe(v => { initial = v })()
|
||||
|
||||
const value = ref<T>(initial)
|
||||
const unsub = store.subscribe(v => { value.value = v as any })
|
||||
onUnmounted(unsub)
|
||||
return value // use as a readonly ref
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- **No Svelte runtime needed.** Only `svelte/store` types are imported. The store objects themselves ship with `@welshman/app`.
|
||||
- **Welshman stores with `.get()`** (created via `withGetter`) can be read synchronously without subscribing — useful in event handlers and callbacks outside any reactive context. Most writable stores in `@welshman/app` expose `.get()`.
|
||||
- **`subscribe` always fires immediately.** Unlike many observable libraries, the initial emission is synchronous, so the `useState` / `createSignal` initial value is always populated on first render.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas & Tips
|
||||
|
||||
- **Thunks sign lazily.** `publishThunk` returns synchronously and immediately writes an unsigned/hashed event to `repository` for optimistic UI. Actual signing happens in a background queue. Do not assume the event has an `id` suitable for embedding in other events until signing completes.
|
||||
- **`delay` is an undo window, not a debounce.** The thunk starts the delay timer immediately; if not aborted before `delay` ms, it signs and publishes. Calling `thunk.controller.abort()` after the delay has elapsed does nothing.
|
||||
- **`sendWrapped` uses `recipients`, not `pubkeys`.** The docs example uses `pubkeys` but the actual type is `recipients: string[]`.
|
||||
- **Gift wrap processing requires opt-in.** Set `shouldUnwrap.set(true)` to enable automatic NIP-59 unwrapping of incoming `kind:1059` events. Without this, wrapped events are silently discarded.
|
||||
- **`commands` force-load lists before modifying them.** `follow()`, `unfollow()`, etc. call `forceLoadUserFollowList` to ensure they have the latest list before adding/removing, preventing accidental list truncation. Do not call these in rapid succession without awaiting each one.
|
||||
- **WoT graph is rebuilt at most once per second** (throttled). Do not expect `wotGraph` to reflect a `follow()` call immediately; subscribe to the store instead.
|
||||
- **`routerContext.getDefaultRelays` is throttled** with a 200 ms window by default in `index.ts`. It returns up to the 5 highest-quality known relays. Override it before any relay connections if you want a fixed bootstrap list.
|
||||
- **Multiple sessions are supported.** Call `loginWith*` multiple times to add sessions. Switch the active session with `pubkey.set(otherPubkey)`. Remove a session with `dropSession(pubkey)` — this also cleans up the cached signer.
|
||||
- **Stores have `.get()` via `withGetter`.** `pubkey.get()`, `signer.get()`, `session.get()`, `signerLog.get()`, `shouldUnwrap.get()` all work without `get()` from `svelte/store`. Use this for synchronous reads outside of reactive contexts.
|
||||
- **`appContext.dufflepudUrl` must be set before first handle/zapper load.** There is no lazy re-fetch; set it at app startup.
|
||||
- `welshman-domain` — the readers/writers every plugin decodes events with
|
||||
- `welshman-net` — sockets, adapters, request/publish lifecycle, auth
|
||||
- `welshman-store` — the repository and the derive helpers plugins are built on
|
||||
- `welshman-signer` — signer implementations behind `User`
|
||||
- `welshman-util` — kinds, filters, tag specs, and the `RelaySelection` DSL
|
||||
|
|
|
|||
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 {Plugin, PluginKey} from "@tiptap/pm/state"
|
||||
import type {NodeViewRendererProps} from "@tiptap/core"
|
||||
import {Router} from "@welshman/router"
|
||||
import {createSearch, profiles, searchProfiles, deriveProfileDisplay} from "@welshman/app"
|
||||
import {Profiles, Router, createSearch} from "@welshman/app"
|
||||
import {outbox} from "@welshman/util"
|
||||
import {
|
||||
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
|
||||
} from "@welshman/editor"
|
||||
|
|
@ -323,8 +323,8 @@ const onSubmit = (editor: Editor) => {
|
|||
|
||||
## Integration Notes
|
||||
|
||||
- **`@welshman/app`** — `profileSearch` and `deriveProfileDisplay` 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`** — `app.use(Profiles).profileSearch` and `app.use(Profiles).display(pubkey)` are the typical sources for mention autocomplete data and display names.
|
||||
- **`@welshman/app`** — relay hints for nprofile bech32 strings come from `app.use(Router).resolve([outbox(pubkey)])`.
|
||||
- **`@welshman/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.
|
||||
- **`@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
|
||||
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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: "Use this skill when working with @welshman/net: relay connections,
|
|||
|
||||
# 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
|
||||
|
||||
|
|
@ -135,10 +135,10 @@ Emits `"update"` with `RepositoryUpdate` (`{ added: TrustedEvent[], removed: Set
|
|||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `netContext` | Global `NetContext` config object |
|
||||
| `NetContext` | `{ pool, repository, isEventValid, isEventDeleted, getAdapter? }` |
|
||||
| `NetContext` | `{ pool?, repository?, 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)
|
||||
|
||||
|
|
@ -366,7 +366,7 @@ repo.load(storedEvents)
|
|||
### Startup: bulk-load Tracker state
|
||||
|
||||
```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
|
||||
const relaysById = new Map<string, Set<string>>()
|
||||
|
|
@ -384,7 +384,7 @@ tracker.load(relaysById)
|
|||
|
||||
```typescript
|
||||
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 {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/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.
|
||||
- **`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.
|
||||
- **`@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`** — 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/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.
|
||||
|
||||
## Gotchas & Tips
|
||||
|
|
|
|||
|
|
@ -140,15 +140,19 @@ notes.subscribe($notes => {
|
|||
```typescript
|
||||
import { Repository } from "@welshman/net"
|
||||
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 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,
|
||||
filters: [{ kinds: [PROFILE] }],
|
||||
eventToItem: event => readProfile(event),
|
||||
getKey: profile => profile.event.pubkey,
|
||||
eventToItem: event => readProfile(event).parse(),
|
||||
getKey: profile => profile.author(),
|
||||
})
|
||||
|
||||
// All profiles as array
|
||||
|
|
@ -159,7 +163,7 @@ const deriveProfile = makeDeriveItem(profilesByPubkey)
|
|||
const aliceProfile = deriveProfile("alice-pubkey-hex")
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
import {
|
||||
|
|
@ -237,10 +242,9 @@ import {
|
|||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
} from "@welshman/store"
|
||||
import { load } from "@welshman/net"
|
||||
import { repository } from "@welshman/app"
|
||||
import { Router } from "@welshman/router"
|
||||
import { getTagValue, getTagValues } from "@welshman/util"
|
||||
import { Network, Router } from "@welshman/app"
|
||||
import { outbox } from "@welshman/util"
|
||||
import { relayTags, tagSpec, tagValue, tagValues } from "@welshman/util"
|
||||
import type { TrustedEvent } from "@welshman/util"
|
||||
|
||||
const BOOKMARK_KIND = 30003
|
||||
|
|
@ -254,14 +258,14 @@ type Bookmark = {
|
|||
|
||||
const parseBookmark = (event: TrustedEvent): Bookmark => ({
|
||||
pubkey: event.pubkey,
|
||||
title: getTagValue("title", event.tags) ?? "Untitled",
|
||||
urls: getTagValues("r", event.tags),
|
||||
title: tagValue(tagSpec("title"), event.tags) ?? "Untitled",
|
||||
urls: tagValues(relayTags("r"), event.tags),
|
||||
event,
|
||||
})
|
||||
|
||||
// Step 1: Reactive Map<pubkey, Bookmark> — live-updates from repository
|
||||
const bookmarksByPubkey = deriveItemsByKey<Bookmark>({
|
||||
repository,
|
||||
repository: app.repository,
|
||||
filters: [{ kinds: [BOOKMARK_KIND] }],
|
||||
getKey: b => b.pubkey,
|
||||
eventToItem: parseBookmark,
|
||||
|
|
@ -278,8 +282,10 @@ const getBookmark = (pubkey: string) => getBookmarksByPubkey().get(pubkey)
|
|||
// re-fetches only after the timeout window (default: 3600 s)
|
||||
const loadBookmark = makeLoadItem<Bookmark>(
|
||||
async (pubkey: string) => {
|
||||
await load({
|
||||
relays: Router.get().ForPubkey(pubkey).getUrls(),
|
||||
const scenario = await app.use(Router).resolve([outbox(pubkey)])
|
||||
|
||||
await app.use(Network).load({
|
||||
relays: scenario.getUrls(),
|
||||
filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }],
|
||||
})
|
||||
},
|
||||
|
|
@ -298,7 +304,8 @@ aliceBookmark.subscribe($b => console.log($b?.title))
|
|||
## 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/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.
|
||||
- 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` |
|
||||
| `SignedEvent` | `HashedEvent + sig` |
|
||||
| `TrustedEvent` | `HashedEvent + optional sig` — most common in-app type |
|
||||
| `DecryptedEvent` | `TrustedEvent + plaintext` (for encrypted lists/events) |
|
||||
|
||||
### Event Utilities
|
||||
|
||||
|
|
@ -49,9 +48,6 @@ yarn add @welshman/util
|
|||
| `isReplaceable(event)` | True for plain or parameterized replaceable |
|
||||
| `isPlainReplaceable(event)` | True for kinds 10000–19999 and metadata/contacts |
|
||||
| `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
|
||||
|
||||
|
|
@ -274,24 +270,16 @@ isDVMKind(kind) // 5000–7000
|
|||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `getTags(types, tags)` | Get all tags matching one or more type strings |
|
||||
| `getTag(types, tags)` | Get first matching tag |
|
||||
| `getTagValues(types, tags)` | Get value (index 1) of all matching tags — types first, then the tags array |
|
||||
| `getTagValue(types, tags)` | Get value of first matching tag — types first, then the tags array |
|
||||
| `getEventTags(tags)` | `e` tags |
|
||||
| `getEventTagValues(tags)` | Values of `e` tags |
|
||||
| `getAddressTags(tags)` | `a` tags |
|
||||
| `getAddressTagValues(tags)` | Values of `a` tags |
|
||||
| `getPubkeyTags(tags)` | `p` tags |
|
||||
| `getPubkeyTagValues(tags)` | Values of `p` tags |
|
||||
| `getTopicTags(tags)` / `getTopicTagValues(tags)` | `t` (hashtag) tags |
|
||||
| `getRelayTags(tags)` / `getRelayTagValues(tags)` | `r` and `relay` tags |
|
||||
| `getKindTags(tags)` / `getKindTagValues(tags)` | `k` tags (returns `number[]`) |
|
||||
| `getGroupTags(tags)` / `getGroupTagValues(tags)` | group tags |
|
||||
| `getReplyTags(tags)` | `{ roots, replies, mentions }` — NIP-10 threading |
|
||||
| `getCommentTags(tags)` | `{ roots, replies }` — NIP-22 uppercase/lowercase tags |
|
||||
| `uniqTags(tags)` | Remove duplicate tags |
|
||||
| `tagsFromIMeta(imeta)` | Parse `imeta` tag into array of tag arrays |
|
||||
| `tagSpec(keys, matchValue?, normalize?)` | Build a spec: which tag keys, plus optional validation/normalization |
|
||||
| `hexTags(keys)` | Spec for 32-byte hex values (`e`, `p`, …) |
|
||||
| `addressTags(keys)` | Spec for `kind:pubkey:d` addresses (`a`, `A`) |
|
||||
| `kindTags(keys)` | Spec for kind numbers (`k`) |
|
||||
| `topicTags(keys)` | Spec for topics, normalized (`t`) |
|
||||
| `relayTags(keys)` | Spec for relay urls (`r`, `relay`) |
|
||||
| `tagValue(spec, tags)` | Value (index 1) of the first matching tag |
|
||||
| `tagValues(spec, tags)` | Values of all matching tags |
|
||||
| `matchTag(spec, tags)` / `matchTags(spec, tags)` | The matching tag(s) themselves |
|
||||
| `tagMatcher(spec)` | A `(tag) => boolean` predicate, for filtering in one pass |
|
||||
|
||||
### Filters
|
||||
|
||||
|
|
@ -319,50 +307,12 @@ isDVMKind(kind) // 5000–7000
|
|||
| `Address.from(s, relays?)` | Parse from `kind:pubkey:identifier` string |
|
||||
| `Address.fromNaddr(naddr)` | Parse from NIP-19 naddr |
|
||||
| `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 |
|
||||
|
||||
### 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
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `RelayMode` | Enum: `Read`, `Write`, `Search`, `Blocked`, `Messaging` |
|
||||
| `RelayProfile` | NIP-11 relay info type |
|
||||
| `isRelayUrl(url)` | Validate relay URL |
|
||||
| `isShareableRelayUrl(url)` | True if valid relay URL and not a local address |
|
||||
| `isOnionUrl(url)` | Tor address check |
|
||||
|
|
@ -370,7 +320,6 @@ Profile fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`,
|
|||
| `isIPAddress(url)` | IP address check |
|
||||
| `normalizeRelayUrl(url)` | Normalize to standard wss:// format |
|
||||
| `displayRelayUrl(url)` | Strip protocol and trailing slash |
|
||||
| `displayRelayProfile(profile?, fallback?)` | Get display name for relay |
|
||||
|
||||
### 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 |
|
||||
| `getInvoiceAmount(bolt11)` | Extract millisatoshi amount from BOLT11 invoice |
|
||||
| `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
|
||||
|
||||
|
|
@ -502,29 +448,14 @@ Only do this for events you persisted yourself after they were validated. Never
|
|||
### Working with tags
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
getPubkeyTagValues,
|
||||
getTopicTagValues,
|
||||
getRelayTagValues,
|
||||
getReplyTags,
|
||||
uniqTags,
|
||||
} from '@welshman/util'
|
||||
import {tagValue, tagValues, hexTags, relayTags, tagSpec, topicTags} from '@welshman/util'
|
||||
|
||||
// getTagValue and getTagValues: types argument FIRST, then the tags array
|
||||
const title = getTagValue('title', event.tags) // string | undefined
|
||||
const urls = getTagValues('r', event.tags) // string[]
|
||||
|
||||
// Multiple types at once
|
||||
const ids = getTagValues(['e', 'a'], event.tags) // string[]
|
||||
|
||||
const mentions = getPubkeyTagValues(event.tags) // string[]
|
||||
const topics = getTopicTagValues(event.tags) // string[]
|
||||
const relays = getRelayTagValues(event.tags) // string[]
|
||||
|
||||
// NIP-10 thread context
|
||||
const { roots, replies, mentions: threadMentions } = getReplyTags(event.tags)
|
||||
// Specs come first, then the tags array. The spec says which keys to match and how to
|
||||
// validate the value, so malformed tags are skipped rather than silently returned.
|
||||
const title = tagValue(tagSpec('title'), event.tags) // string | undefined
|
||||
const urls = tagValues(relayTags('r'), event.tags) // string[], valid relay urls only
|
||||
const ids = tagValues(hexTags(['e', 'a']), event.tags) // string[], 32-byte hex only
|
||||
const topics = tagValues(topicTags('t'), event.tags) // string[], normalized
|
||||
```
|
||||
|
||||
### Matching and building filters
|
||||
|
|
@ -566,24 +497,11 @@ const parsed = Address.fromNaddr('naddr1...')
|
|||
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
|
||||
|
||||
```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
|
||||
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 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) {
|
||||
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/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/router`** — uses `RelayMode` and relay URL helpers when computing relay selections.
|
||||
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here; the `Encrypt` function type used by `Encryptable` is typically provided by a signer.
|
||||
- **`@welshman/app`** — high-level application layer; wraps net/store/domain and resolves this package's `RelaySelection` DSL through `app.use(Router)`.
|
||||
- **`@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, 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.
|
||||
|
||||
- **`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.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http
|
|||
|
||||
| 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/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/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/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/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:
|
||||
|
||||
- **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`
|
||||
- **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
|
||||
# Full application framework (includes app, net, router, store, signer, feeds)
|
||||
npm i @welshman/app
|
||||
```typescript
|
||||
import {createApp, Network, Profiles, User} from "@welshman/app"
|
||||
|
||||
# Or assemble manually for more control
|
||||
npm i @welshman/util @welshman/net @welshman/signer
|
||||
// `createApp` = `new App` plus the default policies (ingest, relay stats, gift-wrap unwrapping)
|
||||
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
|
||||
|
||||
|
|
@ -61,91 +79,67 @@ If you're building a conventional nostr web client, use `@welshman/app` for batt
|
|||
|
||||
| Goal | Package(s) to use |
|
||||
|---|---|
|
||||
| Fetch notes from relays | `@welshman/net` (low-level) or `@welshman/app` (high-level) |
|
||||
| Select which relays to use | `@welshman/router` |
|
||||
| Sign and publish events | `@welshman/signer` + `@welshman/net` |
|
||||
| Build a feed UI | `@welshman/feeds` + `@welshman/app` |
|
||||
| Fetch notes from relays | `app.use(Network)`, or `@welshman/net` directly for low-level control |
|
||||
| Select which relays to use | `RelaySelection` helpers in `@welshman/util` + `app.use(Router)` |
|
||||
| Read or write a specific kind | `@welshman/domain` via `app.use(Domain)` |
|
||||
| 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` |
|
||||
| Embed a composer / editor | `@welshman/editor` |
|
||||
| Cache nostr events client-side | `@welshman/store` |
|
||||
| Core event/filter utilities | `@welshman/util` |
|
||||
| Cache nostr events client-side | `@welshman/store` + `app.repository` |
|
||||
| Core event/filter/tag utilities | `@welshman/util` |
|
||||
| Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` |
|
||||
|
||||
### App Example
|
||||
## App example
|
||||
|
||||
```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"
|
||||
import { batch, on } from "@welshman/lib"
|
||||
import { verifiedSymbol } from "@welshman/util"
|
||||
import { repository, tracker, loginWithNip07, publishThunk, userProfile, loadUserProfile } from "@welshman/app"
|
||||
import { routerContext } from "@welshman/router"
|
||||
import { load } from "@welshman/net"
|
||||
import type { TrustedEvent } from "@welshman/util"
|
||||
import type { RepositoryUpdate } from "@welshman/net"
|
||||
|
||||
// 1. Configure fallback relays
|
||||
routerContext.getDefaultRelays = () => ["wss://relay.example.com", "wss://relay2.example.com"]
|
||||
routerContext.getIndexerRelays = () => ["wss://indexer.example.com"]
|
||||
|
||||
// 2. Open IndexedDB and hydrate the repository
|
||||
const db = await openDB("my-app", 1, {
|
||||
upgrade(db) {
|
||||
db.createObjectStore("events", { keyPath: "id" })
|
||||
// 1. Build an app for the signed-in user
|
||||
const signer = new Nip07Signer()
|
||||
const app = createApp({
|
||||
user: await User.fromSigner(signer),
|
||||
config: {
|
||||
getDefaultRelays: () => ["wss://relay.example.com"],
|
||||
getIndexerRelays: () => ["wss://indexer.example.com"],
|
||||
},
|
||||
})
|
||||
|
||||
const stored: TrustedEvent[] = await db.getAll("events")
|
||||
for (const e of stored) e[verifiedSymbol] = true
|
||||
repository.load(stored)
|
||||
// 2. Read the user's profile (loads from their write relays if not cached)
|
||||
const profile = await app.use(Profiles).load(app.user!.pubkey)
|
||||
|
||||
// Flush new events to IndexedDB
|
||||
on(repository, "update", batch(3000, async (updates: RepositoryUpdate[]) => {
|
||||
const tx = db.transaction("events", "readwrite")
|
||||
for (const { added, removed } of updates) {
|
||||
for (const e of added) tx.store.put(e)
|
||||
for (const id of removed) tx.store.delete(id)
|
||||
}
|
||||
await tx.done
|
||||
}))
|
||||
console.log("Hello,", profile?.display())
|
||||
|
||||
// 3. Log in
|
||||
const pk = await window.nostr.getPublicKey()
|
||||
loginWithNip07(pk)
|
||||
// 3. Publish a note — build a writer, wrap it in a command, publish it
|
||||
const writer = app.use(Domain).writer(Note).setContent("Hello, Nostr!")
|
||||
const command = await app.use(Domain).command(writer)
|
||||
|
||||
// 4. Load user's profile reactively (triggers network fetch if not cached)
|
||||
await loadUserProfile()
|
||||
|
||||
userProfile.subscribe($profile => {
|
||||
if ($profile) console.log("Hello,", $profile.name)
|
||||
})
|
||||
|
||||
// 5. Publish a note
|
||||
import { makeEvent } from "@welshman/util"
|
||||
import { Router } from "@welshman/router"
|
||||
|
||||
const thunk = publishThunk({
|
||||
event: makeEvent(1, { content: "Hello, Nostr!", tags: [] }),
|
||||
relays: Router.get().FromUser().getUrls(),
|
||||
})
|
||||
|
||||
await thunk.complete
|
||||
await command.publish().waitForError()
|
||||
```
|
||||
|
||||
### 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
|
||||
import { AbstractAdapter, ClientMessage, NetContext, isClientEvent, netContext, publish, request } from '@welshman/net'
|
||||
import { call, sleep } from '@welshman/lib'
|
||||
import { Nip01Signer } from '@welshman/signer'
|
||||
import { makeEvent, NOTE } from '@welshman/util'
|
||||
import {AbstractAdapter, isClientEvent, publish, request} from "@welshman/net"
|
||||
import type {ClientMessage, NetContext} from "@welshman/net"
|
||||
import {call, sleep} from "@welshman/lib"
|
||||
import {Nip01Signer} from "@welshman/signer"
|
||||
import {makeEvent, NOTE} from "@welshman/util"
|
||||
|
||||
const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
|
||||
const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
|
||||
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 {
|
||||
get sockets() { return [] }
|
||||
get urls() { return [] }
|
||||
|
|
@ -157,37 +151,32 @@ export class PrintAdapter extends AbstractAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
// Configure net context to use our custom adapter
|
||||
netContext.getAdapter = (url: string, context: NetContext) => {
|
||||
if (url === RELAY_URL) {
|
||||
return new PrintAdapter()
|
||||
}
|
||||
// Context is passed explicitly. An `App` supplies its own via `app.netContext`; here we build one.
|
||||
const context: NetContext = {
|
||||
getAdapter: (url: string) => (url === RELAY_URL ? new PrintAdapter() : undefined),
|
||||
}
|
||||
|
||||
// Loop, sending off pings every so often
|
||||
call(async () => {
|
||||
while (true) {
|
||||
await sleep(1000)
|
||||
|
||||
const ping = await pingSigner.sign(
|
||||
makeEvent(NOTE, {content: 'ping'})
|
||||
)
|
||||
const ping = await pingSigner.sign(makeEvent(NOTE, {content: "ping"}))
|
||||
|
||||
await publish({event: ping, relays: [RELAY_URL]})
|
||||
await publish({event: ping, relays: [RELAY_URL], context})
|
||||
}
|
||||
})
|
||||
|
||||
// Meanwhile, listen for pings and quote-note with a pong
|
||||
call(async () => {
|
||||
request({
|
||||
relays: [RELAY_URL],
|
||||
filters: [{kinds: [NOTE], authors: [await pingSigner.getPubkey()]}],
|
||||
context,
|
||||
onEvent: async (ping, url) => {
|
||||
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
|
||||
import {derived} from "svelte/store"
|
||||
import {throttle} from "throttle-debounce"
|
||||
import {Profiles} from "@welshman/app"
|
||||
import {Dialog} from "$lib/components"
|
||||
import {repository} from "$app/core/state"
|
||||
import {app} from "@app/core"
|
||||
```
|
||||
|
||||
## Cleanup Pass
|
||||
|
|
@ -67,9 +68,19 @@ Conventions below.
|
|||
**Core Principles:**
|
||||
|
||||
- Use Svelte 4 **stores** for all state (NOT runes outside UI components)
|
||||
- Most global state flows through Welshman's `repository` (unidirectional)
|
||||
- Query state using `deriveEventsMapped` or `deriveProfile` etc
|
||||
- Update state by publishing events via `publishThunk`
|
||||
- Everything hangs off the single `App` instance in `app/core.ts`. There are no welshman globals —
|
||||
reach features with `app.use(Plugin)`, which is memoized per app and cheap to call inline.
|
||||
- `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:**
|
||||
|
||||
|
|
@ -82,12 +93,12 @@ Conventions below.
|
|||
|
||||
**Welshman Library Suite:**
|
||||
|
||||
- `@welshman/app` - High-level state (pubkey, signer, repository, tracker)
|
||||
- `@welshman/net` - Network layer (Pool, Socket, load, pull, request)
|
||||
- `@welshman/store` - Svelte integration (deriveEventsMapped, etc.)
|
||||
- `@welshman/util` - Event utilities (kinds, tags, validation)
|
||||
- `@welshman/app` - The `App` instance and its plugins (Profiles, Rooms, Thunks, Router, …)
|
||||
- `@welshman/domain` - A typed Reader/Writer pair per event kind, plus `Relay` and `Zapper`
|
||||
- `@welshman/net` - Network layer (Pool, Socket, adapters, request/publish/pull)
|
||||
- `@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/router` - Relay routing (inbox/outbox model)
|
||||
- `@welshman/editor` - Rich text editor with Nostr
|
||||
- `@welshman/content` - Content parsing
|
||||
- `@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
|
||||
- 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.
|
||||
- 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)`.
|
||||
- Do not render a profile's `about` directly (e.g. `profile.about`); use the `ProfileAbout` component instead.
|
||||
- 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.
|
||||
- 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.
|
||||
- Avoid pass-through functions except when the wrapper is part of an abstraction. `x => y()` is not ok, but `x => this.impl.y()` is ok, for example.
|
||||
- Use `call` from `@welshman/lib` instead of an IIFE: `const x = call(() => {...})`, not `const x = (() => {...})()`. It reads left-to-right and drops the wrapping parens and the leading-semicolon hazard. `call` ignores extra arguments, so it also works directly as a callback — `unsubscribers.forEach(call)`.
|
||||
- To match on field equality, use `spec` from `@welshman/lib` rather than an arrow comparing properties: `.filter(spec({shortcode}))`, not `.filter(emoji => emoji.shortcode === shortcode)`. It takes an object of key/value pairs and matches when every pair is equal, so it covers multi-key checks too (`spec({kind, pubkey})`), and it accepts an array for positional matching on tags (`spec(["p", pubkey])`). Keep an explicit arrow when the predicate isn't plain equality — ranges, negation, or a comparison joined with `&&`.
|
||||
- For durations and timestamps, use `@welshman/lib`'s time helpers and constants (`MINUTE`, `HOUR`, `DAY`, `WEEK`, `MONTH`, `YEAR`) rather than raw milliseconds: `int(5, MINUTE)` for a duration in seconds, `ago(5, MINUTE)` for a past timestamp, `now()` for the current one, and `ms()`/`ms(int(...))` only where a browser API needs milliseconds. So `checkedAt < ago(5, MINUTE)` instead of `checkedAt < Date.now() - 300_000`. Write these count-first (`int(3, MONTH)`, `ago(2, WEEK)`) — the declared parameter order is `(unit, count)`, but the product is the same either way and every call site in this repo reads count-first. Nostr timestamps are seconds, so prefer `now()` over `Date.now()` for anything stored on an event.
|
||||
- When declaring variables in a svelte component, the order should generally be: props, constants derived from props/state, functions declared with `const`, mutable variables declared with `let`, effects, onMount. This order may vary due to dependencies, but should generally be adhered to.
|
||||
|
||||
|
|
@ -161,20 +173,23 @@ Conventions below.
|
|||
|
||||
### Loading Data from Network
|
||||
|
||||
1. Use utilities from `app/core/requests.ts`
|
||||
2. Or create derived stores in `app/core/state.ts`
|
||||
3. Use `load`, `pull`, or `request` from `@welshman/net`
|
||||
1. Prefer a plugin's `one(key)` / `load(key)`, which handle outbox routing and caching
|
||||
2. For anything else use `app.use(Network).load/request` or `app.use(Sync).pull/push` — the bare
|
||||
`load`/`request`/`publish` from `@welshman/net` throw without a context
|
||||
|
||||
### Publishing Events
|
||||
|
||||
1. Create `make*` function to build event template
|
||||
2. Create `publish*` function using `publishThunk`
|
||||
3. Display thunk status to user (for cancel/error handling)
|
||||
4. These go in in `app/core/commands.ts`
|
||||
1. Build a writer: `app.use(Domain).writer(Kind, reader?)`, then chain its setters
|
||||
2. Wrap it: `const command = await app.use(Domain).command(writer)`
|
||||
3. Publish it: `command.publish()`, `.publishToRelays(urls)`, or `.publishAsRelay(url)`
|
||||
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
|
||||
|
||||
- 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
|
||||
- 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())
|
||||
return SessionInfo(
|
||||
session.optString("method", "anonymous"),
|
||||
session.optString("pubkey", ""),
|
||||
session,
|
||||
state.optString("pubkey", ""),
|
||||
session.optJSONObject("data") ?: JSONObject(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -537,7 +537,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||
private data class SessionInfo(
|
||||
val method: String,
|
||||
val pubkey: String,
|
||||
val session: JSONObject,
|
||||
val data: JSONObject,
|
||||
)
|
||||
|
||||
private data class Subscription(
|
||||
|
|
@ -699,24 +699,23 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||
}
|
||||
|
||||
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
|
||||
val signed = signWithNip01Secret(secret, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
||||
return sendAuthMessage(webSocket, signed)
|
||||
}
|
||||
|
||||
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
|
||||
val signed = signWithNip55ContentResolver(signerPackage, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
||||
return sendAuthMessage(webSocket, signed)
|
||||
}
|
||||
|
||||
private fun tryNip46Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||
val handler = sessionInfo.session.optJSONObject("handler") ?: return false
|
||||
val clientSecret = sessionInfo.session.optString("secret", "")
|
||||
val signerPubkey = handler.optString("pubkey", "")
|
||||
val relays = handler.optJSONArray("relays")
|
||||
val clientSecret = sessionInfo.data.optString("clientSecret", "")
|
||||
val signerPubkey = sessionInfo.data.optString("signerPubkey", "")
|
||||
val relays = sessionInfo.data.optJSONArray("relays")
|
||||
|
||||
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",
|
||||
"@hono/node-server": "^2.0.0",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@pomade/core": "^0.3.1",
|
||||
"@pomade/core": "^0.3.3",
|
||||
"@poppanator/sveltekit-svg": "^7.0.0",
|
||||
"@svelte-plugins/datepicker": "^1.0.11",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
|
|
@ -73,16 +73,16 @@
|
|||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@welshman/app": "^0.8.16",
|
||||
"@welshman/content": "^0.8.16",
|
||||
"@welshman/editor": "^0.8.16",
|
||||
"@welshman/feeds": "^0.8.16",
|
||||
"@welshman/lib": "^0.8.16",
|
||||
"@welshman/net": "^0.8.16",
|
||||
"@welshman/router": "^0.8.16",
|
||||
"@welshman/signer": "^0.8.16",
|
||||
"@welshman/store": "^0.8.16",
|
||||
"@welshman/util": "^0.8.16",
|
||||
"@welshman/app": "^0.9.0-pre2",
|
||||
"@welshman/content": "^0.9.0-pre2",
|
||||
"@welshman/domain": "^0.9.0-pre2",
|
||||
"@welshman/editor": "^0.9.0-pre2",
|
||||
"@welshman/feeds": "^0.9.0-pre2",
|
||||
"@welshman/lib": "^0.9.0-pre2",
|
||||
"@welshman/net": "^0.9.0-pre2",
|
||||
"@welshman/signer": "^0.9.0-pre2",
|
||||
"@welshman/store": "^0.9.0-pre2",
|
||||
"@welshman/util": "^0.9.0-pre2",
|
||||
"cheerio": "^1.2.0",
|
||||
"compressorjs-next": "^1.1.2",
|
||||
"dotenv": "^16.6.1",
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -11,9 +11,19 @@ minimumReleaseAgeExclude:
|
|||
- '@welshman/feeds'
|
||||
- '@welshman/lib'
|
||||
- '@welshman/net'
|
||||
- '@welshman/router'
|
||||
- '@welshman/domain'
|
||||
- '@welshman/signer'
|
||||
- '@welshman/store'
|
||||
- '@welshman/util'
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 {AuthStatus, Pool, request, SocketStatus} from "@welshman/net"
|
||||
import {AuthStatus, SocketStatus} from "@welshman/net"
|
||||
import type {Socket} from "@welshman/net"
|
||||
import {
|
||||
displayRelayUrl,
|
||||
getTagValue,
|
||||
isRelayUrl,
|
||||
makeEvent,
|
||||
normalizeRelayUrl,
|
||||
FOLLOWS,
|
||||
MESSAGING_RELAYS,
|
||||
PROFILE,
|
||||
RELAY_INVITE,
|
||||
RELAY_JOIN,
|
||||
RELAY_LEAVE,
|
||||
ROOM_JOIN,
|
||||
RELAYS,
|
||||
} 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 {app, command, network, reader, roomLists, thunks, writer} from "@app/core"
|
||||
import {PLATFORM_URL} from "@app/env"
|
||||
import {addSpace, userSpaceUrls} from "@app/groups"
|
||||
import {relaysMostlyRestricted} from "@app/policies"
|
||||
import {broadcastUserData} from "@app/profiles"
|
||||
import {Push} from "@app/push"
|
||||
import {syncApplicationData} from "@app/sync"
|
||||
import {notificationSettings, setSpaceNotifications} from "@app/settings"
|
||||
import {deriveSocket} from "@app/relays"
|
||||
import {notificationSettings, setSpaceNotifications} from "@app/settings"
|
||||
import {syncApplicationData} from "@app/sync"
|
||||
|
||||
export const ROOM_CREATE_INVITE = 9009
|
||||
|
||||
|
|
@ -32,11 +33,6 @@ export type InviteData = {
|
|||
code?: string
|
||||
}
|
||||
|
||||
export type JoinRequestParams = {
|
||||
url: string
|
||||
claim: string
|
||||
}
|
||||
|
||||
export const isNetworkAuthError = (error: string) => {
|
||||
const lower = error.toLowerCase()
|
||||
|
||||
|
|
@ -104,32 +100,35 @@ export const deriveRelayAuthError = (url: string) =>
|
|||
}
|
||||
})
|
||||
|
||||
export const makeJoinRequest = (params: JoinRequestParams) =>
|
||||
makeEvent(RELAY_JOIN, {tags: [["claim", params.claim]]})
|
||||
export const publishJoinRequest = (url: string, claim = "") =>
|
||||
command(writer(RelayJoin).forceRelays(url).setClaim(claim)).then(publish)
|
||||
|
||||
export const publishJoinRequest = (params: JoinRequestParams) =>
|
||||
publishThunk({event: makeJoinRequest(params), relays: [params.url]})
|
||||
export const publishLeaveRequest = (url: string) =>
|
||||
command(writer(RelayLeave).forceRelays(url)).then(publish)
|
||||
|
||||
export type LeaveRequestParams = {
|
||||
url: string
|
||||
export const publishRoomJoinRequest = (url: string, h: string, code?: string) => {
|
||||
const eventWriter = writer(RoomJoin).setRoom(url, h)
|
||||
|
||||
if (code) {
|
||||
eventWriter.setClaim(code)
|
||||
}
|
||||
|
||||
return command(eventWriter).then(publish)
|
||||
}
|
||||
|
||||
export const publishLeaveRequest = (params: LeaveRequestParams) =>
|
||||
publishThunk({event: makeEvent(RELAY_LEAVE), relays: [params.url]})
|
||||
|
||||
export const publishRoomInvite = async (url: string, h: string) => {
|
||||
const code = randomId()
|
||||
const error = await waitForThunkError(
|
||||
publishThunk({
|
||||
event: makeEvent(ROOM_CREATE_INVITE, {
|
||||
tags: [
|
||||
["h", h],
|
||||
["code", code],
|
||||
],
|
||||
}),
|
||||
relays: [url],
|
||||
}),
|
||||
)
|
||||
const event = makeEvent(ROOM_CREATE_INVITE, {
|
||||
tags: [
|
||||
["h", h],
|
||||
["code", code],
|
||||
],
|
||||
})
|
||||
|
||||
const error = await thunks
|
||||
.get()
|
||||
.publish({event, relays: [url]})
|
||||
.waitForError()
|
||||
|
||||
if (error) {
|
||||
return {error, code: undefined}
|
||||
|
|
@ -138,19 +137,6 @@ export const publishRoomInvite = async (url: string, h: string) => {
|
|||
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 = [
|
||||
AuthStatus.None,
|
||||
AuthStatus.Ok,
|
||||
|
|
@ -158,7 +144,7 @@ const authTerminalStatuses = [
|
|||
AuthStatus.DeniedSignature,
|
||||
]
|
||||
|
||||
const waitForAuth = async (socket: ReturnType<ReturnType<typeof Pool.get>["get"]>) => {
|
||||
const waitForAuth = async (socket: Socket) => {
|
||||
await poll({
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
condition: () => authTerminalStatuses.includes(socket.auth.status),
|
||||
|
|
@ -180,7 +166,7 @@ const formatAuthError = (status: AuthStatus, details?: string) => {
|
|||
}
|
||||
|
||||
export const attemptRelayAccess = async (url: string, claim = "") => {
|
||||
const socket = Pool.get().get(url)
|
||||
const socket = app.get().pool.get(url)
|
||||
|
||||
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++) {
|
||||
await socket.auth.retryAuth(sign)
|
||||
await socket.auth.retryAuth(User.require(app.get()).sign)
|
||||
await waitForAuth(socket)
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +193,8 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
|
|||
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
|
||||
|
||||
|
|
@ -292,10 +279,21 @@ export class Access {
|
|||
|
||||
async completeJoin(notifications: boolean) {
|
||||
await this.configureNotifications(notifications)
|
||||
await addSpace(this.url)
|
||||
await roomLists.get().addRelay(this.url).then(publish)
|
||||
this.clearRestricted()
|
||||
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({
|
||||
|
|
@ -321,7 +319,8 @@ export class Access {
|
|||
async joinRoom(h: string, code?: string) {
|
||||
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:")) {
|
||||
return message
|
||||
|
|
@ -329,11 +328,11 @@ export class Access {
|
|||
}
|
||||
|
||||
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({
|
||||
claim: data.claim,
|
||||
notifications,
|
||||
alreadyJoined: alreadyInSpace,
|
||||
alreadyJoined: spaceUrls.includes(data.url),
|
||||
})
|
||||
|
||||
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
|
||||
// empty result is the only signal we get that the relay never answered.
|
||||
const [events, roomInviteResult] = await Promise.all([
|
||||
request({
|
||||
network.get().request({
|
||||
relays: [this.url],
|
||||
autoClose: true,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
|
|
@ -362,7 +361,13 @@ export class Access {
|
|||
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)
|
||||
|
||||
if (h) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import {derived} from "svelte/store"
|
||||
import {first, groupBy, removeUndefined, spec} from "@welshman/lib"
|
||||
import {
|
||||
REPORT,
|
||||
ROOM_ADD_MEMBER,
|
||||
|
|
@ -5,28 +7,48 @@ import {
|
|||
ROOM_LEAVE,
|
||||
ROOM_MEMBERS,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
getPubkeyTagValues,
|
||||
getTagValue,
|
||||
hexTags,
|
||||
sortEventsAsc,
|
||||
sortEventsDesc,
|
||||
tagSpec,
|
||||
tagValue,
|
||||
tagValues,
|
||||
} 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 {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)
|
||||
|
||||
export const deriveSpaceActionItems = (url: string) =>
|
||||
derived(
|
||||
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 => {
|
||||
const getRoomId = (e: TrustedEvent) =>
|
||||
getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
|
||||
const reports = $events.filter(e => e.kind === REPORT)
|
||||
tagValue(tagSpec(e.kind === ROOM_MEMBERS ? "d" : "h"), e.tags)
|
||||
const reports = $events.filter(spec({kind: REPORT}))
|
||||
const pendingJoins: TrustedEvent[] = []
|
||||
|
||||
// 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(
|
||||
...removeUndefined(
|
||||
|
|
@ -72,7 +94,7 @@ export const deriveSpaceActionItems = (url: string) =>
|
|||
return true
|
||||
}
|
||||
|
||||
return getPubkeyTagValues(event.tags).includes(pubkey)
|
||||
return tagValues(hexTags("p"), event.tags).includes(pubkey)
|
||||
})
|
||||
) {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -16,15 +16,15 @@ import {
|
|||
type AudioCaptureOptions,
|
||||
} from "livekit-client"
|
||||
import {derived, get, writable} from "svelte/store"
|
||||
import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
||||
import {signer} from "@welshman/app"
|
||||
import {load} from "@welshman/net"
|
||||
import {first, not, nthEq, reject, uniqBy} from "@welshman/lib"
|
||||
import {makeHttpAuth, makeHttpAuthHeader, sortEventsDesc, tagSpec, tagValues} from "@welshman/util"
|
||||
import {deriveDeduplicated} from "@welshman/store"
|
||||
import {makeRoomKey} from "@welshman/app"
|
||||
import type {Room} from "@welshman/app"
|
||||
import {getLivekitEndpoint} from "$lib/livekit"
|
||||
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {deriveLatestEventForUrl} from "@app/repository"
|
||||
import {deriveRoom, makeRoomId, type Room} from "@app/groups"
|
||||
import {network, user} from "@app/core"
|
||||
import {deriveEventsForUrl} from "@app/repository"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
export const LIVEKIT_PARTICIPANTS = 39004
|
||||
|
|
@ -135,7 +135,7 @@ export const joinVoiceRoom = async (
|
|||
): Promise<void> => {
|
||||
abortJoinVoiceRoom()
|
||||
|
||||
callTargetRoom.set(get(deriveRoom(url, h)))
|
||||
callTargetRoom.set({url, h, id: makeRoomKey(url, h)})
|
||||
callState.set(CallState.Joining)
|
||||
|
||||
const controller = new AbortController()
|
||||
|
|
@ -384,7 +384,7 @@ export const toggleVideoPrimaryTile = (key: string) => {
|
|||
}
|
||||
|
||||
export const loadCallParticipants = (url: string, h: string) =>
|
||||
load({
|
||||
network.get().load({
|
||||
relays: [url],
|
||||
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
|
||||
})
|
||||
|
|
@ -395,25 +395,22 @@ export const deriveCallParticipants = (url: string, h: string) =>
|
|||
[
|
||||
participantMediaState,
|
||||
callTargetRoom,
|
||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||
deriveDeduplicated(
|
||||
deriveEventsForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||
events => first(sortEventsDesc(events)),
|
||||
),
|
||||
],
|
||||
([$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 participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
||||
return uniqBy((p: CallParticipant) => participantKey(p), participants)
|
||||
} else {
|
||||
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
||||
if (!latestEvent) return []
|
||||
const participants = removeUndefined(
|
||||
map(
|
||||
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
|
||||
getTags("participant", latestEvent.tags),
|
||||
),
|
||||
)
|
||||
return uniqBy((p: CallParticipant) => participantKey(p), participants)
|
||||
}
|
||||
const identities = inCall
|
||||
? [...$participantMediaState.keys()]
|
||||
: tagValues(tagSpec("participant"), $publishedParticipantList?.tags ?? [])
|
||||
|
||||
return uniqBy(
|
||||
(p: CallParticipant) => participantKey(p),
|
||||
identities.map(participantFromLiveKitIdentity),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -527,18 +524,15 @@ const resyncAfterReconnect = (livekit: LiveKitRoom) => {
|
|||
|
||||
const fetchLivekitToken = async (
|
||||
url: string,
|
||||
groupId: string,
|
||||
roomId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{server_url: string; participant_token: string}> => {
|
||||
const endpoint = getLivekitEndpoint(url, groupId)
|
||||
|
||||
const $signer = signer.get()
|
||||
if (!$signer) throw new Error("No signer available")
|
||||
const endpoint = getLivekitEndpoint(url, roomId)
|
||||
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
||||
|
||||
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 response = await fetch(endpoint, {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import {DELETE, PROFILE, getPubkeyTagValues} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {derived, readable} from "svelte/store"
|
||||
import {append, call, on, reject, remove, sort, sortBy, spec, uniq, uniqBy} from "@welshman/lib"
|
||||
import type {Override} from "@welshman/lib"
|
||||
import {createSearch, displayProfileByPubkey, pubkey, repository} from "@welshman/app"
|
||||
import {derived, readable} from "svelte/store"
|
||||
import {DM_KINDS} from "@app/content"
|
||||
import {DELETE, PROFILE, hexTags, tagValues} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import type {RepositoryUpdate} from "@welshman/net"
|
||||
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 = {
|
||||
id: string
|
||||
pubkeys: string[]
|
||||
|
|
@ -15,13 +17,13 @@ export type Chat = {
|
|||
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) =>
|
||||
getChatPubkeys(getPubkeyTagValues(event.tags).concat(event.pubkey))
|
||||
getChatPubkeys(tagValues(hexTags("p"), event.tags).concat(event.pubkey))
|
||||
|
||||
export const makeChatId = (pubkeys: string[]) => {
|
||||
const userPubkey = pubkey.get()!
|
||||
const userPubkey = user.get().pubkey
|
||||
const otherPubkeys = remove(userPubkey, uniq(pubkeys))
|
||||
const visiblePubkeys = otherPubkeys.length === 0 ? [userPubkey] : otherPubkeys
|
||||
|
||||
|
|
@ -34,11 +36,13 @@ export const chatsById = call(() => {
|
|||
const chatsById = new Map<string, Chat>()
|
||||
const chatsByPubkey = new Map<string, string[]>()
|
||||
|
||||
const displayProfile = (pubkey: string) => profiles.get().display(pubkey).get()
|
||||
|
||||
const addSearchText = (chat: Override<Chat, {search_text?: string}>) => {
|
||||
chat.search_text =
|
||||
chat.pubkeys.length === 1
|
||||
? displayProfileByPubkey(chat.pubkeys[0]) + " note to self"
|
||||
: remove(pubkey.get()!, chat.pubkeys).map(displayProfileByPubkey).join(" ")
|
||||
? displayProfile(chat.pubkeys[0]) + " note to self"
|
||||
: remove(user.get().pubkey, chat.pubkeys).map(displayProfile).join(" ")
|
||||
|
||||
return chat as Chat
|
||||
}
|
||||
|
|
@ -91,7 +95,7 @@ export const chatsById = call(() => {
|
|||
let dirty = false
|
||||
|
||||
for (const id of removed) {
|
||||
const event = repository.getEvent(id)
|
||||
const event = app.get().repository.getEvent(id)
|
||||
|
||||
if (event && DM_KINDS.includes(event.kind)) {
|
||||
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 = [
|
||||
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
|
||||
on(app.get().repository, "update", ({added, removed}: RepositoryUpdate) => {
|
||||
// Do this async so that profiles are populated
|
||||
setTimeout(() => {
|
||||
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">
|
||||
import type {Snippet} from "svelte"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import Dialog from "@lib/components/Dialog.svelte"
|
||||
import Landing from "@app/components/Landing.svelte"
|
||||
import Toast from "@app/components/Toast.svelte"
|
||||
import PrimaryNav from "@app/components/PrimaryNav.svelte"
|
||||
import {app} from "@app/core"
|
||||
import {modal} from "@app/modal"
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
children: Snippet
|
||||
}
|
||||
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
</script>
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
{#if $pubkey}
|
||||
{#if $app.user?.pubkey}
|
||||
<PrimaryNav>
|
||||
{@render children?.()}
|
||||
</PrimaryNav>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {PinboardReader} from "@welshman/domain"
|
||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import {type PublishedBoard} from "@app/pinboards"
|
||||
import {shareEventToChat} from "@app/share"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
board: PublishedBoard
|
||||
board: PinboardReader
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {getTagValue, getAddress} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import {getAddress, tagSpec, tagValue} from "@welshman/util"
|
||||
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {publishReaction, retractReaction} from "@app/reactions"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
import {publishReaction} from "@app/reactions"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {user} from "@app/core"
|
||||
import {makeCalendarPath, makeSpacePath} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
|
|
@ -27,17 +25,14 @@
|
|||
|
||||
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 shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
|
||||
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||
</script>
|
||||
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
|
|
@ -53,7 +48,7 @@
|
|||
{/if}
|
||||
<EventActions {url} {event} noun="Event">
|
||||
{#snippet customActions()}
|
||||
{#if event.pubkey === $pubkey}
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
<li>
|
||||
<Button onclick={editEvent}>
|
||||
<Icon size={4} icon={Pen2} />
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
<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>
|
||||
{/snippet}
|
||||
</CalendarEventForm>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
<script lang="ts">
|
||||
import {fromPairs, LOCALE, secondsToDate} from "@welshman/lib"
|
||||
import {LOCALE, secondsToDate} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {TimeEvent} from "@welshman/domain"
|
||||
import {reader} from "@app/core"
|
||||
|
||||
type Props = {
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{#if !isNaN(start)}
|
||||
{#if start}
|
||||
{@const startDate = secondsToDate(start)}
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import {TimeEvent} from "@welshman/domain"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import {reader} from "@app/core"
|
||||
import CalendarEventForm from "@app/components/CalendarEventForm.svelte"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -13,21 +14,27 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const initialValues = {
|
||||
d: getTagValue("d", event.tags)!,
|
||||
title: getTagValue("title", event.tags)!,
|
||||
location: getTagValue("location", event.tags)!,
|
||||
start: parseInt(getTagValue("start", event.tags)!),
|
||||
end: parseInt(getTagValue("end", event.tags)!),
|
||||
content: event.content,
|
||||
}
|
||||
const timeEvent = reader(TimeEvent)(event)
|
||||
|
||||
const initialValues = $derived(
|
||||
timeEvent && {
|
||||
d: timeEvent.identifier() ?? "",
|
||||
title: timeEvent.title() ?? "",
|
||||
location: timeEvent.location() ?? "",
|
||||
start: timeEvent.start(),
|
||||
end: timeEvent.end(),
|
||||
content: timeEvent.content(),
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<CalendarEventForm {url} {initialValues}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
<ModalTitle>Edit this Event</ModalTitle>
|
||||
<ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
{/snippet}
|
||||
</CalendarEventForm>
|
||||
{#if initialValues}
|
||||
<CalendarEventForm {url} {initialValues}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
<ModalTitle>Edit this Event</ModalTitle>
|
||||
<ModalSubtitle>Invite other room members to events online or in real life.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
{/snippet}
|
||||
</CalendarEventForm>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@
|
|||
import type {Snippet} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {randomId} from "@welshman/lib"
|
||||
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {publish} from "@welshman/app"
|
||||
import {TimeEvent} from "@welshman/domain"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {daysBetween} from "@lib/util"
|
||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
|
|
@ -18,11 +17,11 @@
|
|||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import DateTimeRangeInput from "@lib/components/DateTimeRangeInput.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 {DraftKey} from "@app/drafts"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {command, relays, writer} from "@app/core"
|
||||
|
||||
type Values = {
|
||||
d: string
|
||||
|
|
@ -49,7 +48,7 @@
|
|||
initialValues = draftKey.get()
|
||||
}
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const shouldProtect = $relays.hasNip(url, 70)
|
||||
|
||||
const uploading = writable(false)
|
||||
|
||||
|
|
@ -83,32 +82,29 @@
|
|||
|
||||
const ed = await editor
|
||||
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
|
||||
|
||||
try {
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
const eventWriter = writer(TimeEvent)
|
||||
.setIdentifier(d)
|
||||
.setTitle(title)
|
||||
.setLocation(location)
|
||||
.setStart(start)
|
||||
.setEnd(end)
|
||||
.setContent(content)
|
||||
.setProtected(protect)
|
||||
.addTags(...ed.storage.nostr.getEditorTags())
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
eventWriter.setRoom(url, h)
|
||||
} else {
|
||||
eventWriter.forceRelays(url)
|
||||
}
|
||||
|
||||
const event = makeEvent(EVENT_TIME, {content, tags})
|
||||
const calendarThunk = publishThunk({event, relays: [url]})
|
||||
const error = await waitForThunkError(calendarThunk)
|
||||
const calendarThunk = await command(eventWriter).then(publish)
|
||||
const error = await calendarThunk.waitForError()
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
fromPairs,
|
||||
formatTimestamp,
|
||||
formatTimestampAsDate,
|
||||
formatTimestampAsTime,
|
||||
} from "@welshman/lib"
|
||||
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/lib"
|
||||
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 Icon from "@lib/components/Icon.svelte"
|
||||
import {reader} from "@app/core"
|
||||
|
||||
type Props = {
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {event}: Props = $props()
|
||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
||||
const start = $derived(parseInt(meta.start))
|
||||
const end = $derived(parseInt(meta.end))
|
||||
|
||||
const timeEvent = reader(TimeEvent)(event)
|
||||
|
||||
// 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>
|
||||
|
||||
<div class="flex flex-col justify-between gap-1">
|
||||
<p class="text-lg">{meta.title || meta.name}</p>
|
||||
{#if !isNaN(start) && !isNaN(end)}
|
||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||
{@const endDateDisplay = formatTimestampAsDate(end)}
|
||||
{@const isSingleDay = startDateDisplay === endDateDisplay}
|
||||
<p class="text-lg">{title}</p>
|
||||
{#if start && end}
|
||||
{@const isSingleDay = formatTimestampAsDate(start) === formatTimestampAsDate(end)}
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={ClockCircle} size={4} />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
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 CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
||||
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const h = getTagValue("h", event.tags)
|
||||
const h = tagValue(tagSpec("h"), event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<script lang="ts">
|
||||
import {fromPairs} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {TimeEvent} from "@welshman/domain"
|
||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {reader} from "@app/core"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -12,7 +13,10 @@
|
|||
}
|
||||
|
||||
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>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
|
||||
|
|
@ -20,10 +24,10 @@
|
|||
<Icon icon={UserCircle} size={4} />
|
||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||
</span>
|
||||
{#if meta.location}
|
||||
{#if location}
|
||||
<span class="flex items-start gap-1">
|
||||
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
||||
<span class="wrap-break-word">{meta.location}</span>
|
||||
<span class="wrap-break-word">{location}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,22 +18,9 @@
|
|||
formatTimestampAsDate,
|
||||
} from "@welshman/lib"
|
||||
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 {
|
||||
makeEvent,
|
||||
tagsFromIMeta,
|
||||
getTags,
|
||||
DIRECT_MESSAGE,
|
||||
DIRECT_MESSAGE_FILE,
|
||||
} from "@welshman/util"
|
||||
import {
|
||||
pubkey,
|
||||
tagPubkey,
|
||||
sendWrapped,
|
||||
mergeThunks,
|
||||
loadMessagingRelayList,
|
||||
messagingRelayListsByPubkey,
|
||||
} from "@welshman/app"
|
||||
import {MessagingRelayLists, Thunks} from "@welshman/app"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -52,12 +39,12 @@
|
|||
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
|
||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||
import {app, deletes, user, wraps} from "@app/core"
|
||||
import {userSettingsValues} from "@app/settings"
|
||||
import {deriveChat, makeChatId} from "@app/chats"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {makeDelete} from "@app/deletes"
|
||||
import {prependParent} from "@app/groups"
|
||||
import {prependParent} from "@app/rooms"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -70,8 +57,9 @@
|
|||
const chatId = makeChatId(pubkeys)
|
||||
const chat = deriveChat(chatId)
|
||||
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`)
|
||||
const others = remove($pubkey!, pubkeys)
|
||||
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
||||
const others = remove($user.pubkey, pubkeys)
|
||||
const messagingRelayLists = $app.use(MessagingRelayLists).index.$
|
||||
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk)))
|
||||
|
||||
const showMembers = () =>
|
||||
others.length === 1
|
||||
|
|
@ -95,28 +83,26 @@
|
|||
|
||||
const onSubmit = async (params: EventContent) => {
|
||||
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
|
||||
params.tags = params.tags.filter(nthNe(0, "p"))
|
||||
|
||||
// Add our reply quote to content
|
||||
params = prependParent(parent, params)
|
||||
params = await prependParent(parent, params)
|
||||
|
||||
if (eventToEdit) {
|
||||
if (eventToEdit.content === params.content) {
|
||||
return
|
||||
}
|
||||
|
||||
await sendWrapped({
|
||||
event: makeDelete({event: eventToEdit, protect: false}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
const command = await $deletes.deleteEvent(eventToEdit)
|
||||
|
||||
await $wraps.publish({event: command.event, recipients: pubkeys, pow: 16})
|
||||
}
|
||||
|
||||
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 buffer = []
|
||||
|
||||
|
|
@ -141,11 +127,7 @@
|
|||
|
||||
if (isLink(p) && imeta) {
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
addTemplate(
|
||||
DIRECT_MESSAGE_FILE,
|
||||
p.value.url.toString(),
|
||||
imeta.slice(1).filter(nthNe(0, "url")),
|
||||
)
|
||||
addTemplate(DIRECT_MESSAGE_FILE, p.value.url.toString(), imeta.filter(nthNe(0, "url")))
|
||||
} else {
|
||||
buffer.push(p.raw)
|
||||
}
|
||||
|
|
@ -157,7 +139,7 @@
|
|||
// Sleep 1 second between each one to make sure timestamps are distinct
|
||||
const thunks = await Promise.all(
|
||||
Array.from(enumerate(templates)).map(([i, event]) =>
|
||||
sendWrapped({
|
||||
$wraps.publish({
|
||||
event,
|
||||
recipients: pubkeys,
|
||||
delay: $userSettingsValues.send_delay + ms(i),
|
||||
|
|
@ -170,7 +152,7 @@
|
|||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk: mergeThunks(thunks)},
|
||||
props: {thunk: $app.use(Thunks).merge(thunks)},
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
|
|
@ -185,7 +167,7 @@
|
|||
}
|
||||
|
||||
const canEditEvent = (event: TrustedEvent) =>
|
||||
event.pubkey === $pubkey &&
|
||||
event.pubkey === $user.pubkey &&
|
||||
event.kind === DIRECT_MESSAGE &&
|
||||
event.created_at >= ago(500, MINUTE)
|
||||
|
||||
|
|
@ -233,7 +215,7 @@
|
|||
|
||||
onMount(() => {
|
||||
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}>
|
||||
{#if others.length === 0}
|
||||
<div class="flex gap-2">
|
||||
<ProfileCircle pubkey={$pubkey!} size={5} />
|
||||
<ProfileName pubkey={$pubkey!} />
|
||||
<ProfileCircle pubkey={$user.pubkey} size={5} />
|
||||
<ProfileName pubkey={$user.pubkey} />
|
||||
</div>
|
||||
{:else if others.length === 1}
|
||||
<div class="flex gap-2">
|
||||
|
|
@ -294,7 +276,7 @@
|
|||
{#each missingRelayLists as pubkey (pubkey)}
|
||||
<ProfileLink {pubkey} />
|
||||
{/each}
|
||||
to enable direct messaging by opening this conversation in their app.
|
||||
to enable direct messaging by opening this conversation in their $app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {displayProfileByPubkey} from "@welshman/app"
|
||||
import {slide} from "@lib/transition"
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import {profiles} from "@app/core"
|
||||
|
||||
const {
|
||||
verb,
|
||||
event,
|
||||
clear,
|
||||
}: {
|
||||
type Props = {
|
||||
verb: string
|
||||
event: TrustedEvent
|
||||
clear: () => void
|
||||
} = $props()
|
||||
}
|
||||
|
||||
const {verb, event, clear}: Props = $props()
|
||||
|
||||
const display = $profiles.display(event.pubkey).$
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-surface-more relative border-l-2 border-solid px-2 py-1 pr-8"
|
||||
style="border-color: var(--primary)"
|
||||
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}
|
||||
<NoteContentMinimal trimParent {event} />
|
||||
{/key}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {getRelaysFromList} from "@welshman/util"
|
||||
import {waitForThunkError, setMessagingRelays, userRelayList, setRelays} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
|
|
@ -12,6 +10,7 @@
|
|||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.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 {pushToast} from "@app/toast"
|
||||
|
||||
|
|
@ -21,7 +20,7 @@
|
|||
|
||||
const {next}: Props = $props()
|
||||
|
||||
let loading = $state(false)
|
||||
const userRelayList = deriveUserItem($app => $relayLists)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
|
|
@ -29,8 +28,14 @@
|
|||
loading = true
|
||||
|
||||
try {
|
||||
if (getRelaysFromList($userRelayList).length === 0) {
|
||||
const error = await waitForThunkError(await setRelays(DEFAULT_RELAYS.map(r => ["r", r])))
|
||||
const relayUrls = $userRelayList?.urls() ?? []
|
||||
|
||||
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) {
|
||||
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) {
|
||||
pushToast({theme: "error", message: error})
|
||||
|
|
@ -50,6 +56,8 @@
|
|||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(enable)}>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
import {page} from "$app/stores"
|
||||
import {remove, uniq, formatTimestamp} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {pubkey, loadMessagingRelayList} from "@welshman/app"
|
||||
import {fade} from "@lib/transition"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
|
|
@ -12,6 +11,7 @@
|
|||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import {makeChatPath, goToChat} from "@app/routes"
|
||||
import {notifications} from "@app/notifications"
|
||||
import {messagingRelayLists, user} from "@app/core"
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
|
|
@ -22,14 +22,14 @@
|
|||
|
||||
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 path = makeChatPath(props.pubkeys)
|
||||
const openChat = () => goToChat(props.pubkeys)
|
||||
|
||||
onMount(() => {
|
||||
for (const pk of others) {
|
||||
loadMessagingRelayList(pk)
|
||||
$messagingRelayLists.load(pk)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
{#if others.length === 0}
|
||||
<ProfileCircle pubkey={$pubkey!} size={5} />
|
||||
<ProfileCircle pubkey={$user.pubkey} size={5} />
|
||||
Note to self
|
||||
{:else if others.length === 1}
|
||||
<ProfileCircle pubkey={others[0]} size={5} />
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
</div>
|
||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
<span class="opacity-70">
|
||||
{#if props.messages[0].pubkey === $pubkey}
|
||||
{#if props.messages[0].pubkey === $user.pubkey}
|
||||
You:
|
||||
{/if}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
pubkeys: string[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import {type Instance} from "tippy.js"
|
||||
import {hash, formatTimestampAsTime} from "@welshman/lib"
|
||||
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 MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -11,15 +11,15 @@
|
|||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
import TapTarget from "@lib/components/TapTarget.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import {publishWrappedReaction, retractWrappedReaction} from "@app/reactions"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ThunkFailure from "@app/components/ThunkFailure.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
|
||||
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
|
||||
import {app, profiles, user} from "@app/core"
|
||||
import {colors} from "@app/theme"
|
||||
import {makeDelete} from "@app/deletes"
|
||||
import {makeReaction} from "@app/reactions"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
interface Props {
|
||||
|
|
@ -33,23 +33,18 @@
|
|||
|
||||
const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props()
|
||||
|
||||
const isOwn = event.pubkey === $pubkey
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
|
||||
const isOwn = event.pubkey === $user.pubkey
|
||||
const profileDisplay = $profiles.display(event.pubkey).$
|
||||
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 reply = () => replyTo(event)
|
||||
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
|
||||
|
||||
const deleteReaction = (event: TrustedEvent) =>
|
||||
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys, pow: 16})
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractWrappedReaction(reaction, pubkeys)
|
||||
|
||||
const createReaction = (template: EventContent) =>
|
||||
sendWrapped({
|
||||
event: makeReaction({event, protect: false, ...template}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
const createReaction = (values: EventContent) => publishWrappedReaction(event, values, pubkeys)
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
||||
|
||||
|
|
@ -67,9 +62,7 @@
|
|||
let popoverIsVisible = $state(false)
|
||||
</script>
|
||||
|
||||
{#if thunk}
|
||||
<ThunkFailure showToastOnRetry {thunk} class="mt-1" />
|
||||
{/if}
|
||||
<ThunkFailure showToastOnRetry {thunk} class="mt-1" />
|
||||
<div
|
||||
data-event={event.id}
|
||||
class={cx("group flex items-center justify-end gap-1 px-2", {"flex-row-reverse": !isOwn})}>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,23 @@
|
|||
<script lang="ts">
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {sendWrapped} from "@welshman/app"
|
||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.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
|
||||
pubkeys: string[]
|
||||
}
|
||||
|
||||
const {event, pubkeys}: Props = $props()
|
||||
|
||||
const onEmoji = (emoji: NativeEmoji) =>
|
||||
sendWrapped({
|
||||
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
const onEmoji = async (emoji: NativeEmoji) => {
|
||||
const reaction = await $reactions.react(event, emoji.unicode)
|
||||
|
||||
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
|
||||
}
|
||||
</script>
|
||||
|
||||
<EmojiButton {onEmoji} class="button button-neutral button-xs join-item">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {sendWrapped} from "@welshman/app"
|
||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
|
|
@ -13,7 +12,7 @@
|
|||
import Button from "@lib/components/Button.svelte"
|
||||
import EmojiPicker from "@lib/components/EmojiPicker.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 {clip} from "@app/toast"
|
||||
|
||||
|
|
@ -26,14 +25,13 @@
|
|||
|
||||
const {event, pubkeys, reply, edit}: Props = $props()
|
||||
|
||||
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
|
||||
const onEmoji = async (emoji: NativeEmoji) => {
|
||||
history.back()
|
||||
sendWrapped({
|
||||
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
}).bind(undefined, event, pubkeys)
|
||||
|
||||
const reaction = await $reactions.react(event, emoji.unicode)
|
||||
|
||||
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
|
||||
}
|
||||
|
||||
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {uniq} from "@welshman/lib"
|
||||
import {loadMessagingRelayList} from "@welshman/app"
|
||||
import {decodePubkey} from "@lib/util"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
|
|
@ -18,6 +17,7 @@
|
|||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
||||
import {goToChat} from "@app/routes"
|
||||
import {messagingRelayLists} from "@app/core"
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
let pubkeys: string[] = $state([])
|
||||
|
||||
$effect(() => {
|
||||
pubkeys.forEach(pubkey => loadMessagingRelayList(pubkey))
|
||||
pubkeys.forEach(pubkey => $messagingRelayLists.load(pubkey))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,26 @@
|
|||
<script lang="ts">
|
||||
import {uniq} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {getTagValue, getTagValues, getAddress} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import {getAddress} from "@welshman/util"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
|
||||
import {normalizeTopic} from "@lib/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {publishReaction, retractReaction} from "@app/reactions"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ClassifiedStatus from "@app/components/ClassifiedStatus.svelte"
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
import {publishReaction} from "@app/reactions"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {reader, user} from "@app/core"
|
||||
import {makeClassifiedPath, makeSpacePath} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
showRoom?: boolean
|
||||
|
|
@ -30,18 +29,17 @@
|
|||
|
||||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const h = getTagValue("h", event.tags)
|
||||
const topics = getTagValues("t", event.tags)
|
||||
const classified = reader(Classified)(event)
|
||||
|
||||
const h = classified.room()
|
||||
const topics = classified.topics() ?? []
|
||||
const path = makeClassifiedPath(url, getAddress(event))
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const editClassified = () => pushModal(ClassifiedEdit, {url, event})
|
||||
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||
</script>
|
||||
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
|
|
@ -66,7 +64,7 @@
|
|||
{/if}
|
||||
<EventActions {url} {event} noun="Listing">
|
||||
{#snippet customActions()}
|
||||
{#if event.pubkey === $pubkey}
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
<li>
|
||||
<Button onclick={editClassified}>
|
||||
<Icon size={4} icon={Pen2} />
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script lang="ts">
|
||||
import {fromPairs} from "@welshman/lib"
|
||||
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 ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import {reader} from "@app/core"
|
||||
import ClassifiedForm from "@app/components/ClassifiedForm.svelte"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -14,19 +14,29 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const {content} = event
|
||||
const {d, title, status} = fromPairs(event.tags)
|
||||
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
|
||||
const images = getTagValues("image", event.tags)
|
||||
const topics = getTagValues("t", event.tags)
|
||||
const initialValues = {d, title, status, content, price: Number(price), currency, images, topics}
|
||||
const classified = reader(Classified)(event)
|
||||
|
||||
const initialValues = $derived(
|
||||
classified && {
|
||||
d: classified.identifier() ?? "",
|
||||
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>
|
||||
|
||||
<ClassifiedForm {url} {initialValues}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
<ModalTitle>Edit this Listing</ModalTitle>
|
||||
<ModalSubtitle>Advertise a job, sale, or need.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
{/snippet}
|
||||
</ClassifiedForm>
|
||||
{#if initialValues}
|
||||
<ClassifiedForm {url} {initialValues}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
<ModalTitle>Edit this Listing</ModalTitle>
|
||||
<ModalSubtitle>Advertise a job, sale, or need.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
{/snippet}
|
||||
</ClassifiedForm>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {removeUndefined, randomId, uniq} from "@welshman/lib"
|
||||
import {makeEvent, CLASSIFIED} from "@welshman/util"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {publish} from "@welshman/app"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import {normalizeTopic} from "@lib/util"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
|
|
@ -17,11 +17,11 @@
|
|||
import CurrencyInput from "@app/components/CurrencyInput.svelte"
|
||||
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {command, relays, writer} from "@app/core"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
||||
import {publishRoomQuote} from "@app/rooms"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {compressFileForUpload, uploadFile} from "@app/uploads"
|
||||
|
||||
type Values = {
|
||||
|
|
@ -51,8 +51,6 @@
|
|||
initialValues = draftKey.get()
|
||||
}
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const submit = async () => {
|
||||
|
|
@ -76,32 +74,11 @@
|
|||
})
|
||||
}
|
||||
|
||||
const tags = [
|
||||
["d", d],
|
||||
["title", title],
|
||||
["summary", content],
|
||||
["price", String(price), currency],
|
||||
["status", status],
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
]
|
||||
|
||||
for (const topic of topics) {
|
||||
tags.push(["t", topic])
|
||||
}
|
||||
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
const imageUrls: string[] = []
|
||||
|
||||
for (const image of images) {
|
||||
if (typeof image === "string") {
|
||||
tags.push(["image", image])
|
||||
imageUrls.push(image)
|
||||
} else {
|
||||
const {result, error} = await uploadFile(await compressFileForUpload(image), {url})
|
||||
|
||||
|
|
@ -113,17 +90,33 @@
|
|||
}
|
||||
|
||||
if (result) {
|
||||
tags.push(["image", result.url])
|
||||
imageUrls.push(result.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const classifiedThunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(CLASSIFIED, {content, tags}),
|
||||
})
|
||||
const protect = await $relays.hasNip(url, 70)
|
||||
|
||||
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) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<script lang="ts">
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
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 CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
|
||||
import {reader} from "@app/core"
|
||||
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
|
|
@ -18,10 +20,12 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const title = getTagValue("title", event.tags)
|
||||
const h = getTagValue("h", event.tags)
|
||||
const images = new Set(getTagValues("image", event.tags))
|
||||
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
|
||||
const classified = reader(Classified)(event)
|
||||
|
||||
const title = classified.title()
|
||||
const h = classified.room()
|
||||
const images = new Set(classified.images())
|
||||
const price = classified.price()
|
||||
</script>
|
||||
|
||||
<Link
|
||||
|
|
@ -31,7 +35,7 @@
|
|||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<p class="text-xl">
|
||||
{title} —
|
||||
<CurrencySymbol code={currency} />{price}
|
||||
<CurrencySymbol code={price?.currency ?? "SAT"} />{price?.amount ?? 0}
|
||||
</p>
|
||||
<p class="text-sm opacity-75">
|
||||
{formatTimestamp(event.created_at)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import {ucFirst} from "@lib/util"
|
||||
import Badge from "@lib/components/Badge.svelte"
|
||||
import {reader} from "@app/core"
|
||||
|
||||
type Props = {
|
||||
event: TrustedEvent
|
||||
|
|
@ -10,7 +11,9 @@
|
|||
|
||||
const {event}: Props = $props()
|
||||
|
||||
const status = getTagValue("status", event.tags)
|
||||
const classified = reader(Classified)(event)
|
||||
|
||||
const status = classified.status()
|
||||
</script>
|
||||
|
||||
{#if status}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {tagSpec, tagValue} from "@welshman/util"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import {publishReaction, retractReaction} from "@app/reactions"
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.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"
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
segment: string
|
||||
|
|
@ -18,15 +17,13 @@
|
|||
|
||||
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 deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import {fromNostrURI, getTagValue} from "@welshman/util"
|
||||
import {fromNostrURI, tagSpec, tagValue} from "@welshman/util"
|
||||
import {
|
||||
parse,
|
||||
truncate,
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
import {entityLink} from "@app/env"
|
||||
import {userSettingsValues} from "@app/settings"
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
event: any
|
||||
minLength?: number
|
||||
maxLength?: number
|
||||
|
|
@ -106,7 +106,7 @@
|
|||
const warning = $derived(
|
||||
warningDismissed
|
||||
? 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>) => {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
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 {preventDefault, stopPropagation} from "@lib/html"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
import {pushModal} from "@app/modal"
|
||||
import {PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
|
||||
import {IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/content"
|
||||
import {isRoomId} from "@app/groups"
|
||||
import {isRoomId} from "@app/rooms"
|
||||
|
||||
const {value, event} = $props()
|
||||
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
return [url, true]
|
||||
})
|
||||
|
||||
const fileType = getTagValue("file-type", event.tags) || ""
|
||||
const fileType = tagValue(tagSpec("file-type"), event.tags) || ""
|
||||
|
||||
const getVideoPoster = (videoUrl: string): string | undefined => {
|
||||
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
|
||||
|
|
@ -62,50 +62,48 @@
|
|||
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
||||
{:else}
|
||||
<Link {external} {href} class="my-2 block">
|
||||
<div class="overflow-hidden rounded-2xl">
|
||||
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
|
||||
<video
|
||||
controls
|
||||
src={url}
|
||||
poster={getVideoPoster(url)}
|
||||
preload="metadata"
|
||||
class="max-h-96 rounded-2xl object-contain object-center">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
|
||||
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
|
||||
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-2xl" />
|
||||
</button>
|
||||
{:else}
|
||||
{#await loadPreview(url)}
|
||||
<div class="flex justify-center items-center my-12 w-full">
|
||||
<Spinner />
|
||||
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
|
||||
<video
|
||||
controls
|
||||
src={url}
|
||||
poster={getVideoPoster(url)}
|
||||
preload="metadata"
|
||||
class="max-h-96 rounded-2xl object-contain object-center">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
|
||||
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
|
||||
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-2xl" />
|
||||
</button>
|
||||
{:else}
|
||||
{#await loadPreview(url)}
|
||||
<div class="flex justify-center items-center my-12 w-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:then preview}
|
||||
<div
|
||||
class="border border-solid flex max-w-xl flex-col overflow-hidden leading-normal rounded-2xl"
|
||||
style="border-color: var(--line)">
|
||||
{#if preview.image && !hideImage}
|
||||
<img
|
||||
alt=""
|
||||
onerror={onError}
|
||||
src={preview.image}
|
||||
class="bg-surface max-h-72 object-contain object-center" />
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>{preview.title || displayUrl(url)}</strong>
|
||||
<p>{ellipsize(preview.description, 140)}</p>
|
||||
</div>
|
||||
{:then preview}
|
||||
<div
|
||||
class="border border-solid flex max-w-xl flex-col leading-normal rounded-2xl"
|
||||
style="border-color: var(--line)">
|
||||
{#if preview.image && !hideImage}
|
||||
<img
|
||||
alt=""
|
||||
onerror={onError}
|
||||
src={preview.image}
|
||||
class="bg-surface max-h-72 rounded-t-2xl object-contain object-center" />
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>{preview.title || displayUrl(url)}</strong>
|
||||
<p>{ellipsize(preview.description, 140)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:catch}
|
||||
<p
|
||||
class="border border-solid p-12 text-center leading-normal"
|
||||
style="border-color: var(--line)">
|
||||
Unable to load a preview for {url}
|
||||
</p>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:catch}
|
||||
<p
|
||||
class="border border-solid p-12 text-center leading-normal rounded-2xl"
|
||||
style="border-color: var(--line)">
|
||||
Unable to load a preview for {url}
|
||||
</p>
|
||||
{/await}
|
||||
{/if}
|
||||
</Link>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -2,34 +2,35 @@
|
|||
import {onMount, onDestroy} from "svelte"
|
||||
import {displayUrl, once} from "@welshman/lib"
|
||||
import {
|
||||
getTags,
|
||||
getBlob,
|
||||
decryptFile,
|
||||
getTagValue,
|
||||
tagsFromIMeta,
|
||||
makeBlossomAuthEvent,
|
||||
matchTags,
|
||||
tagSpec,
|
||||
tagValue,
|
||||
} from "@welshman/util"
|
||||
import {signer} from "@welshman/app"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {user} from "@app/core"
|
||||
|
||||
const {value, event, ...props} = $props()
|
||||
|
||||
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 =
|
||||
getTags("imeta", event.tags)
|
||||
.map(tagsFromIMeta)
|
||||
.find(meta => getTagValue("url", meta) === url) || event.tags
|
||||
matchTags(tagSpec("imeta"), event.tags)
|
||||
.map(([, ...values]: string[]) => values.map(value => value.split(" ")))
|
||||
.find(meta => tagValue(tagSpec("url"), meta) === url) || event.tags
|
||||
|
||||
// Fallback to filename if hash was omitted from the message for interoperability
|
||||
const hash = getTagValue("x", meta) || url.split(/[\/\.]/).slice(-2)[0]
|
||||
const key = getTagValue("decryption-key", meta)
|
||||
const nonce = getTagValue("decryption-nonce", meta)
|
||||
const algorithm = getTagValue("encryption-algorithm", meta)
|
||||
const mime = getTagValue("m", meta)
|
||||
const hash = tagValue(tagSpec("x"), meta) || url.split(/[\/\.]/).slice(-2)[0]
|
||||
const key = tagValue(tagSpec("decryption-key"), meta)
|
||||
const nonce = tagValue(tagSpec("decryption-nonce"), meta)
|
||||
const algorithm = tagValue(tagSpec("encryption-algorithm"), meta)
|
||||
const mime = tagValue(tagSpec("m"), meta)
|
||||
const fileName =
|
||||
getTagValue("filename", meta) ||
|
||||
getTagValue("name", meta) ||
|
||||
tagValue(tagSpec("filename"), meta) ||
|
||||
tagValue(tagSpec("name"), meta) ||
|
||||
decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).at(-1) || "image")
|
||||
|
||||
const revokeSrc = () => {
|
||||
|
|
@ -45,10 +46,10 @@
|
|||
|
||||
const onError = once(async () => {
|
||||
// If the image failed to load, try authenticating
|
||||
if (hash && $signer) {
|
||||
if (hash) {
|
||||
const server = new URL(url).origin
|
||||
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})
|
||||
|
||||
if (res.status === 200) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import {displayUrl} from "@welshman/lib"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import {tagSpec, tagValue} from "@welshman/util"
|
||||
import {preventDefault, stopPropagation} from "@lib/html"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
const {value, event} = $props()
|
||||
|
||||
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})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
<script lang="ts">
|
||||
import {call, displayUrl} from "@welshman/lib"
|
||||
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
||||
import {splitRoomKey} from "@welshman/app"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {PLATFORM_URL} from "@app/env"
|
||||
import {isRoomId, splitRoomId} from "@app/groups"
|
||||
import {isRoomId} from "@app/rooms"
|
||||
import {makeRoomPath, makeSpacePath} from "@app/routes"
|
||||
|
||||
const {
|
||||
url,
|
||||
class: className = "",
|
||||
}: {
|
||||
type Props = {
|
||||
url: string
|
||||
class?: string
|
||||
} = $props()
|
||||
}
|
||||
|
||||
const {url, class: className = ""}: Props = $props()
|
||||
|
||||
const roomReference = call(() => {
|
||||
if (!isRoomId(url)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [roomUrl, h] = splitRoomId(url)
|
||||
const [roomUrl, h] = splitRoomKey(url)
|
||||
|
||||
if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
|
||||
return undefined
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
<script lang="ts">
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import type {ProfilePointer} from "@welshman/content"
|
||||
import {deriveProfileDisplay} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {profiles} from "@app/core"
|
||||
|
||||
type Props = {
|
||||
value: ProfilePointer
|
||||
|
|
@ -13,7 +12,7 @@
|
|||
|
||||
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})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {fromNostrURI, getTagValue} from "@welshman/util"
|
||||
import {fromNostrURI, tagValue, tagSpec} from "@welshman/util"
|
||||
import {
|
||||
parse,
|
||||
truncate,
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
const warning = $derived(
|
||||
warningDismissed
|
||||
? 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>) => {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
<script lang="ts">
|
||||
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 {Address, MESSAGE} from "@welshman/util"
|
||||
import {Address, MESSAGE, eventOutbox, relays as relaySelections, seen} from "@welshman/util"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import {router} from "@app/core"
|
||||
import {deriveEvent} from "@app/repository"
|
||||
import {entityLink} from "@app/env"
|
||||
import {goToEvent} from "@app/routes"
|
||||
|
|
@ -22,22 +25,37 @@
|
|||
|
||||
const {id, identifier, kind, pubkey, relays = []} = value
|
||||
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) {
|
||||
mergedRelays.push(url)
|
||||
}
|
||||
// Start with the hints we were handed, then widen to everything the router can work out:
|
||||
// where the quoted event has been seen, its author's outbox, and — since whoever quoted it
|
||||
// must have seen it — the relays the quoting event came from.
|
||||
const mergedRelays = writable(removeUndefined([...relays, url]))
|
||||
|
||||
const quote = deriveEvent(idOrAddress, mergedRelays)
|
||||
const entity = id
|
||||
? nip19.neventEncode({id, relays: mergedRelays})
|
||||
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
|
||||
$router.resolver
|
||||
.relays([
|
||||
...relaySelections(removeUndefined([url])),
|
||||
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 = () => {
|
||||
if ($quote) {
|
||||
goToEvent($quote)
|
||||
} else {
|
||||
window.open(entityLink(entity))
|
||||
window.open(entityLink($entity))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import {spec} from "@welshman/lib"
|
||||
import cx from "classnames"
|
||||
import {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 popover: Instance | undefined = $state()
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@
|
|||
loading = true
|
||||
|
||||
try {
|
||||
const error = await setFeaturedContent(url, content)
|
||||
const thunk = await setFeaturedContent(url, content)
|
||||
const error = await thunk.waitForError()
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
|
|
@ -39,6 +40,9 @@
|
|||
pushToast({message: "Featured content updated!"})
|
||||
back()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
pushToast({theme: "error", message: "Failed to update featured content."})
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@
|
|||
import type {Instance} from "tippy.js"
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {tagSpec, tagValue} from "@welshman/util"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ZapButton from "@app/components/ZapButton.svelte"
|
||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
import ZapButton from "@app/components/ZapButton.svelte"
|
||||
import EventMenu from "@app/components/EventMenu.svelte"
|
||||
import {reactions, relays} from "@app/core"
|
||||
import {ENABLE_ZAPS} from "@app/env"
|
||||
import {publishReaction} from "@app/reactions"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
|
@ -26,20 +26,27 @@
|
|||
|
||||
const {url, noun, event, hideZap, customActions}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const shouldProtect = $relays.hasNip(url, 70)
|
||||
|
||||
const showPopover = () => popover?.show()
|
||||
|
||||
const hidePopover = () => popover?.hide()
|
||||
|
||||
const onEmoji = async (emoji: NativeEmoji) =>
|
||||
publishReaction({
|
||||
event,
|
||||
content: emoji.unicode,
|
||||
relays: [url],
|
||||
protect: await shouldProtect,
|
||||
const onEmoji = async (emoji: NativeEmoji) => {
|
||||
const protect = await shouldProtect
|
||||
const room = tagValue(tagSpec("h"), event.tags)
|
||||
|
||||
const command = await $reactions.react(event, emoji.unicode, writer => {
|
||||
writer.setProtected(protect)
|
||||
|
||||
if (room) {
|
||||
writer.setRoom(url, room)
|
||||
}
|
||||
})
|
||||
|
||||
return command.publishToRelays([url])
|
||||
}
|
||||
|
||||
let popover: Instance | undefined = $state()
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,23 +2,22 @@
|
|||
import {onMount} from "svelte"
|
||||
import {max, gt, formatTimestampRelative} from "@welshman/lib"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
import {load} from "@welshman/net"
|
||||
import {deriveArray, deriveEventsById} from "@welshman/store"
|
||||
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 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 checked = deriveChecked(path)
|
||||
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)))
|
||||
|
||||
onMount(() => {
|
||||
load({relays: [url], filters})
|
||||
$network.load({relays: [url], filters})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {deletes, relays} from "@app/core"
|
||||
import {clearModals} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -12,10 +11,13 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const shouldProtect = $relays.hasNip(url, 70)
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {Router} from "@welshman/router"
|
||||
import {LOCALE, secondsToDate} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {displayRelayUrl, toNostrURI} from "@welshman/util"
|
||||
import {tracker} from "@welshman/app"
|
||||
import {displayRelayUrl, seen, toNostrURI} from "@welshman/util"
|
||||
import FileText from "@assets/icons/file-text.svg?dataurl"
|
||||
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||
|
|
@ -18,6 +17,7 @@
|
|||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import {app, router} from "@app/core"
|
||||
import {clip} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -27,9 +27,7 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
||||
const nevent1 = toNostrURI(nip19.neventEncode({...event, relays}))
|
||||
const seenOn = tracker.getRelays(event.id)
|
||||
const seenOn = $app.tracker.getRelays(event.id)
|
||||
const npub1 = nip19.npubEncode(event.pubkey)
|
||||
const json = JSON.stringify(event, null, 2)
|
||||
const copyLink = () => clip(nevent1)
|
||||
|
|
@ -41,6 +39,14 @@
|
|||
dateStyle: "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>
|
||||
|
||||
<Modal>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
import {onMount} from "svelte"
|
||||
import type {Snippet} from "svelte"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT, ManagementMethod} from "@welshman/util"
|
||||
import {pubkey, repository, manageRelay} from "@welshman/app"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
|
|
@ -17,9 +16,10 @@
|
|||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||
import PinboardSelect from "@app/components/PinboardSelect.svelte"
|
||||
import {shareEventToChat} from "@app/share"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/members"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/management"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {app, relayManagement, user} from "@app/core"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
|
@ -49,16 +49,13 @@
|
|||
title: `Delete ${noun}`,
|
||||
message: `Are you sure you want to delete this ${noun.toLowerCase()} from the space?`,
|
||||
confirm: async () => {
|
||||
const {error} = await manageRelay(url, {
|
||||
method: ManagementMethod.BanEvent,
|
||||
params: [event.id],
|
||||
})
|
||||
const {error} = await $relayManagement.forUrl(url).banEvent(event.id)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Event has successfully been deleted!"})
|
||||
repository.removeEvent(event.id)
|
||||
$app.repository.removeEvent(event.id)
|
||||
history.back()
|
||||
}
|
||||
},
|
||||
|
|
@ -93,7 +90,7 @@
|
|||
</Button>
|
||||
</li>
|
||||
{@render customActions?.()}
|
||||
{#if event.pubkey === $pubkey}
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
<li>
|
||||
<Button onclick={showDelete} class="text-error">
|
||||
<Icon size={4} icon={TrashBin2} />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {publishToRelays} from "@welshman/app"
|
||||
import {Comment} from "@welshman/domain"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import {fly} from "@lib/transition"
|
||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||
|
|
@ -10,12 +12,11 @@
|
|||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||
import {publishComment} from "@app/comments"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {PROTECTED, prependParent} from "@app/groups"
|
||||
import {prependParent} from "@app/rooms"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {command, relays, writer} from "@app/core"
|
||||
|
||||
type Values = {
|
||||
content?: string | object
|
||||
|
|
@ -33,7 +34,6 @@
|
|||
const {url, event, parent, onClose, onClearParent, onSubmit}: Props = $props()
|
||||
const draftKey = new DraftKey<Values>(`reply:${event.id}:${parent?.id || ""}`)
|
||||
const initialValues = draftKey.get()
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const uploading = writable(false)
|
||||
const autofocus = !isMobile
|
||||
|
||||
|
|
@ -46,10 +46,6 @@
|
|||
let content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
let tags = ed.storage.nostr.getEditorTags()
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
|
|
@ -58,11 +54,20 @@
|
|||
}
|
||||
|
||||
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()
|
||||
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
||||
onSubmit(thunk)
|
||||
}
|
||||
|
||||
let form: HTMLElement
|
||||
|
|
|
|||
|
|
@ -14,10 +14,18 @@
|
|||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
|
||||
import {roomsByUrl} from "@app/groups"
|
||||
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()
|
||||
|
||||
|
|
@ -40,14 +48,14 @@
|
|||
<ModalSubtitle>Which room would you like to share this event to?</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each $roomsByUrl.get(url) || [] as room (room.h)}
|
||||
{#each $spaceRooms as room (room.h)}
|
||||
<Button
|
||||
type="button"
|
||||
class="flex justify-center button {selection === room.h
|
||||
? 'button-primary'
|
||||
: 'button-neutral'}"
|
||||
onclick={() => toggleRoom(room.h)}>
|
||||
<RoomNameWithImage {...room} />
|
||||
<RoomNameWithImage {url} h={room.h} />
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
<script lang="ts">
|
||||
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 {reader} from "@app/core"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import {publishReaction, retractReaction} from "@app/reactions"
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.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"
|
||||
|
||||
interface Props {
|
||||
|
|
@ -22,14 +21,13 @@
|
|||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const path = makeGoalPath(url, event.id)
|
||||
const h = getTagValue("h", event.tags)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const goal = reader(ZapGoal)(event)
|
||||
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
const h = goal.room()
|
||||
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url})
|
||||
|
||||
const createReaction = (values: EventContent) => publishReaction(event, values, {url})
|
||||
</script>
|
||||
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import {writable} from "svelte/store"
|
||||
import {makeEvent, ZAP_GOAL} from "@welshman/util"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {ZapGoal} from "@welshman/domain"
|
||||
import {publish} from "@welshman/app"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
|
|
@ -18,11 +18,11 @@
|
|||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {command, relays, writer} from "@app/core"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
||||
import {publishRoomQuote} from "@app/rooms"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
|
||||
type Values = {
|
||||
title: string
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
initialValues = draftKey.get()
|
||||
}
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const shouldProtect = $relays.hasNip(url, 70)
|
||||
|
||||
const uploading = writable(false)
|
||||
|
||||
|
|
@ -73,32 +73,25 @@
|
|||
})
|
||||
}
|
||||
|
||||
const tags = [
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
["summary", content],
|
||||
["amount", String(amount)],
|
||||
["relays", url],
|
||||
]
|
||||
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
const eventWriter = writer(ZapGoal)
|
||||
.setTitle(title)
|
||||
.setSummary(content)
|
||||
.setAmount(amount)
|
||||
.setUrls([url])
|
||||
.setProtected(protect)
|
||||
.addTags(...ed.storage.nostr.getEditorTags())
|
||||
.forceRelays(url)
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
eventWriter.setRoom(url, h)
|
||||
}
|
||||
|
||||
const goalThunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(ZAP_GOAL, {content: title, tags}),
|
||||
})
|
||||
|
||||
const error = await waitForThunkError(goalThunk)
|
||||
const goalThunk = await command(eventWriter).then(publish)
|
||||
const error = await goalThunk.waitForError()
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import {ZapGoal} from "@welshman/domain"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import {reader} from "@app/core"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import GoalActions from "@app/components/GoalActions.svelte"
|
||||
|
|
@ -16,14 +17,17 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const summary = getTagValue("summary", event.tags)
|
||||
const h = getTagValue("h", event.tags)
|
||||
const goal = reader(ZapGoal)(event)
|
||||
|
||||
const title = goal.title()
|
||||
const summary = goal.summary()
|
||||
const h = goal.room()
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="cv flex flex-col gap-2 card card-interactive w-full"
|
||||
href={makeGoalPath(url, event.id)}>
|
||||
<p class="text-2xl">{event.content}</p>
|
||||
<p class="text-2xl">{title}</p>
|
||||
<Content
|
||||
event={{content: summary, tags: event.tags}}
|
||||
{url}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
<script lang="ts">
|
||||
import {derived} from "svelte/store"
|
||||
import {now, DAY, uniq, sum} from "@welshman/lib"
|
||||
import type {Zap, TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
|
||||
import {deriveItemsByKey, deriveArray} from "@welshman/store"
|
||||
import {repository, getValidZap} from "@welshman/app"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {fromMsats, ZAP_RECEIPT} from "@welshman/util"
|
||||
import {ZapGoal} from "@welshman/domain"
|
||||
import type {Zap} from "@welshman/domain"
|
||||
import {Zappers} from "@welshman/app"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {deriveEvents} from "@app/repository"
|
||||
import ZapButton from "@app/components/ZapButton.svelte"
|
||||
import {app, reader} from "@app/core"
|
||||
|
||||
type Props = {
|
||||
url?: string
|
||||
|
|
@ -16,18 +20,23 @@
|
|||
|
||||
const {url, event, ...props}: Props = $props()
|
||||
|
||||
const zaps = deriveArray(
|
||||
deriveItemsByKey<Zap>({
|
||||
repository,
|
||||
getKey: zap => zap.response.id,
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
||||
}),
|
||||
const receipts = deriveEvents([{kinds: [ZAP_RECEIPT], "#e": [event.id]}])
|
||||
|
||||
const zaps = derived<typeof receipts, Zap[]>(
|
||||
receipts,
|
||||
($receipts, set) => $app.use(Zappers).validZapReceipts($receipts, event).$.subscribe(set),
|
||||
[],
|
||||
)
|
||||
|
||||
const goalAmount = parseInt(getTagValue("amount", event.tags) || "0")
|
||||
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
|
||||
const contributorsCount = $derived(uniq($zaps.map(zap => zap.request.pubkey)).length)
|
||||
const goal = reader(ZapGoal)(event)
|
||||
|
||||
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)
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {session} from "@welshman/app"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
|
|
@ -11,8 +10,10 @@
|
|||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import KeyRecoveryRequest from "@app/components/KeyRecoveryRequest.svelte"
|
||||
import {isPomadeSession} from "@app/pomade"
|
||||
import {PLATFORM_NAME} from "@app/env"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {session} from "@app/core"
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
|
|
@ -36,7 +37,7 @@
|
|||
<strong>public key</strong>, which acts as your user ID, and a
|
||||
<strong>private key</strong> which allows you to prove your identity.
|
||||
</p>
|
||||
{#if $session?.email}
|
||||
{#if isPomadeSession($session)}
|
||||
<p>
|
||||
It's very important to keep private keys safe, but this can sometimes be tricky, which is
|
||||
why {PLATFORM_NAME}
|
||||
|
|
@ -47,7 +48,7 @@
|
|||
<Button class="button button-primary" onclick={back}>Got it</Button>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
{#if $session?.email}
|
||||
{#if isPomadeSession($session)}
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import {deriveZapperForPubkey} from "@welshman/app"
|
||||
import {Zappers} from "@welshman/app"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -9,10 +9,15 @@
|
|||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.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()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
<script lang="ts">
|
||||
import {Client} from "@pomade/core"
|
||||
import {getPubkey} from "@welshman/util"
|
||||
import type {SessionPomade} from "@welshman/app"
|
||||
import {session} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
|
|
@ -19,6 +17,8 @@
|
|||
import KeyDownload from "@app/components/KeyDownload.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {pushModal, clearModals} from "@app/modal"
|
||||
import {session} from "@app/core"
|
||||
import {requirePomadeSession} from "@app/pomade"
|
||||
|
||||
type Props = {
|
||||
peersByPrefix: Map<string, string>
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
const {
|
||||
email,
|
||||
clientOptions: {secret, peers},
|
||||
} = $session as SessionPomade
|
||||
} = requirePomadeSession($session).data
|
||||
|
||||
const confirmRecovery = async () => {
|
||||
const request = await Client.recoverWithChallenge(email, peersByPrefix, otps)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<script lang="ts">
|
||||
import {Client} from "@pomade/core"
|
||||
import type {SessionPomade} from "@welshman/app"
|
||||
import {session} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
|
|
@ -15,12 +13,14 @@
|
|||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {session} from "@app/core"
|
||||
import {requirePomadeSession} from "@app/pomade"
|
||||
import KeyRecoveryConfirm from "@app/components/KeyRecoveryConfirm.svelte"
|
||||
|
||||
const {
|
||||
email,
|
||||
clientOptions: {peers},
|
||||
} = $session as SessionPomade
|
||||
} = requirePomadeSession($session).data
|
||||
|
||||
const requestRecovery = async () => {
|
||||
const {peersByPrefix} = await Client.requestChallenge(email, peers)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import {onMount} from "svelte"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
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 Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import Cpu from "@assets/icons/cpu-bolt.svg?dataurl"
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
import {PLATFORM_NAME, POMADE_SIGNERS} from "@app/env"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {setChecked} from "@app/notifications"
|
||||
import {login} from "@app/core"
|
||||
|
||||
let signers: any[] = $state([])
|
||||
let loading: string | undefined = $state()
|
||||
|
|
@ -35,7 +37,7 @@
|
|||
const signUp = () => pushModal(SignUp)
|
||||
|
||||
const onSuccess = async (session: Session) => {
|
||||
addSession(session)
|
||||
await login(session)
|
||||
setChecked("*")
|
||||
clearModals()
|
||||
}
|
||||
|
|
@ -47,7 +49,7 @@
|
|||
const pubkey = await getNip07()?.getPublicKey()
|
||||
|
||||
if (pubkey) {
|
||||
await onSuccess(makeNip07Session(pubkey))
|
||||
await onSuccess(toSession(nip07, {}))
|
||||
} else {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
|
|
@ -59,15 +61,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
const loginWithNip55 = async (app: any) => {
|
||||
const loginWithNip55 = async (signerApp: any) => {
|
||||
loading = "nip55"
|
||||
|
||||
try {
|
||||
const signer = new Nip55Signer(app.packageName)
|
||||
const signer = new Nip55Signer(signerApp.packageName)
|
||||
const pubkey = await signer.getPubkey()
|
||||
|
||||
if (pubkey) {
|
||||
await onSuccess(makeNip55Session(pubkey, app.packageName))
|
||||
await onSuccess(toSession(nip55, {pubkey, signer: signerApp.packageName}))
|
||||
} else {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
|
|
@ -112,14 +114,14 @@
|
|||
Log in with Extension
|
||||
</Button>
|
||||
{/if}
|
||||
{#each signers as app}
|
||||
<Button {disabled} class="button button-primary" onclick={() => loginWithNip55(app)}>
|
||||
{#each signers as signerApp}
|
||||
<Button {disabled} class="button button-primary" onclick={() => loginWithNip55(signerApp)}>
|
||||
{#if loading === "nip55"}
|
||||
<Spinner size="sm" class="mr-3" />
|
||||
{:else}
|
||||
<img src={app.iconUrl} alt={app.name} width="20" height="20" />
|
||||
<img src={signerApp.iconUrl} alt={signerApp.name} width="20" height="20" />
|
||||
{/if}
|
||||
Log in with {app.name}
|
||||
Log in with {signerApp.name}
|
||||
</Button>
|
||||
{/each}
|
||||
{#if hasPomade && !hasSigner}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import type {Nip46ResponseWithResult} from "@welshman/signer"
|
||||
import {Nip46Broker} from "@welshman/signer"
|
||||
import {makeSecret} from "@welshman/util"
|
||||
import {loginWithNip01, loginWithNip46} from "@welshman/app"
|
||||
import {nip01, nip46, toSession} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import BunkerConnect from "@app/components/BunkerConnect.svelte"
|
||||
import BunkerUrl from "@app/components/BunkerUrl.svelte"
|
||||
import {login} from "@app/core"
|
||||
import {Nip46Controller} from "@app/nip46"
|
||||
import {clearModals} from "@app/modal"
|
||||
import {setChecked} from "@app/notifications"
|
||||
|
|
@ -36,16 +37,16 @@
|
|||
|
||||
const controller = new Nip46Controller({
|
||||
onNostrConnect: async (response: Nip46ResponseWithResult) => {
|
||||
const pubkey = await controller.broker.getPublicKey()
|
||||
|
||||
// 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.
|
||||
loginWithNip46(
|
||||
pubkey,
|
||||
controller.clientSecret,
|
||||
response.event.pubkey,
|
||||
controller.broker.params.relays,
|
||||
await login(
|
||||
toSession(nip46, {
|
||||
clientSecret: controller.clientSecret,
|
||||
signerPubkey: response.event.pubkey,
|
||||
relays: controller.broker.params.relays,
|
||||
}),
|
||||
)
|
||||
|
||||
setChecked("*")
|
||||
clearModals()
|
||||
},
|
||||
|
|
@ -86,7 +87,7 @@
|
|||
controller.stop()
|
||||
|
||||
// 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("*")
|
||||
} else {
|
||||
return pushToast({
|
||||
|
|
@ -128,7 +129,7 @@
|
|||
$effect(() => {
|
||||
// For testing and for play store reviewers
|
||||
if ($bunker === "reviewkey") {
|
||||
loginWithNip01(makeSecret())
|
||||
login(toSession(nip01, {secret: makeSecret()}))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import {uniq} from "@welshman/lib"
|
||||
import {Client} from "@pomade/core"
|
||||
import {uniq} from "@welshman/lib"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
import {setChecked} from "@app/notifications"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
email?: string
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
|
||||
|
||||
if (res.ok && clientOptions) {
|
||||
loginWithPomade(clientOptions, email)
|
||||
await loginWithPomade(clientOptions, email)
|
||||
deleteDeactivatedPomadeSessions()
|
||||
setChecked("*")
|
||||
clearModals()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import {bytesToHex} from "@welshman/lib"
|
||||
import {loginWithNip01} from "@welshman/app"
|
||||
import {decrypt} from "nostr-tools/nip49"
|
||||
import {bytesToHex} from "@welshman/lib"
|
||||
import {nip01, toSession} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {nsecDecode} from "@lib/util"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {login} from "@app/core"
|
||||
import {clearModals} from "@app/modal"
|
||||
import {setChecked} from "@app/notifications"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
|
@ -56,7 +57,7 @@
|
|||
})
|
||||
}
|
||||
|
||||
loginWithNip01(secret)
|
||||
await login(toSession(nip01, {secret}))
|
||||
setChecked("*")
|
||||
clearModals()
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
const {clientOptions, ...res} = await Client.selectLogin(clientSecret, client, peers)
|
||||
|
||||
if (res.ok && clientOptions) {
|
||||
loginWithPomade(clientOptions, email)
|
||||
await loginWithPomade(clientOptions, email)
|
||||
deleteDeactivatedPomadeSessions()
|
||||
setChecked("*")
|
||||
clearModals()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import ServerPath from "@assets/icons/server-path.svg?dataurl"
|
||||
import RemoteControlMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
|
||||
import GalleryMinimalistic from "@assets/icons/gallery-minimalistic.svg?dataurl"
|
||||
|
|
@ -14,6 +13,7 @@
|
|||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import LogOut from "@app/components/LogOut.svelte"
|
||||
import {user} from "@app/core"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {theme} from "@app/theme"
|
||||
|
||||
|
|
@ -24,11 +24,9 @@
|
|||
<Modal>
|
||||
<ModalBody>
|
||||
<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">
|
||||
<Profile inert pubkey={$pubkey} />
|
||||
</Link>
|
||||
{/if}
|
||||
<Link replaceState href="/settings/profile">
|
||||
<Profile inert pubkey={$user.pubkey} />
|
||||
</Link>
|
||||
<div class="grid grid-cols-3 gap-3 w-full">
|
||||
<Link
|
||||
replaceState
|
||||
|
|
|
|||
|
|
@ -11,14 +11,7 @@
|
|||
import {goToEvent} from "@app/routes"
|
||||
import {isEventMuted} from "@app/social"
|
||||
|
||||
const {
|
||||
event,
|
||||
children,
|
||||
minimal = false,
|
||||
hideProfile = false,
|
||||
url,
|
||||
...restProps
|
||||
}: {
|
||||
type Props = {
|
||||
event: TrustedEvent
|
||||
children?: Snippet
|
||||
minimal?: boolean
|
||||
|
|
@ -26,12 +19,16 @@
|
|||
url?: string
|
||||
class?: string
|
||||
style?: string
|
||||
} = $props()
|
||||
}
|
||||
|
||||
const {event, children, minimal = false, hideProfile = false, url, ...restProps}: Props = $props()
|
||||
|
||||
const ignoreMute = () => {
|
||||
muted = false
|
||||
}
|
||||
|
||||
const goToNote = () => goToEvent(event)
|
||||
|
||||
let muted = $state($isEventMuted(event))
|
||||
</script>
|
||||
|
||||
|
|
@ -55,7 +52,7 @@
|
|||
{/if}
|
||||
<Button
|
||||
class={cx("text-sm opacity-75 @max-sm:hidden", {"text-xs": minimal})}
|
||||
onclick={() => goToEvent(event)}>
|
||||
onclick={goToNote}>
|
||||
{formatTimestamp(event.created_at)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
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 NoteContentThread from "@app/components/NoteContentThread.svelte"
|
||||
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
import NoteContentPin from "@app/components/NoteContentPin.svelte"
|
||||
import NoteContentBoard from "@app/components/NoteContentBoard.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import {PIN, BOARD} from "@app/pinboards"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
</script>
|
||||
|
|
@ -27,7 +26,7 @@
|
|||
<NoteContentPoll {...props} />
|
||||
{:else if props.event.kind === PIN}
|
||||
<NoteContentPin {...props} />
|
||||
{:else if props.event.kind === BOARD}
|
||||
{:else if props.event.kind === PINBOARD}
|
||||
<NoteContentBoard {...props} />
|
||||
{:else}
|
||||
<Content {...props} />
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {Pinboard} from "@welshman/domain"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import {readBoard} from "@app/pinboards"
|
||||
import {reader} from "@app/core"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
|
||||
const board = readBoard(props.event)
|
||||
const board = reader(Pinboard)(props.event)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<strong class="text-lg">{board.title || "Untitled shelf"}</strong>
|
||||
{#if board.description}
|
||||
<span class="text-sm opacity-70">{board.description}</span>
|
||||
<strong class="text-lg">{board?.title() || "Untitled shelf"}</strong>
|
||||
{#if board?.description()}
|
||||
<span class="text-sm opacity-70">{board.description()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
<script lang="ts">
|
||||
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 {reader} from "@app/core"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
|
||||
const title = getTagValue("title", props.event.tags)
|
||||
const images = getTagValues("image", props.event.tags)
|
||||
const [_, price = 0, currency = "SAT"] = getTag("price", props.event.tags) || []
|
||||
const classified = reader(Classified)(props.event)
|
||||
|
||||
const title = classified.title()
|
||||
const images = classified.images() ?? []
|
||||
const price = classified.price()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if title}
|
||||
<p class="text-xl">
|
||||
{title} —
|
||||
<CurrencySymbol code={currency} />{price}
|
||||
<CurrencySymbol code={price?.currency ?? "SAT"} />{price?.amount ?? 0}
|
||||
</p>
|
||||
{/if}
|
||||
{#if props.event.content}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
<script lang="ts">
|
||||
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 GoalSummary from "@app/components/GoalSummary.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
|
||||
const content = getTagValue("summary", props.event.tags)
|
||||
const fakeEvent = {content, tags: props.event.tags}
|
||||
const goal = reader(ZapGoal)(props.event)
|
||||
|
||||
const title = goal.title()
|
||||
const summaryEvent = $derived({content: goal.summary(), tags: props.event.tags})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-2xl">{props.event.content}</p>
|
||||
<Content {...props} event={fakeEvent} expandMode="inline" minLength={50} maxLength={300} />
|
||||
<p class="text-2xl">{title}</p>
|
||||
<Content {...props} event={summaryEvent} expandMode="inline" minLength={50} maxLength={300} />
|
||||
<GoalSummary url={props.url} event={props.event} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
<script lang="ts">
|
||||
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"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
|
||||
const title = getTagValue("title", props.event.tags)
|
||||
const classified = reader(Classified)(props.event)
|
||||
|
||||
const title = classified.title()
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,43 @@
|
|||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {sum} from "@welshman/lib"
|
||||
import type {Zap, TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
|
||||
import {deriveItemsByKey, deriveArray} from "@welshman/store"
|
||||
import {repository, getValidZap} from "@welshman/app"
|
||||
import {ZAP_RECEIPT, fromMsats} from "@welshman/util"
|
||||
import {ZapGoal} from "@welshman/domain"
|
||||
import type {Zap} from "@welshman/domain"
|
||||
import {Zappers} from "@welshman/app"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.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 content = getTagValue("summary", props.event.tags)
|
||||
const fakeEvent = {content, tags: props.event.tags}
|
||||
const goal = reader(ZapGoal)(props.event)
|
||||
|
||||
const zaps = deriveArray(
|
||||
deriveItemsByKey<Zap>({
|
||||
repository,
|
||||
getKey: zap => zap.response.id,
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [props.event.id]}],
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, props.event),
|
||||
}),
|
||||
const title = goal.title()
|
||||
const summaryEvent = $derived({content: goal.summary(), tags: props.event.tags})
|
||||
|
||||
const receipts = deriveEvents([{kinds: [ZAP_RECEIPT], "#e": [props.event.id]}])
|
||||
|
||||
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 zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
|
||||
const goalAmount = goal.amount() ?? 0
|
||||
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>
|
||||
|
||||
<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">
|
||||
<Icon icon={Bolt} size={4} />
|
||||
{zapAmount}/{goalAmount} sats funded
|
||||
</div>
|
||||
</div>
|
||||
<ContentMinimal {...props} event={fakeEvent} />
|
||||
<ContentMinimal {...props} event={summaryEvent} />
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {POLL_RESPONSE} from "@welshman/util"
|
||||
import {Poll} from "@welshman/domain"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
import {reader} from "@app/core"
|
||||
import {deriveEvents} from "@app/repository"
|
||||
import {getPollResults} from "@app/polls"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
|
||||
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>
|
||||
|
||||
<div class="flex flex-col gap-0">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import {tagSpec, tagValue} from "@welshman/util"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
|
||||
const title = getTagValue("title", props.event.tags)
|
||||
const title = tagValue(tagSpec("title"), props.event.tags)
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,42 @@
|
|||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {Address} from "@welshman/util"
|
||||
import {Pin} from "@welshman/domain"
|
||||
import Badge from "@lib/components/Badge.svelte"
|
||||
import Content from "@app/components/Content.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 pin = readPin(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
|
||||
})
|
||||
const loadPin = reader(Pin)(props.event)
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-2">
|
||||
{#if pin.title}
|
||||
<strong class="truncate min-w-0">{pin.title}</strong>
|
||||
{/if}
|
||||
{#if pin.description}
|
||||
<Content event={{content: pin.description, tags: []}} />
|
||||
{/if}
|
||||
{#if pin.value[0] === "e" || pin.value[0] === "a"}
|
||||
<PinContentEvent value={pin.value[1]} relays={pin.value[2] ? [pin.value[2]] : []} />
|
||||
{:else}
|
||||
<Content event={{content, tags: []}} />
|
||||
{/if}
|
||||
{#if pin.topics.length > 0}
|
||||
<div class="mt-auto flex flex-wrap gap-1">
|
||||
{#each pin.topics as topic (topic)}
|
||||
<Badge>#{topic}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#await loadPin then pin}
|
||||
{@const reference = pin.reference()}
|
||||
<div class="flex h-full flex-col gap-2">
|
||||
{#if pin.title()}
|
||||
<strong class="truncate min-w-0">{pin.title()}</strong>
|
||||
{/if}
|
||||
{#if pin.content()}
|
||||
<Content event={{content: pin.content(), tags: []}} />
|
||||
{/if}
|
||||
{#if reference?.type === "event"}
|
||||
<PinContentEvent value={reference.id} relays={reference.relay ? [reference.relay] : []} />
|
||||
{:else if reference?.type === "address"}
|
||||
<PinContentEvent
|
||||
value={reference.address}
|
||||
relays={reference.relay ? [reference.relay] : []} />
|
||||
{:else}
|
||||
<!-- External references are urls or bech32 entities, both of which Content parses. -->
|
||||
<Content event={{content: pinToReference(pin), tags: []}} />
|
||||
{/if}
|
||||
{#if pin.topics().length > 0}
|
||||
<div class="mt-auto flex flex-wrap gap-1">
|
||||
{#each pin.topics() as topic (topic)}
|
||||
<Badge>#{topic}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/await}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import {tagSpec, tagValue} from "@welshman/util"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
|
||||
const title = getTagValue("title", props.event.tags)
|
||||
const title = tagValue(tagSpec("title"), props.event.tags)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -2,16 +2,15 @@
|
|||
import type {Snippet} from "svelte"
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
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 Icon from "@lib/components/Icon.svelte"
|
||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import {publishReaction, retractReaction} from "@app/reactions"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
import {publishReaction} from "@app/reactions"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {router} from "@app/core"
|
||||
|
||||
type Props = {
|
||||
event: TrustedEvent
|
||||
|
|
@ -21,23 +20,15 @@
|
|||
|
||||
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) =>
|
||||
publishDelete({relays, event, protect: await shouldProtect})
|
||||
const createReaction = async (values: EventContent) =>
|
||||
publishReaction(event, values, {url, urls: await getRelays()})
|
||||
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays, protect: await shouldProtect})
|
||||
|
||||
const onEmoji = async (emoji: NativeEmoji) =>
|
||||
publishReaction({
|
||||
event,
|
||||
relays,
|
||||
content: emoji.unicode,
|
||||
protect: await shouldProtect,
|
||||
})
|
||||
const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []})
|
||||
</script>
|
||||
|
||||
<NoteCard {event} {url} class="cv card card-interactive">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<script lang="ts">
|
||||
import {Client} from "@pomade/core"
|
||||
import type {SessionPomade} from "@welshman/app"
|
||||
import {session} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
|
|
@ -16,6 +14,7 @@
|
|||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
|
||||
import PasswordResetConfirm from "@app/components/PasswordResetConfirm.svelte"
|
||||
import {session} from "@app/core"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {getPomadeClient} from "@app/pomade"
|
||||
|
|
@ -26,7 +25,7 @@
|
|||
|
||||
const {peersByPrefix}: Props = $props()
|
||||
|
||||
const {email} = $session as SessionPomade
|
||||
const {email} = $session?.data as {email: string}
|
||||
|
||||
const confirmRecovery = async () => {
|
||||
const request = await Client.recoverWithChallenge(email, peersByPrefix, otps)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<script lang="ts">
|
||||
import {Client} from "@pomade/core"
|
||||
import {session} from "@welshman/app"
|
||||
import type {SessionPomade} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Key from "@assets/icons/key.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
|
|
@ -19,6 +17,8 @@
|
|||
import {loginWithPomade, deleteCurrentPomadeSession} from "@app/pomade"
|
||||
import {clearModals} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {session} from "@app/core"
|
||||
import {requirePomadeSession} from "@app/pomade"
|
||||
|
||||
type Props = {
|
||||
userSecret: string
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
const {userSecret}: Props = $props()
|
||||
|
||||
const {email} = $session as SessionPomade
|
||||
const {email} = requirePomadeSession($session).data
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<script lang="ts">
|
||||
import {Pin} from "@welshman/domain"
|
||||
import {publishAsRelay} from "@welshman/app"
|
||||
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 = {
|
||||
url: string
|
||||
|
|
@ -11,11 +14,17 @@
|
|||
const {url, address, reference}: Props = $props()
|
||||
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,30 @@
|
|||
<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 {createPin, referenceToPin, pinToReference, type PublishedPin} from "@app/pinboards"
|
||||
import {command, writer} from "@app/core"
|
||||
import {pinToReference, setPinReference} from "@app/pinboards"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
pin: PublishedPin
|
||||
pin: PinReader
|
||||
}
|
||||
|
||||
const {url, pin}: Props = $props()
|
||||
|
||||
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.
|
||||
return createPin(url, {
|
||||
...params,
|
||||
identifier: pin.identifier,
|
||||
boards: pin.boards,
|
||||
title,
|
||||
topics,
|
||||
description: content,
|
||||
})
|
||||
eventWriter.setTitle(title).setTopics(topics).setContent(content)
|
||||
|
||||
const thunk = await command(eventWriter).then(publishAsRelay(url))
|
||||
|
||||
return thunk.waitForError()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -32,9 +34,9 @@
|
|||
action="Save changes"
|
||||
successMessage="Link updated!"
|
||||
values={{
|
||||
title: pin.title,
|
||||
topics: pin.topics,
|
||||
title: pin.title(),
|
||||
topics: pin.topics(),
|
||||
value: pinToReference(pin),
|
||||
content: pin.description,
|
||||
content: pin.content(),
|
||||
}}
|
||||
{submit} />
|
||||
|
|
|
|||
|
|
@ -2,16 +2,17 @@
|
|||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import {Address} from "@welshman/util"
|
||||
import type {PinReader} from "@welshman/domain"
|
||||
import Badge from "@lib/components/Badge.svelte"
|
||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import PinContentEvent from "@app/components/PinContentEvent.svelte"
|
||||
import PinMenu from "@app/components/PinMenu.svelte"
|
||||
import {type PublishedPin} from "@app/pinboards"
|
||||
import {pinToReference} from "@app/pinboards"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
pin: PublishedPin
|
||||
pin: PinReader
|
||||
minimal?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
|
@ -20,20 +21,20 @@
|
|||
|
||||
// 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 reference = $derived(pin.reference())
|
||||
|
||||
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 (type === "p") return "nostr:" + nip19.nprofileEncode({pubkey: data, relays: [url]})
|
||||
|
||||
if (type === "a") {
|
||||
const {kind, pubkey, identifier} = Address.from(data)
|
||||
if (reference?.type === "address") {
|
||||
const {kind, pubkey, identifier} = Address.from(reference.address)
|
||||
|
||||
return "nostr:" + nip19.naddrEncode({kind, pubkey, identifier, relays: [url]})
|
||||
}
|
||||
|
||||
return data
|
||||
return pinToReference(pin)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -48,20 +49,23 @@
|
|||
componentProps={{url, pin}} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if pin.title}
|
||||
<strong class="truncate min-w-0 pr-8">{pin.title}</strong>
|
||||
{#if pin.title()}
|
||||
<strong class="truncate min-w-0 pr-8">{pin.title()}</strong>
|
||||
{/if}
|
||||
{#if pin.description}
|
||||
<Content event={{content: pin.description, tags: []}} {url} />
|
||||
{#if pin.content()}
|
||||
<Content event={{content: pin.content(), tags: []}} {url} />
|
||||
{/if}
|
||||
{#if pin.value[0] === "e" || pin.value[0] === "a"}
|
||||
<PinContentEvent {url} value={pin.value[1]} relays={removeUndefined([pin.value[2], url])} />
|
||||
{#if reference?.type === "event" || reference?.type === "address"}
|
||||
<PinContentEvent
|
||||
{url}
|
||||
value={reference.type === "event" ? reference.id : reference.address}
|
||||
relays={removeUndefined([reference.relay, url])} />
|
||||
{:else}
|
||||
<Content event={{content, tags: []}} {url} />
|
||||
{/if}
|
||||
{#if !minimal && pin.topics.length > 0}
|
||||
{#if !minimal && pin.topics().length > 0}
|
||||
<div class="mt-auto flex flex-wrap gap-1">
|
||||
{#each pin.topics as topic (topic)}
|
||||
{#each pin.topics() as topic (topic)}
|
||||
<Badge>#{topic}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,31 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {PinReader} from "@welshman/domain"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import {errorMessage} from "@lib/util"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import PinEdit from "@app/components/PinEdit.svelte"
|
||||
import {deriveSupportedMethods} from "@app/relays"
|
||||
import {deletePin, type PublishedPin} from "@app/pinboards"
|
||||
import {deletes} from "@app/core"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/management"
|
||||
import {shareEventToChat} from "@app/share"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
pin: PublishedPin
|
||||
pin: PinReader
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const {url, pin, onClick}: Props = $props()
|
||||
|
||||
const supportedMethods = deriveSupportedMethods(url)
|
||||
const canManage = $derived($supportedMethods.some(m => (m as string) === "signevent"))
|
||||
const canManage = deriveUserIsSpaceAdmin(url)
|
||||
|
||||
const showInfo = () => pushModal(EventInfo, {url, event: pin.event})
|
||||
|
||||
|
|
@ -32,19 +33,27 @@
|
|||
|
||||
const edit = () => pushModal(PinEdit, {url, pin})
|
||||
|
||||
const deletePin = async () => {
|
||||
try {
|
||||
const command = await $deletes.deleteEvent(pin.event)
|
||||
const error = await command.publishAsRelay(url).then(thunk => thunk.waitForError())
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
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: async () => {
|
||||
const error = await deletePin(url, pin.id)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Link deleted!"})
|
||||
}
|
||||
},
|
||||
confirm: deletePin,
|
||||
})
|
||||
|
||||
let ul: Element
|
||||
|
|
@ -67,7 +76,7 @@
|
|||
Share to chat
|
||||
</Button>
|
||||
</li>
|
||||
{#if canManage}
|
||||
{#if $canManage}
|
||||
<li>
|
||||
<Button onclick={edit}>
|
||||
<Icon icon={Pen} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
<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 Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -10,23 +15,17 @@
|
|||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
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 {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
board: Board
|
||||
isNew?: boolean
|
||||
board?: PinboardReader
|
||||
}
|
||||
|
||||
const {url, board, isNew = false}: Props = $props()
|
||||
|
||||
let title = $state(board.title)
|
||||
let description = $state(board.description)
|
||||
let loading = $state(false)
|
||||
const {url, board}: Props = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
|
|
@ -34,30 +33,41 @@
|
|||
loading = true
|
||||
|
||||
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) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else if (board) {
|
||||
pushToast({message: "Shelf updated!"})
|
||||
back()
|
||||
} else {
|
||||
pushToast({message: isNew ? "Shelf created!" : "Shelf updated!"})
|
||||
|
||||
// Navigate to a freshly created board; otherwise just close the modal.
|
||||
if (isNew) {
|
||||
goto(makeLibraryPath(url, board.address))
|
||||
} else {
|
||||
back()
|
||||
}
|
||||
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 {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let title = $state(board?.title() ?? "")
|
||||
let description = $state(board?.description() ?? "")
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{isNew ? "Create Shelf" : "Edit Shelf"}</ModalTitle>
|
||||
<ModalTitle>{board ? "Edit Shelf" : "Create Shelf"}</ModalTitle>
|
||||
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<Field>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
<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 Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||
import PinItem from "@app/components/PinItem.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"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
board: PublishedBoard
|
||||
board: PinboardReader
|
||||
}
|
||||
|
||||
const {url, board}: Props = $props()
|
||||
|
||||
const pins = derivePins(url, board.address)
|
||||
const detailPath = makeLibraryPath(url, board.address)
|
||||
const pins = $app.use(Pins).forBoard(board.address()).$
|
||||
const detailPath = makeLibraryPath(url, board.address())
|
||||
</script>
|
||||
|
||||
<div class="card relative flex flex-col gap-2">
|
||||
|
|
@ -29,12 +31,12 @@
|
|||
componentProps={{url, board}} />
|
||||
</div>
|
||||
<div class="flex flex-col pr-8">
|
||||
<strong class="text-lg">{board.title || "Untitled shelf"}</strong>
|
||||
{#if board.description}
|
||||
<span class="text-sm opacity-70">{board.description}</span>
|
||||
<strong class="text-lg">{board.title() || "Untitled shelf"}</strong>
|
||||
{#if board.description()}
|
||||
<span class="text-sm opacity-70">{board.description()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#each $pins.slice(0, 1) as pin (pin.id)}
|
||||
{#each $pins.slice(0, 1) as pin (pin.id())}
|
||||
<PinItem minimal {url} {pin} />
|
||||
{/each}
|
||||
<Link href={detailPath} class="button button-primary">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script lang="ts">
|
||||
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 Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -11,7 +13,8 @@
|
|||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import RelayName from "@app/components/RelayName.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 {pushModal} from "@app/modal"
|
||||
|
||||
|
|
@ -22,11 +25,14 @@
|
|||
|
||||
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 selectBoard = (board: PublishedBoard) =>
|
||||
pushModal(PinAdd, {url, address: board.address, reference})
|
||||
const selectBoard = (board: PinboardReader) =>
|
||||
pushModal(PinAdd, {url, address: board.address(), reference})
|
||||
</script>
|
||||
|
||||
<Modal class="flex flex-col gap-2">
|
||||
|
|
@ -43,14 +49,14 @@
|
|||
</Link>
|
||||
</div>
|
||||
{:else}
|
||||
{#each $boards as board (board.address)}
|
||||
{#each $boards as board (board.address())}
|
||||
<Button
|
||||
class="card card-sm card-interactive flex flex-row items-center justify-between gap-3"
|
||||
onclick={() => selectBoard(board)}>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<strong class="truncate min-w-0">{board.title || "Untitled shelf"}</strong>
|
||||
{#if board.description}
|
||||
<span class="truncate min-w-0 text-sm opacity-70">{board.description}</span>
|
||||
<strong class="truncate min-w-0">{board.title() || "Untitled shelf"}</strong>
|
||||
{#if board.description()}
|
||||
<span class="truncate min-w-0 text-sm opacity-70">{board.description()}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<Icon size={4} icon={AltArrowRight} class="shrink-0" />
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
<script lang="ts">
|
||||
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 RoomName from "@app/components/RoomName.svelte"
|
||||
import {publishReaction, retractReaction} from "@app/reactions"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.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"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -21,15 +19,12 @@
|
|||
|
||||
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 shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||
</script>
|
||||
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
|
||||
import {makeEvent, POLL} from "@welshman/util"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {insertAt, now, randomId, removeAt, removeUndefined, spec} from "@welshman/lib"
|
||||
import {publish} from "@welshman/app"
|
||||
import {Poll} from "@welshman/domain"
|
||||
import type {PollType} from "@welshman/domain"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
|
||||
|
|
@ -19,11 +20,10 @@
|
|||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import {command, relays, writer} from "@app/core"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {publishRoomQuote} from "@app/rooms"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import type {PollType} from "@app/polls"
|
||||
|
||||
type Option = {
|
||||
id: string
|
||||
|
|
@ -47,8 +47,6 @@
|
|||
const draftKey = new DraftKey<Values>(`poll:${url}:${h ?? ""}`)
|
||||
const initialValues = draftKey.get()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const addOption = () => {
|
||||
|
|
@ -68,8 +66,8 @@
|
|||
return
|
||||
}
|
||||
|
||||
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
|
||||
const targetIndex = options.findIndex(option => option.id === targetId)
|
||||
const sourceIndex = options.findIndex(spec({id: draggedOptionId}))
|
||||
const targetIndex = options.findIndex(spec({id: targetId}))
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
|
||||
return
|
||||
|
|
@ -119,35 +117,32 @@
|
|||
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
|
||||
|
||||
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) {
|
||||
tags.push(PROTECTED)
|
||||
for (const option of nonEmptyOptions) {
|
||||
eventWriter.addOption(option)
|
||||
}
|
||||
|
||||
const pollThunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(POLL, {content: title.trim(), tags}),
|
||||
})
|
||||
if (endsAt) {
|
||||
eventWriter.setEndsAt(endsAt)
|
||||
}
|
||||
|
||||
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) {
|
||||
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