Compare commits
No commits in common. "91a8995a9886a40df07732425f5a8aaf56d649df" and "9955a50add7b2ab288f9f00835578681a62d9d80" have entirely different histories.
91a8995a98
...
9955a50add
475 changed files with 10979 additions and 17405 deletions
|
|
@ -1,279 +1,634 @@
|
|||
---
|
||||
name: welshman-app
|
||||
description: "Use this skill when working with @welshman/app: the App instance and its plugins, sessions and login, publishing via Commands and Thunks, app policies, WoT, feeds, sync, or relay selection at the app layer."
|
||||
description: "Use this skill when working with @welshman/app: high-level Svelte stores for nostr apps, session management, WoT (web of trust), making requests, publishing events, user data, or relay selection at the app layer."
|
||||
---
|
||||
|
||||
# welshman/app — The App Instance and its Plugins
|
||||
# welshman/app — Application Layer Stores
|
||||
|
||||
`@welshman/app` composes `net`, `store`, `domain`, `signer`, and `feeds` into an application
|
||||
framework built around a single `App` object.
|
||||
`@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.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm i @welshman/app
|
||||
npm install @welshman/app
|
||||
# or
|
||||
pnpm add @welshman/app
|
||||
yarn add @welshman/app
|
||||
```
|
||||
|
||||
## The App
|
||||
## Key Exports
|
||||
|
||||
```typescript
|
||||
import {App, createApp, User} from "@welshman/app"
|
||||
### Core Singletons
|
||||
|
||||
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 |
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
`createApp` is `new App` plus `defaultAppPolicies`. Use `new App({policies: [...]})` for a bare app.
|
||||
### Session Management
|
||||
|
||||
**An app is scoped to one identity.** To log in, build a *new* app and `cleanup()` the old one —
|
||||
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 |
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `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) |
|
||||
| `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` |
|
||||
|
||||
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.
|
||||
**Login functions** (all call `addSession` internally):
|
||||
|
||||
```typescript
|
||||
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})
|
||||
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
|
||||
```
|
||||
|
||||
`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:
|
||||
**Session utilities**:
|
||||
|
||||
```typescript
|
||||
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)
|
||||
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
|
||||
```
|
||||
|
||||
Plugin mutators already return a `Command`, so `.then(publish)` is the common shape:
|
||||
**Gift wrap (NIP-59)**:
|
||||
|
||||
```typescript
|
||||
await app.use(FollowLists).follow(["p", pubkey]).then(publish)
|
||||
await app.use(RelayLists).addWriteUrl(url).then(publish)
|
||||
shouldUnwrap: Writable<boolean> // must be true to process incoming wraps
|
||||
wrapManager: WrapManager // tracks wrap↔rumor mappings
|
||||
unwrapAndStore(wrap: SignedEvent): Promise<void>
|
||||
```
|
||||
|
||||
Free-function forms exist for pipelines: `publish`, `publishToRelays(urls)`,
|
||||
`publishAsRelay(url)`, `signAsRelay(url)`.
|
||||
### Publishing — Thunks
|
||||
|
||||
### Thunks
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `publishThunk(options: ThunkOptions): Thunk` | Create, enqueue, and optimistically publish an event |
|
||||
| `Thunk` | Class representing a single in-flight publish |
|
||||
| `MergedThunk` | Aggregates multiple `Thunk`s (used by `sendWrapped`) |
|
||||
| `thunks` | `Writable<Thunk[]>` — all active thunks |
|
||||
| `abortThunk(thunk)` | Abort all constituent thunks |
|
||||
| `retryThunk(thunk)` | Re-publish with original options |
|
||||
| `mergeThunks(thunks[])` | Combine into a `MergedThunk` |
|
||||
|
||||
`app.use(Thunks).publish({event, relays, delay})` publishes optimistically: the event lands in the
|
||||
local repository immediately, so the UI updates before the network settles. The returned `Thunk`
|
||||
is a store you can render:
|
||||
**Thunk status helpers**:
|
||||
|
||||
```typescript
|
||||
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()
|
||||
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>
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
`ThunkOptions`:
|
||||
```typescript
|
||||
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
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
### Commands (Higher-level Thunk Factories)
|
||||
|
||||
## Web of trust
|
||||
Most return a `Thunk` (or `Promise<Thunk>`). They automatically load the relevant user list before modifying it. Exception: `manageRelay` returns `Promise<Response>` (an HTTP response from the NIP-86 management endpoint), not a Thunk.
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `setProfile(profile: Profile)` | Publish NIP-01 profile metadata |
|
||||
| `follow(tag: string[])` | Add to NIP-02 follow list |
|
||||
| `unfollow(value: string)` | Remove from follow list |
|
||||
| `mutePublicly(tag)` | Add to public mute list |
|
||||
| `mutePrivately(tag)` | Add to private (encrypted) mute list |
|
||||
| `unmute(value)` | Remove from mute list |
|
||||
| `setMutes({publicTags?, privateTags?})` | Replace entire mute list |
|
||||
| `pin(tag)` / `unpin(value)` | Manage pin list |
|
||||
| `addRelay(url, mode)` / `removeRelay(url, mode)` | NIP-65 relay list management |
|
||||
| `setRelays(tags)` / `setReadRelays(urls)` / `setWriteRelays(urls)` | Bulk relay list updates |
|
||||
| `addMessagingRelay(url)` / `removeMessagingRelay(url)` | NIP-17 messaging relay list |
|
||||
| `addBlockedRelay(url)` / `removeBlockedRelay(url)` | Blocked relay list |
|
||||
| `addSearchRelay(url)` / `removeSearchRelay(url)` | Search relay list |
|
||||
| `sendWrapped({event, recipients, ...options})` | NIP-59 gift-wrap to multiple recipients |
|
||||
| `manageRelay(url, request)` | NIP-86 relay management |
|
||||
| `createRoom` / `editRoom` / `deleteRoom` / `joinRoom` / `leaveRoom` | NIP-29 group room management |
|
||||
|
||||
### Profiles
|
||||
|
||||
```typescript
|
||||
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).$
|
||||
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
|
||||
```
|
||||
|
||||
## Feeds and sync
|
||||
### Follow / Mute / Pin Lists
|
||||
|
||||
Each list type follows the same pattern (`follow` shown, `mute` and `pin` are identical):
|
||||
|
||||
```typescript
|
||||
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
|
||||
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>
|
||||
```
|
||||
|
||||
## 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.
|
||||
### Relay Lists
|
||||
|
||||
```typescript
|
||||
// React
|
||||
const useStore = <T>(store: Readable<T>): T => {
|
||||
const [value, setValue] = useState<T>(() => get(store))
|
||||
// NIP-65 relay lists
|
||||
relayListsByPubkey / relayLists / getRelayList / loadRelayList / deriveRelayList
|
||||
|
||||
// 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]
|
||||
}
|
||||
```
|
||||
|
||||
For a `Projection`, subscribe to `.$` and read `.get()` for a synchronous snapshot.
|
||||
Usage:
|
||||
|
||||
## Related skills
|
||||
```tsx
|
||||
import {userProfile, pubkey} from '@welshman/app'
|
||||
|
||||
- `welshman-domain` — the readers/writers every plugin decodes events with
|
||||
- `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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,197 +0,0 @@
|
|||
---
|
||||
name: welshman-domain
|
||||
description: "Use this skill when working with @welshman/domain: reading or writing a specific nostr event kind, parsing tags, building events to publish, or adding support for a new kind. Provides a typed Reader/Writer pair per kind so you never hand-parse tags."
|
||||
---
|
||||
|
||||
# welshman/domain — Typed Readers and Writers per Kind
|
||||
|
||||
`@welshman/domain` replaces ad-hoc tag digging. Every supported kind gets a **Reader** (typed
|
||||
accessors over an event) and a **Writer** (a builder that renders a new event), paired in a
|
||||
`KindFactory`.
|
||||
|
||||
**The rule this package exists to enforce:** never reach into `event.tags` yourself. Use the
|
||||
reader's getter (`note.content()`, `roomMeta.name()`, `goal.amount()`). If a kind has no reader,
|
||||
use `tagValue`/`tagValues` from `@welshman/util` — not `tags.find(...)`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm i @welshman/domain
|
||||
```
|
||||
|
||||
## The three pieces
|
||||
|
||||
```typescript
|
||||
import {KindFactory} from "@welshman/domain"
|
||||
|
||||
KindFactory // the exported per-kind object, e.g. `Note`, `Profile`, `RoomMeta`
|
||||
.configure(ctx) // binds a resolver/signer -> ConfiguredKind
|
||||
.reader(event) // -> Reader instance (call .parse() before use)
|
||||
.writer(reader?) // -> Writer, optionally seeded from an existing reader to edit it
|
||||
```
|
||||
|
||||
You rarely call `configure` directly — the `Domain` plugin does it for you.
|
||||
|
||||
## Usage through an app
|
||||
|
||||
```typescript
|
||||
import {Domain} from "@welshman/app"
|
||||
import {Note, Profile} from "@welshman/domain"
|
||||
|
||||
// Read: returns a parsed reader, ready to use
|
||||
const note = app.use(Domain).reader(Note)(event)
|
||||
|
||||
note.content() // typed accessors, no tag digging
|
||||
note.author()
|
||||
note.createdAt()
|
||||
|
||||
// Write: build, then wrap in a Command to publish
|
||||
const writer = app.use(Domain).writer(Note).setContent("hello").addMention(pubkey)
|
||||
const command = await app.use(Domain).command(writer)
|
||||
|
||||
await command.publish().waitForError()
|
||||
|
||||
// Edit an existing event: seed the writer with its reader
|
||||
const profile = app.use(Domain).reader(Profile)(profileEvent)
|
||||
const edit = app.use(Domain).writer(Profile, profile).setName("new name")
|
||||
```
|
||||
|
||||
## Sync vs async readers
|
||||
|
||||
Some kinds decrypt on parse (encrypted lists, app data), so `parse()` is async for them:
|
||||
|
||||
- `EventReader.parse(): this`
|
||||
- `AsyncEventReader.parse(): Promise<this>`
|
||||
|
||||
`Parsed<R> = ReturnType<R["parse"]>` captures which one you get. `app.use(Domain).reader(F)`
|
||||
returns `Parsed<R>` — the reader itself for sync kinds, a promise for async ones. That's exactly
|
||||
what `EventToItem` accepts, so collections keep their synchronous path where the kind allows one.
|
||||
|
||||
```typescript
|
||||
const note = app.use(Domain).reader(Note)(event) // NoteReader
|
||||
const list = await app.use(Domain).reader(MuteList)(event) // MuteListReader (decrypts)
|
||||
```
|
||||
|
||||
## Reader base API
|
||||
|
||||
Every reader inherits from `BaseEventReader`:
|
||||
|
||||
| Method | Returns |
|
||||
|---|---|
|
||||
| `id()` | event id |
|
||||
| `author()` | pubkey |
|
||||
| `content()` | content string |
|
||||
| `tags()` | raw tags (prefer a typed getter when one exists) |
|
||||
| `createdAt()` | timestamp |
|
||||
| `identifier()` | `d` tag |
|
||||
| `address()` | `kind:pubkey:d` |
|
||||
| `room()` | `h` tag |
|
||||
| `protect()` | whether the event carries `-` (NIP-70) |
|
||||
| `expiration()` | `expiration` tag as a number |
|
||||
| `emojis()` | parsed `emoji` tags |
|
||||
| `zapSplits()` | parsed `zap` tags |
|
||||
|
||||
Kind-specific readers add their own on top — `RoomMetaReader.name()`, `ZapGoalReader.amount()`,
|
||||
`RelayMembersReader.isMember(pubkey)`, and so on.
|
||||
|
||||
## Writer base API
|
||||
|
||||
Writers are chainable and validate on render:
|
||||
|
||||
```typescript
|
||||
writer
|
||||
.setContent(content)
|
||||
.addTags(...tags)
|
||||
.dropTags(pred) // also keepTags(pred)
|
||||
.setIdentifier(d) // defaults to a random id
|
||||
.setRoom(url, h) // NIP-29 `h` tag + forced relay
|
||||
.setProtected(true) // NIP-70 `-`
|
||||
.setExpiration(ts)
|
||||
.addMention(pubkey)
|
||||
.addQuote(event)
|
||||
.addZapSplit(pubkey, split)
|
||||
.addEmoji(shortcode, url)
|
||||
.forceRelays(...urls) // bypass routing for relay-scoped kinds
|
||||
```
|
||||
|
||||
`readonly requiresRelays` marks kinds that are meaningless without an explicit relay (NIP-29 room
|
||||
ops, relay membership). Subclasses override `validate()` to enforce kind-specific invariants —
|
||||
e.g. `RoomUpdatePins` throws without a room, `ZapGoal` requires an amount and at least one relay.
|
||||
|
||||
## Display helpers
|
||||
|
||||
A few kinds export display helpers alongside their reader:
|
||||
|
||||
```typescript
|
||||
import {displayPubkey} from "@welshman/domain"
|
||||
|
||||
profile.display("Anonymous") // ProfileReader: name/display_name, or the fallback
|
||||
displayPubkey(pubkey) // 'npub1abc...xyz'
|
||||
```
|
||||
|
||||
## Adding a new kind
|
||||
|
||||
1. Add the kind constant to `@welshman/util`'s `Kinds.ts`.
|
||||
2. Create `packages/domain/src/kinds/YourKind.ts` with a Reader, a Writer, and the factory:
|
||||
|
||||
```typescript
|
||||
import {uniq, spec} from "@welshman/lib"
|
||||
import {YOUR_KIND, hexTags, tagValues} from "@welshman/util"
|
||||
import {EventReader} from "../core/EventReader.js"
|
||||
import {EventWriter} from "../core/EventWriter.js"
|
||||
import {KindFactory} from "../core/Kind.js"
|
||||
|
||||
export class YourKindReader extends EventReader {
|
||||
pubkeys() {
|
||||
return uniq(tagValues(hexTags("p"), this.event.tags))
|
||||
}
|
||||
}
|
||||
|
||||
export class YourKindWriter extends EventWriter<YourKindReader> {
|
||||
readonly requiresRelays = true
|
||||
|
||||
validate() {
|
||||
super.validate()
|
||||
|
||||
if (!this.roomTag) throw new Error("YourKind requires a room")
|
||||
}
|
||||
|
||||
setPubkeys(pubkeys: string[]) {
|
||||
return this.dropTags(spec(["p"])).addTags(...uniq(pubkeys).map(pk => ["p", pk]))
|
||||
}
|
||||
}
|
||||
|
||||
export const YourKind = new KindFactory({
|
||||
kind: YOUR_KIND,
|
||||
reader: YourKindReader,
|
||||
writer: YourKindWriter,
|
||||
})
|
||||
```
|
||||
|
||||
3. Export it from `packages/domain/src/index.ts`.
|
||||
4. Add `packages/domain/__tests__/YourKind.test.ts` — every kind has one. Use the `read`, `write`,
|
||||
and `buildTemplate` helpers from `./helpers.js`, and cover: reading represented tags,
|
||||
round-tripping without duplicating tags, and any `validate()` rule.
|
||||
|
||||
## Available kinds
|
||||
|
||||
Profile, Note, Comment, Thread, Delete, Reaction, Report, Poll, PollResponse, Classified,
|
||||
TimeEvent, AppData, Handler, HandlerRecommendation, Feed, Pin, Pinboard.
|
||||
|
||||
Lists: FollowList, MuteList, PinList, BookmarkList, TopicList, EmojiList, RelayList, RelaySet,
|
||||
SearchRelayList, MessagingRelayList, BlossomServerList, BlockedRelayList, CommunityList,
|
||||
FeedList, RoomList.
|
||||
|
||||
Zaps: ZapRequest, ZapReceipt, ZapGoal.
|
||||
|
||||
NIP-29 rooms: RoomMeta, RoomCreate, RoomEdit, RoomDelete, RoomJoin, RoomLeave, RoomAddMember,
|
||||
RoomRemoveMember, RoomMembers, RoomAdmins, RoomCreatePermission, RoomPins, RoomUpdatePins.
|
||||
|
||||
Relay membership: RelayInvite, RelayJoin, RelayLeave, RelayAddMember, RelayRemoveMember,
|
||||
RelayMembers, RelayRole.
|
||||
|
||||
## Related skills
|
||||
|
||||
- `welshman-app` — the `Domain` plugin, `Command`, and the collections that decode events for you
|
||||
- `welshman-util` — kind constants, tag specs (`tagValue`, `hexTags`, `addressTags`), filters
|
||||
- `welshman-store` — indexing decoded readers into reactive collections
|
||||
|
|
@ -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 {Profiles, Router, createSearch} from "@welshman/app"
|
||||
import {outbox} from "@welshman/util"
|
||||
import {Router} from "@welshman/router"
|
||||
import {createSearch, profiles, searchProfiles, deriveProfileDisplay} from "@welshman/app"
|
||||
import {
|
||||
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
|
||||
} from "@welshman/editor"
|
||||
|
|
@ -323,8 +323,8 @@ const onSubmit = (editor: Editor) => {
|
|||
|
||||
## Integration Notes
|
||||
|
||||
- **`@welshman/app`** — `app.use(Profiles).profileSearch` and `app.use(Profiles).display(pubkey)` are the typical sources for mention autocomplete data and display names.
|
||||
- **`@welshman/app`** — relay hints for nprofile bech32 strings come from `app.use(Router).resolve([outbox(pubkey)])`.
|
||||
- **`@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/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'
|
||||
|
||||
// Each App owns its repository.
|
||||
const unsub = on(app.repository, 'update', updates => {
|
||||
const unsub = on(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 owns an `App` instance and its plugins) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities.
|
||||
`@welshman/net` is the core networking layer for welshman-based nostr apps. It manages WebSocket relay connections, subscriptions, event publishing, NIP-42 auth, and NIP-77 negentropy sync. It sits below `@welshman/app` (which provides higher-level reactive stores and routing) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities.
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -135,10 +135,10 @@ Emits `"update"` with `RepositoryUpdate` (`{ added: TrustedEvent[], removed: Set
|
|||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `NetContext` | `{ pool?, repository?, getAdapter? }` |
|
||||
| `netContext` | Global `NetContext` config object |
|
||||
| `NetContext` | `{ pool, repository, isEventValid, isEventDeleted, getAdapter? }` |
|
||||
|
||||
Pass `context` to each `request`/`publish`/`load` call. An `App` from `@welshman/app` owns one per
|
||||
instance (`app.netContext`), and `app.use(Network)` supplies it for you.
|
||||
Mutate `netContext` fields directly to change global defaults; pass `context` to individual calls to override per-request.
|
||||
|
||||
### Negentropy / Diff (NIP-77)
|
||||
|
||||
|
|
@ -366,7 +366,7 @@ repo.load(storedEvents)
|
|||
### Startup: bulk-load Tracker state
|
||||
|
||||
```typescript
|
||||
// `app.tracker` — wired to that app's pool and repository
|
||||
import {tracker} from '@welshman/app' // singleton wired to the pool and repository
|
||||
|
||||
// Build the map from your stored relay<->event mappings
|
||||
const relaysById = new Map<string, Set<string>>()
|
||||
|
|
@ -384,7 +384,7 @@ tracker.load(relaysById)
|
|||
|
||||
```typescript
|
||||
import {on, batch} from '@welshman/lib'
|
||||
// `app.repository`, or `new Repository()` when using @welshman/net standalone
|
||||
import {repository} from '@welshman/app' // singleton; or Repository.get() standalone
|
||||
import type {RepositoryUpdate} from '@welshman/net'
|
||||
import type {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` 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.
|
||||
- **`@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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
291
.agents/skills/welshman-router/SKILL.md
Normal file
291
.agents/skills/welshman-router/SKILL.md
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
---
|
||||
name: welshman-router
|
||||
description: "Use this skill when working with @welshman/router: relay selection, routing strategies, scenario-based relay routing, or choosing which relays to use for reads/writes."
|
||||
---
|
||||
|
||||
# welshman/router — Relay Selection
|
||||
|
||||
`@welshman/router` provides scenario-based relay selection for nostr clients. It answers the question "which relays should I use for this operation?" by scoring candidate relays based on pubkey relay lists, relay quality, and configurable fallback policies. It sits between `@welshman/util` (types/helpers) and `@welshman/net` (actual relay connections), and is wrapped by `@welshman/app` for full-stack usage.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/router
|
||||
# or
|
||||
pnpm add @welshman/router
|
||||
yarn add @welshman/router
|
||||
```
|
||||
|
||||
Peer dependencies: `@welshman/lib`, `@welshman/net`, `@welshman/util`.
|
||||
|
||||
## Key Exports
|
||||
|
||||
### Router (class)
|
||||
|
||||
The main entry point. Use as a singleton via `Router.configure()` + `Router.get()`, or instantiate directly with options.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `Router.configure(options)` | Merge options into the global `routerContext` |
|
||||
| `Router.get()` | Return a `Router` instance using the global context |
|
||||
| `new Router(options)` | Create a router that overrides specific options from the global context |
|
||||
|
||||
**RouterOptions** (all optional):
|
||||
|
||||
| Option | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `getUserPubkey` | `() => string \| undefined` | Current user's pubkey |
|
||||
| `getPubkeyRelays` | `(pubkey, mode?) => string[]` | Relays for a pubkey; `mode` is `"read"`, `"write"`, or `"messaging"` |
|
||||
| `getDefaultRelays` | `() => string[]` | Fallback relays of last resort |
|
||||
| `getIndexerRelays` | `() => string[]` | Relays that index profiles and relay lists (NIP-65) |
|
||||
| `getSearchRelays` | `() => string[]` | Relays supporting NIP-50 search |
|
||||
| `getRelayQuality` | `(url) => number` | Quality score 0–1 for a relay (affects selection ranking) |
|
||||
| `getLimit` | `() => number` | Max relays returned by `getUrls()` (default: 3) |
|
||||
|
||||
**Default behavior:** if `getPubkeyRelays` is not configured, the router falls back to querying the local `Repository` (from `@welshman/net`) for kind-10002 events.
|
||||
|
||||
### Router Scenario Methods
|
||||
|
||||
All return a `RouterScenario`. Naming convention: `For*` = relays to write to (so others can read), `From*` = relays to read from (author's outbox).
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `FromRelays(relays)` | Use an explicit list of relay URLs |
|
||||
| `ForUser()` | User's read relays (where others can send things to the user) |
|
||||
| `FromUser()` | User's write relays (user's outbox) |
|
||||
| `MessagesForUser()` | User's messaging relays (NIP-17 DMs) |
|
||||
| `ForPubkey(pubkey)` | A pubkey's read relays |
|
||||
| `FromPubkey(pubkey)` | A pubkey's write relays (outbox) |
|
||||
| `MessagesForPubkey(pubkey)` | A pubkey's messaging relays |
|
||||
| `ForPubkeys(pubkeys)` | Merged read relays for multiple pubkeys |
|
||||
| `FromPubkeys(pubkeys)` | Merged write relays for multiple pubkeys |
|
||||
| `MessagesForPubkeys(pubkeys)` | Merged messaging relays for multiple pubkeys |
|
||||
| `Event(event)` | Event author's write relays (where the event lives) |
|
||||
| `Replies(event)` | Event author's read relays (where replies should be sent) |
|
||||
| `PublishEvent(event)` | Author's outbox + mentioned pubkeys' read relays; hard-limits to 30 |
|
||||
| `Quote(event, id, hints)` | Best relays to find a quoted event; checks tag relay hints and author pubkey from tag |
|
||||
| `EventParents(event)` | Relays for fetching parent events (from ancestor tags + mentioned pubkeys) |
|
||||
| `EventRoots(event)` | Relays for fetching root events |
|
||||
| `Search()` | Search relays |
|
||||
| `Index()` | Indexer relays |
|
||||
| `Default()` | Default/fallback relays |
|
||||
| `merge(scenarios)` | Combine multiple scenarios into one |
|
||||
|
||||
### RouterScenario (class)
|
||||
|
||||
Immutable builder — every builder method returns a new instance. Terminal methods (`getUrls()`, `getUrl()`) return relay URLs, not instances.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `getUrls()` | Execute selection; returns `string[]` |
|
||||
| `getUrl()` | Returns the first selected URL or `undefined` |
|
||||
| `limit(n)` | Override max relay count for this scenario |
|
||||
| `weight(scale)` | Multiply all selection weights by `scale` |
|
||||
| `policy(fn)` | Set fallback policy |
|
||||
| `allowLocal(bool)` | Allow `ws://localhost` / `ws://127.*` URLs (default: false) |
|
||||
| `allowOnion(bool)` | Allow `.onion` URLs (default: false) |
|
||||
| `allowInsecure(bool)` | Allow plain `ws://` non-onion URLs (default: false) |
|
||||
| `filter(fn)` | Filter the internal `Selection[]` |
|
||||
| `update(fn)` | Map over the internal `Selection[]` |
|
||||
|
||||
### Fallback Policies
|
||||
|
||||
Applied after relay scoring when not enough relays are found. Draw from `getDefaultRelays`.
|
||||
|
||||
| Export | Behavior |
|
||||
|--------|----------|
|
||||
| `addNoFallbacks` | Never add fallbacks (default) |
|
||||
| `addMinimalFallbacks` | Add 1 fallback only if zero relays were selected |
|
||||
| `addMaximalFallbacks` | Fill remaining slots up to the limit |
|
||||
|
||||
### Filter Selection
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `getFilterSelections(filters, rules?)` | Returns `RelaysAndFilters[]` — optimized relay+filter combos for a subscription |
|
||||
| `RelaysAndFilters` | `{ relays: string[], filters: Filter[] }` |
|
||||
| `defaultFilterSelectionRules` | The default ordered rule array |
|
||||
| `getFilterSelectionsForSearch` | Rule: search filters → search relays (weight 10) |
|
||||
| `getFilterSelectionsForWraps` | Rule: kind-1059 wraps without authors → user messaging relays |
|
||||
| `getFilterSelectionsForIndexedKinds` | Rule: kinds 0/3/10002/10050 → indexer relays |
|
||||
| `getFilterSelectionsForAuthors` | Rule: author filters → each author's outbox (split into up to 30 chunks) |
|
||||
| `getFilterSelectionsForUser` | Rule: low-weight (0.2) baseline that always fires for every filter → user's read relays. It is not conditional on other rules failing. |
|
||||
|
||||
### Other Exports
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `INDEXED_KINDS` | `[PROFILE, RELAYS, MESSAGING_RELAYS, FOLLOWS]` — kinds routed to indexers |
|
||||
| `makeSelection(relays, weight?)` | Create a `Selection` object; validates and normalizes URLs |
|
||||
| `Selection` | `{ weight: number, relays: string[] }` |
|
||||
| `FallbackPolicy` | `(count: number, limit: number) => number` |
|
||||
| `routerContext` | The global mutable options object updated by `Router.configure()` |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### 1. Configure once at app startup
|
||||
|
||||
`Router.get()` is the primary entry point — it returns a `Router` instance using the global `routerContext`. Call `Router.configure()` once at startup to set options, or assign directly to `routerContext` for individual overrides.
|
||||
|
||||
```typescript
|
||||
import {Router} from '@welshman/router'
|
||||
|
||||
Router.configure({
|
||||
getUserPubkey: () => myStore.userPubkey,
|
||||
getPubkeyRelays: (pubkey, mode) => myStore.getRelaysForPubkey(pubkey, mode),
|
||||
getDefaultRelays: () => ['wss://relay.example.com/', 'wss://relay2.example.com/'],
|
||||
getIndexerRelays: () => ['wss://indexer.example.com/', 'wss://indexer2.example.com/'],
|
||||
getSearchRelays: () => ['wss://search.example.com/', 'wss://search2.example.com/'],
|
||||
getRelayQuality: (url) => myStore.getRelayQuality(url),
|
||||
getLimit: () => 5,
|
||||
})
|
||||
```
|
||||
|
||||
When using `@welshman/app`, it pre-configures `Router` automatically. The two most common customization points when using `@welshman/app` are `getDefaultRelays` and `getIndexerRelays`, which you can assign directly on `routerContext`:
|
||||
|
||||
```typescript
|
||||
import {routerContext} from '@welshman/router'
|
||||
|
||||
routerContext.getDefaultRelays = () => [
|
||||
'wss://relay.example.com/',
|
||||
'wss://relay2.example.com/',
|
||||
]
|
||||
|
||||
routerContext.getIndexerRelays = () => [
|
||||
'wss://indexer.example.com/',
|
||||
'wss://indexer2.example.com/',
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Fetch events from specific pubkeys
|
||||
|
||||
```typescript
|
||||
import {Router} from '@welshman/router'
|
||||
|
||||
const relays = Router.get().FromPubkeys(['pubkey1', 'pubkey2']).getUrls()
|
||||
// relays is string[] — pass to your subscription
|
||||
```
|
||||
|
||||
### 3. Publish an event
|
||||
|
||||
```typescript
|
||||
import {Router} from '@welshman/router'
|
||||
import type {TrustedEvent} from '@welshman/util'
|
||||
|
||||
function getPublishRelays(event: TrustedEvent): string[] {
|
||||
return Router.get().PublishEvent(event).getUrls()
|
||||
// Automatically includes author's outbox + mentioned pubkeys' read relays
|
||||
// Hard-limited to 30 relays for deliverability
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Find a quoted/referenced event with fallbacks
|
||||
|
||||
```typescript
|
||||
import {Router, addMaximalFallbacks} from '@welshman/router'
|
||||
import type {TrustedEvent} from '@welshman/util'
|
||||
|
||||
function getQuoteRelays(event: TrustedEvent, quotedId: string, hints: string[]) {
|
||||
return Router.get()
|
||||
.Quote(event, quotedId, hints)
|
||||
.policy(addMaximalFallbacks)
|
||||
.limit(8)
|
||||
.getUrls()
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Common scenario cheat-sheet
|
||||
|
||||
```typescript
|
||||
import {Router} from '@welshman/router'
|
||||
|
||||
const router = Router.get()
|
||||
|
||||
// Read relays for the current user (where others deliver events to you)
|
||||
router.ForUser().getUrls()
|
||||
|
||||
// Write relays for the current user (your outbox)
|
||||
router.FromUser().getUrls()
|
||||
|
||||
// Best relays to deliver an event to a pubkey (their inbox)
|
||||
router.ForPubkey('pubkey').getUrls()
|
||||
|
||||
// Best relays to fetch events authored by a pubkey (their outbox)
|
||||
router.FromPubkey('pubkey').getUrls()
|
||||
|
||||
// Indexer relays (profiles, relay lists)
|
||||
router.Index().getUrls()
|
||||
|
||||
// Cap relay count for this scenario only
|
||||
router.ForPubkey('pubkey').limit(3).getUrls()
|
||||
|
||||
// Merge multiple scenarios; relay URLs are deduplicated when getUrls() is called
|
||||
router.merge([
|
||||
router.FromUser(),
|
||||
router.Index(),
|
||||
]).getUrls()
|
||||
```
|
||||
|
||||
### 6. Build subscriptions with getFilterSelections
|
||||
|
||||
```typescript
|
||||
import {getFilterSelections} from '@welshman/router'
|
||||
import type {Filter} from '@welshman/util'
|
||||
|
||||
const filters: Filter[] = [
|
||||
{kinds: [1], authors: ['pubkey1', 'pubkey2']},
|
||||
{kinds: [0], search: 'bitcoin'},
|
||||
]
|
||||
|
||||
for (const {relays, filters} of getFilterSelections(filters)) {
|
||||
// Open one subscription per relay group
|
||||
myPool.subscribe(relays, filters)
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Use a custom filter routing rule
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Router,
|
||||
getFilterSelections,
|
||||
defaultFilterSelectionRules,
|
||||
type RelaysAndFilters,
|
||||
} from '@welshman/router'
|
||||
import type {Filter} from '@welshman/util'
|
||||
|
||||
// Add a rule that sends kind-1 to a dedicated relay
|
||||
const myRule = (filter: Filter) => {
|
||||
if (!filter.kinds?.includes(1)) return []
|
||||
return [{filter, scenario: Router.get().FromRelays(['wss://notes.example.com/'])}]
|
||||
}
|
||||
|
||||
const selections = getFilterSelections(filters, [myRule, ...defaultFilterSelectionRules])
|
||||
```
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- **`@welshman/util`** — Router imports `TrustedEvent`, `Filter`, `RelayMode`, `PROFILE`, `RELAYS`, `MESSAGING_RELAYS`, `FOLLOWS`, `WRAP`, `normalizeRelayUrl`, and tag-parsing helpers. All relay URLs are normalized with `normalizeRelayUrl` and validated with `isRelayUrl` before use.
|
||||
- **`@welshman/net`** — The default `getPubkeyRelays` implementation queries `Repository.get()` (the in-memory event store from `@welshman/net`) for kind-10002 events. Override it if you maintain relay lists elsewhere.
|
||||
- **`@welshman/app`** — The app layer pre-configures `Router` using its own stores (relay lists, connection quality). If you use `@welshman/app`, call `Router.configure` only to override specific options; the app layer handles the rest.
|
||||
- **`@welshman/lib`** — Used internally for utilities (`sortBy`, `shuffle`, `uniq`, etc.); no direct integration needed.
|
||||
|
||||
## Gotchas & Tips
|
||||
|
||||
- **Relay list events must be in the Repository for pubkey routing to work.** The default `getPubkeyRelays` implementation reads kind-10002 (NIP-65) relay list events from the global in-memory `Repository`. If those events haven't been loaded — either from a local cache at startup or fetched from the network — the Router has no relay list data for that pubkey and silently falls back to default/indexer relays. When using `@welshman/app`, relay lists are fetched automatically as part of profile loading (`loadRelayList`, `makeOutboxLoader`). Without `@welshman/app`, you must fetch and load them yourself before calling pubkey-based scenarios.
|
||||
|
||||
- **`For*` vs `From*`**: `ForPubkey` returns a pubkey's **read** relays (where you send things for that pubkey to receive); `FromPubkey` returns their **write** relays (their outbox, where their events live). Use `From*` to fetch events, `For*` to deliver events.
|
||||
|
||||
- **Default limit is 3.** Set `getLimit` in `Router.configure` or call `.limit(n)` on a scenario if you need more. `PublishEvent` unconditionally overrides to 30.
|
||||
|
||||
- **Scoring includes randomness.** `getUrls()` introduces `Math.random()` in the scoring formula so that lower-quality or less-popular relays get occasional selection. Results are not deterministic across calls.
|
||||
|
||||
- **`addNoFallbacks` is the default policy.** If no relays are found for a scenario (e.g. no relay list for a pubkey) and you haven't set a policy, `getUrls()` returns `[]`. Use `addMinimalFallbacks` or `addMaximalFallbacks` when you need a result even for unknown pubkeys.
|
||||
|
||||
- **Insecure `ws://` URLs are filtered by default.** Only onion addresses (`*.onion`) are exempt from the TLS requirement. Pass `.allowInsecure(true)` to a scenario if you need to support plain websocket relays (e.g. local dev).
|
||||
|
||||
- **`getFilterSelections` uses `addMinimalFallbacks`.** Each resulting relay group will have at least one relay *if* `getDefaultRelays` is configured and returns relays. If `getDefaultRelays` is not configured or returns an empty array, the group may still be empty.
|
||||
|
||||
- **`routerContext` is a shared mutable object.** `Router.configure()` mutates it in place with `Object.assign`. `new Router(options)` merges the supplied options *over* the global `routerContext` (via `mergeLeft`), so any options not provided in `options` still fall back to whatever is in `routerContext`. For true isolation (e.g. in tests), pass a complete options object or reset `routerContext` first.
|
||||
|
||||
- **`Quote` reads relay hints from event tags.** It looks for a tag whose second element (`t[1]`) matches the quoted event ID, then extracts a relay hint from `t[2]` and an author pubkey from `t[3]`. Standard NIP-21/NIP-10 tag format.
|
||||
|
|
@ -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`** wraps a signer in a `User` (`User.fromSigner(signer)` or `User.fromSession(session)`), passed to the app at construction: `createApp({user})`. Reach it again via `app.user?.signer`, or `User.require(app).signer` where a login is required.
|
||||
- **`@welshman/app`** exposes a `signer` writable store (`import { signer } from '@welshman/app'`) that the rest of the app stack reads. Set it to your chosen `ISigner` after login.
|
||||
- `Nip59` wraps events with an ephemeral `Nip01Signer` by default (per the NIP-59 spec), so callers do not need to supply a wrapper unless they want a custom one.
|
||||
|
||||
## Gotchas & Tips
|
||||
|
|
|
|||
|
|
@ -140,19 +140,15 @@ notes.subscribe($notes => {
|
|||
```typescript
|
||||
import { Repository } from "@welshman/net"
|
||||
import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store"
|
||||
import { PROFILE } from "@welshman/util"
|
||||
import { Profile, type ProfileReader } from "@welshman/domain"
|
||||
import { readProfile, PROFILE, type PublishedProfile } from "@welshman/util"
|
||||
|
||||
const repository = new Repository()
|
||||
|
||||
// Decoding is @welshman/domain's job — configure the kind, then use its reader as eventToItem.
|
||||
const readProfile = Profile.configure({}).reader
|
||||
|
||||
const profilesByPubkey = deriveItemsByKey<ProfileReader>({
|
||||
const profilesByPubkey = deriveItemsByKey<PublishedProfile>({
|
||||
repository,
|
||||
filters: [{ kinds: [PROFILE] }],
|
||||
eventToItem: event => readProfile(event).parse(),
|
||||
getKey: profile => profile.author(),
|
||||
eventToItem: event => readProfile(event),
|
||||
getKey: profile => profile.event.pubkey,
|
||||
})
|
||||
|
||||
// All profiles as array
|
||||
|
|
@ -163,7 +159,7 @@ const deriveProfile = makeDeriveItem(profilesByPubkey)
|
|||
const aliceProfile = deriveProfile("alice-pubkey-hex")
|
||||
|
||||
aliceProfile.subscribe($profile => {
|
||||
console.log($profile?.display())
|
||||
console.log($profile?.name)
|
||||
})
|
||||
```
|
||||
|
||||
|
|
@ -231,8 +227,7 @@ This `getBookmark` function is the right shape to pass as `getItem` to `makeLoad
|
|||
### 6. Full reactive item chain: deriveItemsByKey → deriveItems → getter → makeLoadItem → makeDeriveItem
|
||||
|
||||
This is the canonical pattern for domain objects derived from repository events with
|
||||
on-demand network loading. `@welshman/app` packages it as `DerivedPlugin` — prefer subclassing
|
||||
that over hand-rolling the chain unless you need something it doesn't cover.
|
||||
on-demand network loading.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
|
|
@ -242,9 +237,10 @@ import {
|
|||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
} from "@welshman/store"
|
||||
import { Network, Router } from "@welshman/app"
|
||||
import { outbox } from "@welshman/util"
|
||||
import { relayTags, tagSpec, tagValue, tagValues } from "@welshman/util"
|
||||
import { load } from "@welshman/net"
|
||||
import { repository } from "@welshman/app"
|
||||
import { Router } from "@welshman/router"
|
||||
import { getTagValue, getTagValues } from "@welshman/util"
|
||||
import type { TrustedEvent } from "@welshman/util"
|
||||
|
||||
const BOOKMARK_KIND = 30003
|
||||
|
|
@ -258,14 +254,14 @@ type Bookmark = {
|
|||
|
||||
const parseBookmark = (event: TrustedEvent): Bookmark => ({
|
||||
pubkey: event.pubkey,
|
||||
title: tagValue(tagSpec("title"), event.tags) ?? "Untitled",
|
||||
urls: tagValues(relayTags("r"), event.tags),
|
||||
title: getTagValue("title", event.tags) ?? "Untitled",
|
||||
urls: getTagValues("r", event.tags),
|
||||
event,
|
||||
})
|
||||
|
||||
// Step 1: Reactive Map<pubkey, Bookmark> — live-updates from repository
|
||||
const bookmarksByPubkey = deriveItemsByKey<Bookmark>({
|
||||
repository: app.repository,
|
||||
repository,
|
||||
filters: [{ kinds: [BOOKMARK_KIND] }],
|
||||
getKey: b => b.pubkey,
|
||||
eventToItem: parseBookmark,
|
||||
|
|
@ -282,10 +278,8 @@ const getBookmark = (pubkey: string) => getBookmarksByPubkey().get(pubkey)
|
|||
// re-fetches only after the timeout window (default: 3600 s)
|
||||
const loadBookmark = makeLoadItem<Bookmark>(
|
||||
async (pubkey: string) => {
|
||||
const scenario = await app.use(Router).resolve([outbox(pubkey)])
|
||||
|
||||
await app.use(Network).load({
|
||||
relays: scenario.getUrls(),
|
||||
await load({
|
||||
relays: Router.get().ForPubkey(pubkey).getUrls(),
|
||||
filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }],
|
||||
})
|
||||
},
|
||||
|
|
@ -304,8 +298,7 @@ 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`, 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/util`** — provides `TrustedEvent`, `Filter`, `readProfile`, `readList`, and other event-parsing helpers that feed into `deriveItemsByKey` / `deriveEventsById`.
|
||||
- **`@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,6 +32,7 @@ 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
|
||||
|
||||
|
|
@ -48,6 +49,9 @@ 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
|
||||
|
||||
|
|
@ -270,16 +274,24 @@ isDVMKind(kind) // 5000–7000
|
|||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
### Filters
|
||||
|
||||
|
|
@ -307,12 +319,50 @@ 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 |
|
||||
|
|
@ -320,6 +370,7 @@ isDVMKind(kind) // 5000–7000
|
|||
| `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)
|
||||
|
||||
|
|
@ -328,6 +379,9 @@ isDVMKind(kind) // 5000–7000
|
|||
| `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
|
||||
|
||||
|
|
@ -448,14 +502,29 @@ Only do this for events you persisted yourself after they were validated. Never
|
|||
### Working with tags
|
||||
|
||||
```typescript
|
||||
import {tagValue, tagValues, hexTags, relayTags, tagSpec, topicTags} from '@welshman/util'
|
||||
import {
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
getPubkeyTagValues,
|
||||
getTopicTagValues,
|
||||
getRelayTagValues,
|
||||
getReplyTags,
|
||||
uniqTags,
|
||||
} from '@welshman/util'
|
||||
|
||||
// Specs come first, then the tags array. The spec says which keys to match and how to
|
||||
// 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
|
||||
// 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)
|
||||
```
|
||||
|
||||
### Matching and building filters
|
||||
|
|
@ -497,11 +566,24 @@ 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 } from '@welshman/util'
|
||||
import { Zappers } from '@welshman/app'
|
||||
import { getLnUrl, makeEvent, ZAP_REQUEST, zapFromEvent } from '@welshman/util'
|
||||
|
||||
// Step 1: resolve LNURL
|
||||
const lnurl = getLnUrl('satoshi@getalby.com')
|
||||
|
|
@ -522,7 +604,7 @@ const zapRequest = makeEvent(ZAP_REQUEST, {
|
|||
// Step 3: sign, send to LNURL callback, pay invoice...
|
||||
|
||||
// Step 4: validate receipt (kind 9735)
|
||||
const zap = await app.use(Zappers).validateZapReceipt(zapReceipt, zappedEvent)
|
||||
const zap = zapFromEvent(zapReceipt, { nostrPubkey: zapperPubkey, allowsNostr: true, lnurl })
|
||||
if (zap) {
|
||||
console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content)
|
||||
}
|
||||
|
|
@ -564,9 +646,9 @@ await fetch('https://api.example.com/upload', {
|
|||
|
||||
- **`@welshman/net`** — uses `TrustedEvent`, `Filter`, `SignedEvent` from this package as the wire types for relay connections and subscriptions.
|
||||
- **`@welshman/store`** — provides Svelte stores over repositories built on `TrustedEvent`; relies on `isReplaceable`, `getAddress`, etc. for deduplication.
|
||||
- **`@welshman/app`** — high-level application layer; wraps net/store/domain and resolves this package's `RelaySelection` DSL through `app.use(Router)`.
|
||||
- **`@welshman/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.
|
||||
- **`@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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -576,9 +658,11 @@ await fetch('https://api.example.com/upload', {
|
|||
|
||||
- **Replaceable event identity**: Use `getIdOrAddress` rather than `event.id` when referencing events that may be addressable — the address string is stable across updates, the id is not.
|
||||
|
||||
- **`getAncestors` handles two protocols**: Kind 1111 (comment/NIP-22) uses uppercase `E`/`A` for roots and lowercase for replies, returning `{ roots, replies }`. All other kinds use NIP-10 positional rules, returning `{ roots, replies, mentions }` where `mentions` is always present but may be an empty array. You do not need to branch on this; `getAncestors`, `getParentIdOrAddr`, and `isChildOf` handle it automatically.
|
||||
|
||||
- **List mutations return `Encryptable`**: `addToListPrivately`, `removeFromList`, etc. do not return an event directly. Call `.reconcile(encryptFn)` on the result to get the final `EventTemplate` ready to sign.
|
||||
|
||||
- **`validateZapReceipt` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result. For a reactive list of a parent's valid zaps, use `app.use(Zappers).validZapReceipts(receipts, parent)`, which re-validates as each recipient's zapper loads.
|
||||
- **`zapFromEvent` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result.
|
||||
|
||||
- **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else.
|
||||
|
||||
|
|
|
|||
|
|
@ -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, tag specs, NIPs, and the `RelaySelection` routing DSL |
|
||||
| `@welshman/util` | Core nostr types, event helpers, filters, and NIP implementations |
|
||||
| `@welshman/lib` | General-purpose utilities: LRU cache, event emitter, deferred promises, task queue |
|
||||
| `@welshman/net` | Relay connections, request/publish lifecycle, and auth handling |
|
||||
| `@welshman/domain` | A typed Reader/Writer pair per event kind, so you never hand-parse tags |
|
||||
| `@welshman/router` | Relay selection strategies for reads and writes |
|
||||
| `@welshman/store` | Svelte stores and a Repository for indexing/querying nostr events client-side |
|
||||
| `@welshman/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` | The `App` instance and its plugin registry, composing net, store, domain, signer, and feeds into a full application framework |
|
||||
| `@welshman/app` | High-level Svelte stores that compose net, router, store, signer, and feeds into a full application framework |
|
||||
| `@welshman/content` | Parser and renderer for nostr note content (links, mentions, media, custom formatting) |
|
||||
| `@welshman/editor` | Batteries-included Svelte rich-text editor component with mention and embed support |
|
||||
|
||||
|
|
@ -27,43 +27,25 @@ Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](http
|
|||
Packages are layered so lower-level ones have no welshman dependencies:
|
||||
|
||||
- **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util`
|
||||
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/store`, `@welshman/signer`, `@welshman/domain`
|
||||
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/router`, `@welshman/store`, `@welshman/signer`
|
||||
- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/app`
|
||||
- **UI-focused** (largely independent, UI rendering concerns): `@welshman/content`, `@welshman/editor`
|
||||
|
||||
For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-domain`).
|
||||
For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-signer`).
|
||||
|
||||
## The App instance
|
||||
## Getting started
|
||||
|
||||
Everything in the framework hangs off one `App`. An app owns the primitives a single identity
|
||||
needs — repository, socket pool, tracker, wrap manager — so data never bleeds across sessions.
|
||||
Install only what you need:
|
||||
|
||||
```typescript
|
||||
import {createApp, Network, Profiles, User} from "@welshman/app"
|
||||
```bash
|
||||
# Full application framework (includes app, net, router, store, signer, feeds)
|
||||
npm i @welshman/app
|
||||
|
||||
// `createApp` = `new App` plus the default policies (ingest, relay stats, gift-wrap unwrapping)
|
||||
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})
|
||||
# Or assemble manually for more control
|
||||
npm i @welshman/util @welshman/net @welshman/signer
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Key nostr concepts
|
||||
|
||||
|
|
@ -79,67 +61,91 @@ instance. That binding layer is app-specific and deliberately not part of welshm
|
|||
|
||||
| Goal | Package(s) to use |
|
||||
|---|---|
|
||||
| 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)` |
|
||||
| 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` |
|
||||
| Parse note text and media | `@welshman/content` |
|
||||
| Embed a composer / editor | `@welshman/editor` |
|
||||
| Cache nostr events client-side | `@welshman/store` + `app.repository` |
|
||||
| Core event/filter/tag utilities | `@welshman/util` |
|
||||
| Cache nostr events client-side | `@welshman/store` |
|
||||
| Core event/filter utilities | `@welshman/util` |
|
||||
| Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` |
|
||||
|
||||
## App example
|
||||
### App Example
|
||||
|
||||
```typescript
|
||||
import {createApp, Domain, Profiles, User} from "@welshman/app"
|
||||
import {Note} from "@welshman/domain"
|
||||
import {Nip07Signer} from "@welshman/signer"
|
||||
import "@welshman/app" // side effects: wires pool → repository + tracker + router
|
||||
|
||||
// 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"],
|
||||
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" })
|
||||
},
|
||||
})
|
||||
|
||||
// 2. Read the user's profile (loads from their write relays if not cached)
|
||||
const profile = await app.use(Profiles).load(app.user!.pubkey)
|
||||
const stored: TrustedEvent[] = await db.getAll("events")
|
||||
for (const e of stored) e[verifiedSymbol] = true
|
||||
repository.load(stored)
|
||||
|
||||
console.log("Hello,", profile?.display())
|
||||
// Flush new events to IndexedDB
|
||||
on(repository, "update", batch(3000, async (updates: RepositoryUpdate[]) => {
|
||||
const tx = db.transaction("events", "readwrite")
|
||||
for (const { added, removed } of updates) {
|
||||
for (const e of added) tx.store.put(e)
|
||||
for (const id of removed) tx.store.delete(id)
|
||||
}
|
||||
await tx.done
|
||||
}))
|
||||
|
||||
// 3. Publish a note — build a writer, wrap it in a command, publish it
|
||||
const writer = app.use(Domain).writer(Note).setContent("Hello, Nostr!")
|
||||
const command = await app.use(Domain).command(writer)
|
||||
// 3. Log in
|
||||
const pk = await window.nostr.getPublicKey()
|
||||
loginWithNip07(pk)
|
||||
|
||||
await command.publish().waitForError()
|
||||
// 4. Load user's profile reactively (triggers network fetch if not cached)
|
||||
await loadUserProfile()
|
||||
|
||||
userProfile.subscribe($profile => {
|
||||
if ($profile) console.log("Hello,", $profile.name)
|
||||
})
|
||||
|
||||
// 5. Publish a note
|
||||
import { makeEvent } from "@welshman/util"
|
||||
import { Router } from "@welshman/router"
|
||||
|
||||
const thunk = publishThunk({
|
||||
event: makeEvent(1, { content: "Hello, Nostr!", tags: [] }),
|
||||
relays: Router.get().FromUser().getUrls(),
|
||||
})
|
||||
|
||||
await thunk.complete
|
||||
```
|
||||
|
||||
Publishing goes through a `Command`, which owns the rendered event and its resolved relays:
|
||||
`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.
|
||||
### Lower-level Example
|
||||
|
||||
```typescript
|
||||
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"
|
||||
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'
|
||||
|
||||
const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
|
||||
const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
|
||||
const RELAY_URL = "bogus.relay"
|
||||
|
||||
// An adapter for our relay url which just prints the content
|
||||
// Create an adapter for our relay url which just prints the content
|
||||
export class PrintAdapter extends AbstractAdapter {
|
||||
get sockets() { return [] }
|
||||
get urls() { return [] }
|
||||
|
|
@ -151,32 +157,37 @@ export class PrintAdapter extends AbstractAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
// Context is passed explicitly. An `App` supplies its own via `app.netContext`; here we build one.
|
||||
const context: NetContext = {
|
||||
getAdapter: (url: string) => (url === RELAY_URL ? new PrintAdapter() : undefined),
|
||||
// Configure net context to use our custom adapter
|
||||
netContext.getAdapter = (url: string, context: NetContext) => {
|
||||
if (url === RELAY_URL) {
|
||||
return new PrintAdapter()
|
||||
}
|
||||
}
|
||||
|
||||
// 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], context})
|
||||
await publish({event: ping, relays: [RELAY_URL]})
|
||||
}
|
||||
})
|
||||
|
||||
// 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], context})
|
||||
await publish({event: pong, relays: [RELAY_URL]})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
7
.env
7
.env
|
|
@ -9,8 +9,8 @@ VITE_PLATFORM_NAME=Flotilla
|
|||
VITE_PLATFORM_LOGO=static/logo.png
|
||||
VITE_PLATFORM_RELAYS=
|
||||
VITE_PLATFORM_ACCENT="#7161FF"
|
||||
VITE_THEME="clay"
|
||||
VITE_PLATFORM_DESCRIPTION="The chat app built for self-hosted communities."
|
||||
VITE_PLATFORM_SECONDARY="#EB5E28"
|
||||
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
|
||||
VITE_PUSH_SERVER=https://nps.flotilla.social/
|
||||
VITE_PUSH_BRIDGE=wss://npb.coracle.social/
|
||||
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
|
||||
|
|
@ -19,8 +19,7 @@ VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
|
|||
VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
|
||||
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
|
||||
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
|
||||
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
|
||||
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
|
||||
VITE_GLITCHTIP_API_KEY=
|
||||
GLITCHTIP_AUTH_TOKEN=
|
||||
VITE_HOSTING_BACKEND_URL=https://api.hosting.coracle.social
|
||||
VITE_HOSTING_RELAY_DOMAIN=spaces.flotilla.social
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -67,7 +67,6 @@ local.properties
|
|||
proguard/
|
||||
google-services.json
|
||||
GoogleService-Info.plist
|
||||
ic_stat_notify.png
|
||||
|
||||
# IDEs and editors
|
||||
.roo
|
||||
|
|
@ -75,7 +74,6 @@ ic_stat_notify.png
|
|||
.vscode
|
||||
.claude
|
||||
.local
|
||||
CLAUDE.md
|
||||
|
||||
# OS generated
|
||||
.DS_Store
|
||||
|
|
|
|||
148
AGENTS.md
148
AGENTS.md
|
|
@ -6,7 +6,7 @@ Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-
|
|||
|
||||
- SvelteKit 5.48+ with TypeScript 5.9+
|
||||
- Capacitor for cross-platform (Web/PWA, Android, iOS)
|
||||
- TailwindCSS for styling
|
||||
- TailwindCSS + DaisyUI for styling
|
||||
- Welshman library suite for Nostr protocol
|
||||
- IndexedDB for local storage
|
||||
- Vite for building
|
||||
|
|
@ -26,7 +26,7 @@ routes/ (top layer - can depend on anything)
|
|||
↓
|
||||
app/components/ (can depend on app/* and lib/*)
|
||||
↓
|
||||
app/* (can only depend on lib/*)
|
||||
app/core/ & app/util/ (can only depend on lib/*)
|
||||
↓
|
||||
lib/ (can only depend on external libraries)
|
||||
↓
|
||||
|
|
@ -45,42 +45,76 @@ Example:
|
|||
```typescript
|
||||
import {derived} from "svelte/store"
|
||||
import {throttle} from "throttle-debounce"
|
||||
import {Profiles} from "@welshman/app"
|
||||
import {Dialog} from "$lib/components"
|
||||
import {app} from "@app/core"
|
||||
import {repository} from "$app/core/state"
|
||||
```
|
||||
|
||||
## Cleanup Pass
|
||||
## File Structure
|
||||
|
||||
These are the things that most often get fixed by hand after the fact. Go through
|
||||
them before handing work back. The full style reference is under Development
|
||||
Conventions below.
|
||||
|
||||
- **Comments** — only for genuinely surprising things: a workaround, a constraint imposed by a backend or platform, an invariant that isn't visible from the code in front of you. Never comment props, never restate what the next line does, never justify an ordinary decision. If a small refactor would make the comment stale, don't write it.
|
||||
- **Used once, inlined** — a derived value, helper, type, or named constant with a single use is indirection. Write `setTimeout(pollOnce, 3500)`, not a `POLL_INTERVAL` referenced twice in one file. Name something only when the name is what makes the code readable.
|
||||
- **Positive conditionals** — prefer `if (ready) { ... }` over `if (!ready) return`. Nesting is fine; when it gets deep that's the signal the function is doing too much, so split it. Many early returns belong in validation or pipeline functions, not everywhere else.
|
||||
- **Truthiness** — test values directly (`if (invoice.paid_at)`) instead of comparing against `null`/`undefined`, and put the truthy branch first in a ternary.
|
||||
- **Standard names** — `loading` for in-flight state, `on*` for handlers bound to an event, a plain verb (`submit`, `save`) for the action itself. Use `{prop}` shorthand when the names match.
|
||||
- **Errors** — toast a human-readable message and `console.error` anything unexpected (e.g. a non-`HostingError`). Never swallow an error you didn't anticipate.
|
||||
```
|
||||
src/
|
||||
├── lib/ # Generic reusable code
|
||||
│ ├── components/ # 38 UI components (Button, Dialog, etc.)
|
||||
│ ├── html.ts # DOM utilities
|
||||
│ ├── indexeddb.ts # IndexedDB helpers
|
||||
│ └── util.ts # Generic utilities
|
||||
│
|
||||
├── app/
|
||||
│ ├── core/
|
||||
│ │ ├── state.ts # State management, stores, constants (687 lines)
|
||||
│ │ ├── commands.ts # Publishing events and other write operations (440+ lines)
|
||||
│ │ ├── requests.ts # Loading data from network (191 lines)
|
||||
│ │ ├── sync.ts # Data synchronization (296 lines)
|
||||
│ │ └── storage.ts # IndexedDB setup
|
||||
│ │
|
||||
│ ├── util/
|
||||
│ │ ├── notifications.ts # Push notifications (731 lines)
|
||||
│ │ ├── policies.ts # Relay policies
|
||||
│ │ ├── routes.ts # Routing helpers
|
||||
│ │ ├── modal.ts # Modal management
|
||||
│ │ ├── toast.ts # Toast notifications
|
||||
│ │ ├── theme.ts # Theme switching
|
||||
│ │ └── keyboard.ts # Keyboard handling
|
||||
│ │
|
||||
│ ├── editor/ # Rich text editor config
|
||||
│ │ ├── index.ts # TipTap setup with Nostr integration
|
||||
│ │ ├── EditorContent.svelte
|
||||
│ │ └── MentionNodeView.ts
|
||||
│ │
|
||||
│ └── components/ # 188 app-specific components
|
||||
│ ├── Space*.svelte # Space/relay management
|
||||
│ ├── Room*.svelte # Room/channel management
|
||||
│ ├── Chat*.svelte # Direct messaging
|
||||
│ ├── Profile*.svelte # User profiles
|
||||
│ ├── Thread*.svelte # Threaded posts
|
||||
│ └── ...
|
||||
│
|
||||
├── routes/ # SvelteKit file-based routing
|
||||
│ ├── +layout.svelte # Root layout (sync logic here)
|
||||
│ ├── spaces/ # Space management
|
||||
│ │ └── [relay]/ # Specific space
|
||||
│ │ ├── chat/ # Space chat
|
||||
│ │ ├── threads/ # Thread posts
|
||||
│ │ ├── calendar/ # Events
|
||||
│ │ └── [h]/ # Specific room (h = room id)
|
||||
│ ├── chat/ # Direct messages
|
||||
│ ├── settings/ # User settings
|
||||
│ └── [bech32]/ # Bech32 entity viewer
|
||||
│
|
||||
├── assets/icons/ # ~1,277 SVG icons
|
||||
├── app.html # HTML template
|
||||
├── app.css # Global styles
|
||||
└── types.d.ts # Type definitions
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
**Core Principles:**
|
||||
|
||||
- Use Svelte 4 **stores** for all state (NOT runes outside UI components)
|
||||
- Everything hangs off the single `App` instance in `app/core.ts`. There are no welshman globals —
|
||||
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.
|
||||
- Most global state flows through Welshman's `repository` (unidirectional)
|
||||
- Query state using `deriveEventsMapped` or `deriveProfile` etc
|
||||
- Update state by publishing events via `publishThunk`
|
||||
|
||||
**Thunks:**
|
||||
|
||||
|
|
@ -93,12 +127,12 @@ callbacks and hot paths.
|
|||
|
||||
**Welshman Library Suite:**
|
||||
|
||||
- `@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/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/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
|
||||
|
|
@ -127,30 +161,24 @@ callbacks and hot paths.
|
|||
|
||||
- **No `null`** - only use `undefined`
|
||||
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
|
||||
- TailwindCSS styling with css components customized by theme. See lib/components for examples.
|
||||
- Comments, naming, conditionals and single-use indirection are covered by the Cleanup Pass above.
|
||||
- TailwindCSS and DaisyUI styling
|
||||
- Only add comments for really weird stuff
|
||||
- Do not call functions in components unless a parameter is reactive. Instead, use a svelte store or rune to make it reactive.
|
||||
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
|
||||
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.
|
||||
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
|
||||
- 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, 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.
|
||||
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
|
||||
- Do not render a profile's `about` directly (e.g. `profile.about`); use the `ProfileInfo` 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.
|
||||
|
||||
**Human-First Simplicity:**
|
||||
**Human-First Simplicity (Jon Staab Style):**
|
||||
|
||||
- Prefer direct, readable code over layered abstractions.
|
||||
- Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity.
|
||||
- Abstractions must be either: elegant and self-explanatory (bottom-up design), or used in at least 3 locations (ugly glue code).
|
||||
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies. See /welshman-* skills for details.
|
||||
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies.
|
||||
- Favor linear control flow and explicit naming over clever patterns.
|
||||
- Remove defensive checks that do not apply in this runtime model.
|
||||
- When two approaches work, pick the one that feels more human and easier to maintain.
|
||||
|
|
@ -173,23 +201,20 @@ callbacks and hot paths.
|
|||
|
||||
### Loading Data from Network
|
||||
|
||||
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
|
||||
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`
|
||||
|
||||
### Publishing Events
|
||||
|
||||
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.
|
||||
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`
|
||||
|
||||
### Managing Modals/Toasts
|
||||
|
||||
- Import from `app/modal.ts` or `app/toast.ts`
|
||||
- Import from `app/util/modal.ts` or `app/util/toast.ts`
|
||||
- Pass component objects with parameters
|
||||
- Use `$state.snapshot` if calling component might unmount
|
||||
|
||||
|
|
@ -219,6 +244,15 @@ pnpm run check # Type check
|
|||
|
||||
See `.env.template` for all options.
|
||||
|
||||
## Important Files to Reference
|
||||
|
||||
- **src/app/core/state.ts** - All stores and constants
|
||||
- **src/app/core/sync.ts** - Data synchronization
|
||||
- **src/app/core/requests.ts** - Utilities for requesting data
|
||||
- **src/app/core/commands.ts** - Publishing patterns
|
||||
- **src/app/util/notifications.ts** - Notification badges and push notifications
|
||||
- **src/routes/+layout.svelte** - Top-level sync logic
|
||||
|
||||
## Mobile Development
|
||||
|
||||
**Capacitor Integration:**
|
||||
|
|
|
|||
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -1,22 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
# 1.9.0
|
||||
|
||||
* Fix nav safe are inset, qr code reactivity
|
||||
* Performance optimizations for chat rooms
|
||||
* Add hosting platform
|
||||
* Add room invite links
|
||||
* Improve audio/video calls
|
||||
* Bugfixes and UI refinements
|
||||
* Fix push notification icon
|
||||
* Swap datepicker implementations
|
||||
* Fix popover clipping
|
||||
* Add member directory page
|
||||
* Add pinboards and content library
|
||||
* Improve white-labeled support
|
||||
* Add full profile page at /people/[npub]
|
||||
* Add theming support, improve app design
|
||||
|
||||
# 1.8.2
|
||||
|
||||
* Fix thread board styling
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ You can also optionally create an `.env.local` file and populate it with the fol
|
|||
- `VITE_PLATFORM_URL` - The url where the app will be hosted
|
||||
- `VITE_PLATFORM_NAME` - The name of the app
|
||||
- `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file.
|
||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color (used only for generated manifest, for more control create a custom theme file)
|
||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
||||
- `VITE_PLATFORM_TERMS` - URL to your terms of service page
|
||||
- `VITE_PLATFORM_PRIVACY` - URL to your privacy policy page
|
||||
|
|
@ -22,7 +22,7 @@ You can also optionally create an `.env.local` file and populate it with the fol
|
|||
|
||||
**Defaults**
|
||||
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
|
||||
- `VITE_DEFAULT_SPACES` - A comma-separated list of relay urls that new users will be automatically joined to on signup. Each one may optionally include an invite code, delimited by `|`, e.g. `my.space.com|CODE`.
|
||||
- `VITE_DEFAULT_SPACES` - A comma-separated list of relay urls that new users will be automatically joined to on signup
|
||||
- `VITE_DEFAULT_RELAYS` - A comma-separated list of relay urls used as default outbox/inbox relays
|
||||
- `VITE_DEFAULT_MESSAGING_RELAYS` - A comma-separated list of relay urls used for encrypted direct messages
|
||||
- `VITE_DEFAULT_BLOSSOM_SERVERS` - A comma-separated list of blossom server urls used for file uploads
|
||||
|
|
@ -33,6 +33,7 @@ You can also optionally create an `.env.local` file and populate it with the fol
|
|||
- `VITE_BLOCKED_RELAYS` - A comma-separated list of relay urls that will be blocked
|
||||
- `VITE_PUSH_SERVER` - URL of the push notification server
|
||||
- `VITE_PUSH_BRIDGE` - WebSocket URL of the push notification relay bridge
|
||||
- `VITE_VAPID_PUBLIC_KEY` - VAPID public key for web push notifications
|
||||
- `VITE_POMADE_SIGNERS` - A comma-separated list of Pomade signer server URLs (3+ required to enable email signup)
|
||||
- `VITE_THUMBNAIL_URL` - URL of the image thumbnail service
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ android {
|
|||
applicationId "social.flotilla"
|
||||
minSdk rootProject.ext.minSdkVersion
|
||||
targetSdk rootProject.ext.targetSdkVersion
|
||||
versionCode 50
|
||||
versionName "1.9.0"
|
||||
versionCode 49
|
||||
versionName "1.8.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
|
|||
|
|
@ -36,19 +36,6 @@
|
|||
android:grantUriPermissions="true">
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- FCM uses these when it renders a notification-payload message itself
|
||||
(app backgrounded/killed). Without an explicit monochrome small icon
|
||||
it falls back to the full-color adaptive launcher icon, which many
|
||||
Android builds render as a blank square or drop entirely. -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_stat_notify"
|
||||
/>
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/default_notification_channel_id"
|
||||
/>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
package social.flotilla;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
|
@ -13,28 +10,6 @@ public class MainActivity extends BridgeActivity {
|
|||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(AndroidPushFallbackPlugin.class);
|
||||
createPushNotificationChannel();
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
// Ensures the channel named by the FCM default_notification_channel_id metadata
|
||||
// exists. FCM does not create it for us; if it's missing when a notification
|
||||
// arrives, Android falls back to a generic "Miscellaneous" channel. Channels
|
||||
// persist once created, so doing this on launch is sufficient.
|
||||
private void createPushNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||
String id = getString(R.string.default_notification_channel_id);
|
||||
|
||||
if (manager != null && manager.getNotificationChannel(id) == null) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
id,
|
||||
"Notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
);
|
||||
channel.setDescription("New messages and activity");
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
state.optString("pubkey", ""),
|
||||
session.optJSONObject("data") ?: JSONObject(),
|
||||
session.optString("pubkey", ""),
|
||||
session,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -537,7 +537,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||
private data class SessionInfo(
|
||||
val method: String,
|
||||
val pubkey: String,
|
||||
val data: JSONObject,
|
||||
val session: JSONObject,
|
||||
)
|
||||
|
||||
private data class Subscription(
|
||||
|
|
@ -699,23 +699,24 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
|
|||
}
|
||||
|
||||
private fun tryNip01Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||
val secret = sessionInfo.data.optString("secret", "")
|
||||
val secret = sessionInfo.session.optString("secret", "")
|
||||
if (secret.isEmpty()) return false
|
||||
val signed = signWithNip01Secret(secret, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
||||
return sendAuthMessage(webSocket, signed)
|
||||
}
|
||||
|
||||
private fun tryNip55Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||
val signerPackage = sessionInfo.data.optString("signer", "")
|
||||
val signerPackage = sessionInfo.session.optString("signer", "")
|
||||
if (signerPackage.isEmpty()) return false
|
||||
val signed = signWithNip55ContentResolver(signerPackage, buildAuthEvent(challenge).toString(), sessionInfo.pubkey)
|
||||
return sendAuthMessage(webSocket, signed)
|
||||
}
|
||||
|
||||
private fun tryNip46Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||
val clientSecret = sessionInfo.data.optString("clientSecret", "")
|
||||
val signerPubkey = sessionInfo.data.optString("signerPubkey", "")
|
||||
val relays = sessionInfo.data.optJSONArray("relays")
|
||||
val handler = sessionInfo.session.optJSONObject("handler") ?: return false
|
||||
val clientSecret = sessionInfo.session.optString("secret", "")
|
||||
val signerPubkey = handler.optString("pubkey", "")
|
||||
val relays = handler.optJSONArray("relays")
|
||||
|
||||
if (clientSecret.isEmpty() || signerPubkey.isEmpty() || relays == null || relays.length() == 0) return false
|
||||
|
||||
|
|
|
|||
|
|
@ -4,5 +4,4 @@
|
|||
<string name="title_activity_main">Flotilla</string>
|
||||
<string name="package_name">social.flotilla</string>
|
||||
<string name="custom_url_scheme">social.flotilla</string>
|
||||
<string name="default_notification_channel_id">flotilla_push</string>
|
||||
</resources>
|
||||
|
|
|
|||
8
build.sh
8
build.sh
|
|
@ -17,11 +17,6 @@ fi
|
|||
if [[ $VITE_PLATFORM_LOGO =~ ^https:// ]]; then
|
||||
curl -fSL "$VITE_PLATFORM_LOGO" -o static/logo.png
|
||||
export VITE_PLATFORM_LOGO=static/logo.png
|
||||
|
||||
# @capacitor/assets reads its source logo from assets/, not static/, so
|
||||
# propagate a custom platform logo there too for the native app icon/splash
|
||||
cp "$VITE_PLATFORM_LOGO" assets/logo.png
|
||||
cp "$VITE_PLATFORM_LOGO" assets/logo-dark.png
|
||||
fi
|
||||
|
||||
# Ensure generator uses local path (dotenv may have loaded URL from .env)
|
||||
|
|
@ -40,6 +35,3 @@ npx @capacitor/assets generate \
|
|||
--iconBackgroundColorDark '#222222' \
|
||||
--splashBackgroundColor '#ffffff' \
|
||||
--splashBackgroundColorDark '#191E24'
|
||||
|
||||
# @capacitor/assets doesn't generate Android notification icons
|
||||
node scripts/generate-notification-icon.mjs
|
||||
|
|
|
|||
|
|
@ -26,10 +26,6 @@ export default [
|
|||
parser: ts.parser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// eslint doesn't see type parameters declared by <script generics="...">
|
||||
"no-undef": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["build/", ".svelte-kit/", "dist/"],
|
||||
|
|
@ -44,15 +40,6 @@ export default [
|
|||
"no-extra-semi": "off",
|
||||
"no-async-promise-executor": "off",
|
||||
"prefer-const": ["error", {destructuring: "all"}],
|
||||
"no-eq-null": "error",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector:
|
||||
"Literal[raw='null']:not(CallExpression[callee.property.name='stringify'] > Literal)",
|
||||
message: "Use undefined instead of null.",
|
||||
},
|
||||
],
|
||||
"svelte/valid-compile": "off",
|
||||
"svelte/no-at-html-tags": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
|
|
|
|||
|
|
@ -372,7 +372,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 41;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
|
|
@ -381,7 +381,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.8.2;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -401,7 +401,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 41;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
|
|
@ -410,7 +410,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.9.0;
|
||||
MARKETING_VERSION = 1.8.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
28
package.json
28
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "flotilla",
|
||||
"version": "1.9.0",
|
||||
"version": "1.8.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
|
@ -35,7 +35,6 @@
|
|||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"sharp": "^0.35.3",
|
||||
"svelte": "^5.55.9",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.2.2",
|
||||
|
|
@ -63,9 +62,8 @@
|
|||
"@getalby/sdk": "^5.1.2",
|
||||
"@hono/node-server": "^2.0.0",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@pomade/core": "^0.3.3",
|
||||
"@pomade/core": "^0.3.1",
|
||||
"@poppanator/sveltekit-svg": "^7.0.0",
|
||||
"@svelte-plugins/datepicker": "^1.0.11",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@tiptap/core": "^2.27.2",
|
||||
"@tiptap/pm": "^2.27.2",
|
||||
|
|
@ -73,18 +71,20 @@
|
|||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@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",
|
||||
"@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",
|
||||
"cheerio": "^1.2.0",
|
||||
"compressorjs-next": "^1.1.2",
|
||||
"daisyui": "^5.5.19",
|
||||
"date-picker-svelte": "^2.17.0",
|
||||
"dotenv": "^16.6.1",
|
||||
"emoji-picker-element": "^1.28.1",
|
||||
"emoji-picker-element-data": "^1.8.0",
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -11,19 +11,9 @@ minimumReleaseAgeExclude:
|
|||
- '@welshman/feeds'
|
||||
- '@welshman/lib'
|
||||
- '@welshman/net'
|
||||
- '@welshman/domain'
|
||||
- '@welshman/router'
|
||||
- '@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
|
||||
sharp: 0.35.0-rc.0
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// Generates the Android notification (status-bar) small icon from the app's
|
||||
// source logo. @capacitor/assets does NOT produce notification icons, so the
|
||||
// build pipeline calls this after `@capacitor/assets generate` to keep the
|
||||
// notification icon in sync with a custom platform logo (VITE_PLATFORM_LOGO).
|
||||
//
|
||||
// A notification small icon must be a monochrome silhouette: Android renders
|
||||
// only its alpha channel and tints it. We take the logo's alpha channel as the
|
||||
// shape and fill it white, with a little transparent padding so it isn't
|
||||
// edge-to-edge in the status bar.
|
||||
import sharp from "sharp"
|
||||
import {mkdir} from "node:fs/promises"
|
||||
import {dirname, join} from "node:path"
|
||||
|
||||
const source = process.argv[2] || "assets/logo.png"
|
||||
const resDir = process.argv[3] || "android/app/src/main/res"
|
||||
|
||||
// density bucket -> small-icon size in px
|
||||
const densities = {mdpi: 24, hdpi: 36, xhdpi: 48, xxhdpi: 72, xxxhdpi: 96}
|
||||
|
||||
const generate = async (size, out) => {
|
||||
const pad = Math.round(size * 0.14)
|
||||
const inner = size - pad * 2
|
||||
|
||||
// The logo's alpha channel is the shape we want to render. Trim the source's
|
||||
// own transparent margin first — logo.png carries ~15% baked-in padding, and
|
||||
// without trimming that stacks on top of `pad` below, leaving the glyph at
|
||||
// only ~half the icon's width. Trimming lets `pad` be the single source of
|
||||
// padding so the shape actually fills the status-bar icon.
|
||||
const alpha = await sharp(source)
|
||||
.trim({threshold: 1})
|
||||
.resize(inner, inner, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
||||
.ensureAlpha()
|
||||
.extractChannel(3)
|
||||
.toColourspace("b-w")
|
||||
.raw()
|
||||
.toBuffer()
|
||||
|
||||
await sharp({
|
||||
create: {width: inner, height: inner, channels: 3, background: {r: 255, g: 255, b: 255}},
|
||||
})
|
||||
.joinChannel(alpha, {raw: {width: inner, height: inner, channels: 1}})
|
||||
.extend({top: pad, bottom: pad, left: pad, right: pad, background: {r: 0, g: 0, b: 0, alpha: 0}})
|
||||
.png()
|
||||
.toFile(out)
|
||||
|
||||
console.log(`wrote ${out} (${size}px)`)
|
||||
}
|
||||
|
||||
for (const [density, size] of Object.entries(densities)) {
|
||||
const out = join(resDir, `drawable-${density}`, "ic_stat_notify.png")
|
||||
await mkdir(dirname(out), {recursive: true})
|
||||
await generate(size, out)
|
||||
}
|
||||
459
src/app.css
Normal file
459
src/app.css
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
/* root */
|
||||
|
||||
:root {
|
||||
font-family: Lato;
|
||||
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
@utility pt-sai {
|
||||
padding-top: var(--sait);
|
||||
}
|
||||
|
||||
@utility pr-sai {
|
||||
padding-right: var(--sair);
|
||||
}
|
||||
|
||||
@utility pb-sai {
|
||||
padding-bottom: var(--saib);
|
||||
}
|
||||
|
||||
@utility pl-sai {
|
||||
padding-left: var(--sail);
|
||||
}
|
||||
|
||||
@utility px-sai {
|
||||
@apply pl-sai pr-sai;
|
||||
}
|
||||
|
||||
@utility py-sai {
|
||||
@apply pt-sai pb-sai;
|
||||
}
|
||||
|
||||
@utility p-sai {
|
||||
@apply py-sai px-sai;
|
||||
}
|
||||
|
||||
@utility mt-sai {
|
||||
margin-top: var(--sait);
|
||||
}
|
||||
|
||||
@utility mr-sai {
|
||||
margin-right: var(--sair);
|
||||
}
|
||||
|
||||
@utility mb-sai {
|
||||
margin-bottom: var(--saib);
|
||||
}
|
||||
|
||||
@utility ml-sai {
|
||||
margin-left: var(--sail);
|
||||
}
|
||||
|
||||
@utility mx-sai {
|
||||
@apply ml-sai mr-sai;
|
||||
}
|
||||
|
||||
@utility my-sai {
|
||||
@apply mt-sai mb-sai;
|
||||
}
|
||||
|
||||
@utility m-sai {
|
||||
@apply my-sai mx-sai;
|
||||
}
|
||||
|
||||
@utility top-sai {
|
||||
top: var(--sait);
|
||||
}
|
||||
|
||||
@utility right-sai {
|
||||
right: var(--sair);
|
||||
}
|
||||
|
||||
@utility bottom-sai {
|
||||
bottom: var(--saib);
|
||||
}
|
||||
|
||||
@utility left-sai {
|
||||
left: var(--sail);
|
||||
}
|
||||
|
||||
@utility card2 {
|
||||
@apply rounded-box text-base-content border-base-content/20 bg-base-100 border border-solid p-4 shadow-xl/5 sm:p-6;
|
||||
}
|
||||
|
||||
@utility card2-interactive {
|
||||
@apply cursor-pointer hover:scale-101 transition-all;
|
||||
}
|
||||
|
||||
@utility column {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
@utility center {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
@utility row-2 {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
@utility row-3 {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
@utility row-4 {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
@utility col-2 {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
@utility col-3 {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
@utility col-4 {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
@utility col-8 {
|
||||
@apply flex flex-col gap-8;
|
||||
}
|
||||
|
||||
@utility ellipsize {
|
||||
@apply overflow-hidden text-ellipsis;
|
||||
}
|
||||
|
||||
@utility content-padding-x {
|
||||
@apply px-4 sm:px-8 md:px-12;
|
||||
}
|
||||
|
||||
@utility content-padding-t {
|
||||
@apply pt-4 sm:pt-8 md:pt-12;
|
||||
}
|
||||
|
||||
@utility content-padding-b {
|
||||
@apply pb-4 sm:pb-8 md:pb-12;
|
||||
}
|
||||
|
||||
@utility content-padding-y {
|
||||
@apply pt-4 pb-4 sm:pt-8 sm:pb-8 md:pt-12 md:pb-12;
|
||||
}
|
||||
|
||||
@utility content-sizing {
|
||||
@apply m-auto w-full max-w-3xl;
|
||||
}
|
||||
|
||||
@utility content {
|
||||
@apply m-auto w-full max-w-3xl px-4 pt-4 pb-4 sm:px-8 sm:pt-8 sm:pb-8 md:px-12 md:pt-12 md:pb-12;
|
||||
}
|
||||
|
||||
@utility heading {
|
||||
@apply text-center text-2xl;
|
||||
}
|
||||
|
||||
@utility subheading {
|
||||
@apply text-center text-xl;
|
||||
}
|
||||
|
||||
@utility superheading {
|
||||
@apply text-center text-4xl;
|
||||
}
|
||||
|
||||
@utility link {
|
||||
@apply text-primary cursor-pointer underline;
|
||||
}
|
||||
|
||||
/* content visibility */
|
||||
|
||||
@utility cv {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Fonts */
|
||||
|
||||
@font-face {
|
||||
font-family: "Satoshis";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Satoshi Symbol.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Lato-Regular.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: bold;
|
||||
font-weight: 600;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Lato-Bold.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Italic.ttf") format("truetype");
|
||||
}
|
||||
|
||||
/* root */
|
||||
|
||||
:root {
|
||||
font-family: Lato;
|
||||
text-size-adjust: 100%;
|
||||
--sait: var(--safe-area-inset-top, env(safe-area-inset-top));
|
||||
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
[data-theme] {
|
||||
@apply bg-base-300;
|
||||
}
|
||||
|
||||
.mobile [data-tip]::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* safe area insets */
|
||||
}
|
||||
|
||||
/* utilities */
|
||||
|
||||
.bg-alt,
|
||||
.bg-alt .bg-alt .bg-alt,
|
||||
.hover\:bg-alt:hover,
|
||||
.bg-alt .bg-alt .hover\:bg-alt:hover,
|
||||
.bg-alt .bg-alt.hover\:bg-alt:hover {
|
||||
@apply bg-base-100 text-base-content transition-colors;
|
||||
}
|
||||
|
||||
.bg-alt .bg-alt,
|
||||
.bg-alt .bg-alt .bg-alt .bg-alt,
|
||||
.bg-alt .hover\:bg-alt:hover,
|
||||
.bg-alt .bg-alt .bg-alt .hover\:bg-alt:hover,
|
||||
.bg-alt.hover\:bg-alt:hover,
|
||||
.bg-alt .bg-alt .bg-alt.hover\:bg-alt:hover {
|
||||
@apply bg-base-300 text-base-content transition-colors;
|
||||
}
|
||||
|
||||
.card2.card2-sm {
|
||||
@apply text-base-content p-2 sm:p-4;
|
||||
}
|
||||
|
||||
.card2 .card2,
|
||||
.dialog .card2 {
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
[data-tip]::before {
|
||||
@apply overflow-hidden text-ellipsis;
|
||||
}
|
||||
|
||||
.input input::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* tiptap */
|
||||
|
||||
.input-editor,
|
||||
.chat-editor,
|
||||
.note-editor {
|
||||
@apply -m-1 p-1;
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
--tiptap-object-bg: var(--color-neutral);
|
||||
--tiptap-object-fg: var(--color-neutral-content);
|
||||
--tiptap-active-bg: var(--color-primary);
|
||||
--tiptap-active-fg: var(--color-primary-content);
|
||||
}
|
||||
|
||||
.tiptap-suggestions {
|
||||
--tiptap-object-bg: var(--color-base-100);
|
||||
--tiptap-object-fg: var(--color-base-content);
|
||||
--tiptap-active-bg: var(--color-base-300);
|
||||
--tiptap-active-fg: var(--color-base-content);
|
||||
}
|
||||
|
||||
.tiptap-suggestions__item {
|
||||
@apply border-base-100 border-l-2 border-solid;
|
||||
}
|
||||
|
||||
.tiptap-suggestions__selected {
|
||||
@apply border-primary;
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
@apply max-h-[350px] min-h-10 overflow-y-auto p-2 px-4;
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
opacity: 40%;
|
||||
}
|
||||
|
||||
.chat-editor .tiptap {
|
||||
@apply rounded-box bg-base-300 pr-12;
|
||||
}
|
||||
|
||||
.note-editor .tiptap {
|
||||
--tiptap-object-bg: var(--color-base-200);
|
||||
@apply input rounded-box block h-auto min-h-32 w-full p-[.65rem] pb-6;
|
||||
}
|
||||
|
||||
.input-editor .tiptap {
|
||||
--tiptap-object-bg: var(--color-base-200);
|
||||
@apply input block h-auto p-[.65rem];
|
||||
}
|
||||
|
||||
/* link-content, based on tiptap */
|
||||
|
||||
.link-content {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-radius: 3px;
|
||||
padding: 0 0.25rem;
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
/* content rendered by welshman/content */
|
||||
|
||||
.welshman-content a {
|
||||
@apply link;
|
||||
}
|
||||
|
||||
.welshman-content-error a {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
/* date input */
|
||||
|
||||
.picker {
|
||||
--date-picker-foreground: var(--color-base-content);
|
||||
--date-picker-background: var(--color-base-300);
|
||||
--date-picker-highlight-border: var(--color-primary);
|
||||
--date-picker-selected-color: var(--color-primary-content);
|
||||
--date-picker-selected-background: var(--color-primary);
|
||||
}
|
||||
|
||||
.date-time-field {
|
||||
@apply input rounded-lg px-0;
|
||||
}
|
||||
|
||||
.date-time-field input {
|
||||
@apply h-full! w-full! rounded-lg! border-none! bg-inherit! px-4! text-inherit!;
|
||||
}
|
||||
|
||||
/* tippy popover */
|
||||
|
||||
.tippy-target {
|
||||
@apply z-tooltip pointer-events-none fixed inset-0;
|
||||
}
|
||||
|
||||
.tippy-target > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tippy-box {
|
||||
@apply rounded-box shadow-xl;
|
||||
}
|
||||
|
||||
/* emoji picker */
|
||||
|
||||
emoji-picker {
|
||||
--background: var(--color-base-100);
|
||||
--border-color: var(--color-base-100);
|
||||
--border-radius: var(--rounded-box);
|
||||
--button-active-background: var(--color-base-content);
|
||||
--button-hover-background: var(--color-base-content);
|
||||
--indicator-color: var(--color-base-content);
|
||||
--input-border-color: var(--color-base-100);
|
||||
--input-font-color: var(--color-base-content);
|
||||
--outline-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* progress */
|
||||
|
||||
progress[value]::-webkit-progress-value {
|
||||
transition: width 0.5s;
|
||||
}
|
||||
|
||||
/* content width for fixed elements */
|
||||
|
||||
.left-content {
|
||||
@apply left-sai md:left-[calc(18.5rem+var(--sail))];
|
||||
}
|
||||
|
||||
.left-content-full {
|
||||
@apply left-sai md:left-[calc(3.5rem+var(--sail))];
|
||||
}
|
||||
|
||||
/* Keyboard open state adjustments */
|
||||
|
||||
body.keyboard-open {
|
||||
--saib: 0px;
|
||||
}
|
||||
|
||||
body.keyboard-open .hide-on-keyboard {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.keyboard-open .chat__compose {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* chat view */
|
||||
|
||||
.chat__compose {
|
||||
@apply z-compose relative mb-14 shrink-0 md:mb-0;
|
||||
}
|
||||
|
||||
.chat__compose .chat__compose-inner {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.chat__scroll-down {
|
||||
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
<link rel="icon" href="/pwa-64x64.png" sizes="64x64" type="image/png" />
|
||||
<link rel="icon" href="/pwa-192x192.png" sizes="192x192" type="image/png" />
|
||||
<link rel="icon" href="/pwa-512x512.png" sizes="512x512" type="image/png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon-180x180.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" data-sveltekit-preload-code="eager">
|
||||
|
|
|
|||
|
|
@ -1,380 +0,0 @@
|
|||
import {derived, writable, type Readable} from "svelte/store"
|
||||
import {dissoc, fromPairs, last, poll, randomId, sleep, tryCatch} from "@welshman/lib"
|
||||
import {AuthStatus, SocketStatus} from "@welshman/net"
|
||||
import type {Socket} from "@welshman/net"
|
||||
import {
|
||||
displayRelayUrl,
|
||||
isRelayUrl,
|
||||
makeEvent,
|
||||
normalizeRelayUrl,
|
||||
FOLLOWS,
|
||||
MESSAGING_RELAYS,
|
||||
PROFILE,
|
||||
RELAY_INVITE,
|
||||
RELAYS,
|
||||
} from "@welshman/util"
|
||||
import {RelayInvite, RelayJoin, RelayLeave, RoomJoin} from "@welshman/domain"
|
||||
import {Sync, User, publish} from "@welshman/app"
|
||||
import {stripPrefix} from "@lib/util"
|
||||
import {app, command, network, reader, roomLists, thunks, writer} from "@app/core"
|
||||
import {PLATFORM_URL} from "@app/env"
|
||||
import {relaysMostlyRestricted} from "@app/policies"
|
||||
import {Push} from "@app/push"
|
||||
import {deriveSocket} from "@app/relays"
|
||||
import {notificationSettings, setSpaceNotifications} from "@app/settings"
|
||||
import {syncApplicationData} from "@app/sync"
|
||||
|
||||
export const ROOM_CREATE_INVITE = 9009
|
||||
|
||||
export type InviteData = {
|
||||
url: string
|
||||
claim: string
|
||||
h?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export const isNetworkAuthError = (error: string) => {
|
||||
const lower = error.toLowerCase()
|
||||
|
||||
return lower.includes("failed") || lower.includes("timeout") || lower.includes("network")
|
||||
}
|
||||
|
||||
export const parseInviteLink = (invite: string): InviteData | undefined => {
|
||||
if (invite.length < 3 || !invite.includes(".")) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
tryCatch(() => {
|
||||
const params = fromPairs(Array.from(new URL(invite).searchParams))
|
||||
const {r: relay = "", c: claim = "", h = "", code = ""} = params
|
||||
const url = normalizeRelayUrl(relay)
|
||||
|
||||
if (isRelayUrl(url)) {
|
||||
return {url, claim, h: h || undefined, code: code || undefined}
|
||||
}
|
||||
}) ||
|
||||
tryCatch(() => {
|
||||
const url = normalizeRelayUrl(invite)
|
||||
|
||||
if (isRelayUrl(url)) {
|
||||
return {url, claim: ""}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export const makeInviteLink = (data: InviteData) => {
|
||||
const params = new URLSearchParams({
|
||||
r: displayRelayUrl(data.url),
|
||||
c: data.claim,
|
||||
})
|
||||
|
||||
if (data.h) {
|
||||
params.set("h", data.h)
|
||||
}
|
||||
|
||||
if (data.code) {
|
||||
params.set("code", data.code)
|
||||
}
|
||||
|
||||
return PLATFORM_URL + "/join?" + params.toString()
|
||||
}
|
||||
|
||||
export const shouldIgnoreError = (error: string) => {
|
||||
const isIgnored = error.startsWith("mute: ")
|
||||
const isAborted = error.includes("Signing was aborted")
|
||||
const isStrictNip29Relay = error.includes("missing group (`h`) tag")
|
||||
|
||||
return isIgnored || isAborted || isStrictNip29Relay
|
||||
}
|
||||
|
||||
export const deriveRelayAuthError = (url: string) =>
|
||||
derived([relaysMostlyRestricted, deriveSocket(url)], ([$relaysMostlyRestricted, $socket]) => {
|
||||
if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) {
|
||||
return stripPrefix($socket.auth.details)
|
||||
}
|
||||
|
||||
if ($relaysMostlyRestricted[url]) {
|
||||
return stripPrefix($relaysMostlyRestricted[url])
|
||||
}
|
||||
})
|
||||
|
||||
export const publishJoinRequest = (url: string, claim = "") =>
|
||||
command(writer(RelayJoin).forceRelays(url).setClaim(claim)).then(publish)
|
||||
|
||||
export const publishLeaveRequest = (url: string) =>
|
||||
command(writer(RelayLeave).forceRelays(url)).then(publish)
|
||||
|
||||
export const publishRoomJoinRequest = (url: string, h: string, code?: string) => {
|
||||
const eventWriter = writer(RoomJoin).setRoom(url, h)
|
||||
|
||||
if (code) {
|
||||
eventWriter.setClaim(code)
|
||||
}
|
||||
|
||||
return command(eventWriter).then(publish)
|
||||
}
|
||||
|
||||
export const publishRoomInvite = async (url: string, h: string) => {
|
||||
const code = randomId()
|
||||
const event = makeEvent(ROOM_CREATE_INVITE, {
|
||||
tags: [
|
||||
["h", h],
|
||||
["code", code],
|
||||
],
|
||||
})
|
||||
|
||||
const error = await thunks
|
||||
.get()
|
||||
.publish({event, relays: [url]})
|
||||
.waitForError()
|
||||
|
||||
if (error) {
|
||||
return {error, code: undefined}
|
||||
}
|
||||
|
||||
return {error: undefined, code}
|
||||
}
|
||||
|
||||
const authTerminalStatuses = [
|
||||
AuthStatus.None,
|
||||
AuthStatus.Ok,
|
||||
AuthStatus.Forbidden,
|
||||
AuthStatus.DeniedSignature,
|
||||
]
|
||||
|
||||
const waitForAuth = async (socket: Socket) => {
|
||||
await poll({
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
condition: () => authTerminalStatuses.includes(socket.auth.status),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const formatAuthError = (status: AuthStatus, details?: string) => {
|
||||
if (status === AuthStatus.DeniedSignature) {
|
||||
return "Failed to authenticate — check your signer"
|
||||
}
|
||||
|
||||
if (status === AuthStatus.PendingSignature) {
|
||||
return "Failed to authenticate — approve the signing request from your extension or signer"
|
||||
}
|
||||
|
||||
const message = details || last(status.split(":"))
|
||||
|
||||
return `Failed to authenticate (${message})`
|
||||
}
|
||||
|
||||
export const attemptRelayAccess = async (url: string, claim = "") => {
|
||||
const socket = app.get().pool.get(url)
|
||||
|
||||
socket.attemptToOpen()
|
||||
|
||||
await poll({
|
||||
signal: AbortSignal.timeout(3000),
|
||||
condition: () => socket.status === SocketStatus.Open,
|
||||
})
|
||||
|
||||
if (socket.status !== SocketStatus.Open) {
|
||||
return `Failed to connect`
|
||||
}
|
||||
|
||||
await poll({
|
||||
signal: AbortSignal.timeout(3000),
|
||||
condition: () => socket.auth.status === AuthStatus.Requested,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 3 && !authTerminalStatuses.includes(socket.auth.status); i++) {
|
||||
await socket.auth.retryAuth(User.require(app.get()).sign)
|
||||
await waitForAuth(socket)
|
||||
}
|
||||
|
||||
if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) {
|
||||
return formatAuthError(socket.auth.status, socket.auth.details)
|
||||
}
|
||||
|
||||
const thunk = await publishJoinRequest(url, claim)
|
||||
const error = await thunk.waitForError()
|
||||
|
||||
if (shouldIgnoreError(error)) return
|
||||
|
||||
if (claim) {
|
||||
if (error.includes("invite code")) {
|
||||
return "join request rejected"
|
||||
}
|
||||
} else if (error.includes("invite code")) {
|
||||
return
|
||||
}
|
||||
|
||||
return stripPrefix(error)
|
||||
}
|
||||
|
||||
export class Access {
|
||||
url: string
|
||||
authError: Readable<string | undefined>
|
||||
loading = writable(true)
|
||||
claim = writable("")
|
||||
claimFailed = writable(false)
|
||||
roomCode = writable<string | undefined>()
|
||||
roomInviteError = writable<string | undefined>()
|
||||
isExplicitAuthError: Readable<boolean>
|
||||
isGenericError: Readable<boolean>
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
this.authError = deriveRelayAuthError(url)
|
||||
this.isExplicitAuthError = derived([this.authError], ([$authError]) =>
|
||||
Boolean($authError && !isNetworkAuthError($authError)),
|
||||
)
|
||||
this.isGenericError = derived([this.authError], ([$authError]) =>
|
||||
Boolean($authError && isNetworkAuthError($authError)),
|
||||
)
|
||||
}
|
||||
|
||||
createInviteStatus(requireCode: boolean) {
|
||||
return derived(
|
||||
[
|
||||
this.loading,
|
||||
this.isGenericError,
|
||||
this.isExplicitAuthError,
|
||||
this.roomInviteError,
|
||||
this.roomCode,
|
||||
this.claimFailed,
|
||||
],
|
||||
([$loading, $isGeneric, $isExplicit, $roomInviteError, $roomCode, $claimFailed]) => {
|
||||
if ($loading) return "loading" as const
|
||||
if ($isGeneric) return "network" as const
|
||||
if ($isExplicit || $roomInviteError) return "auth" as const
|
||||
if (requireCode && !$roomCode) return "failed" as const
|
||||
if ($claimFailed) return "noclaim" as const
|
||||
|
||||
return "ready" as const
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
clearRestricted() {
|
||||
relaysMostlyRestricted.update(dissoc(this.url))
|
||||
}
|
||||
|
||||
async attempt(claim = "") {
|
||||
return attemptRelayAccess(this.url, claim)
|
||||
}
|
||||
|
||||
async configureNotifications(enabled: boolean) {
|
||||
if (enabled) {
|
||||
if (!notificationSettings.get().push) {
|
||||
await setSpaceNotifications(this.url, true)
|
||||
} else {
|
||||
const permissions = await Push.request()
|
||||
|
||||
if (permissions.startsWith("granted")) {
|
||||
await setSpaceNotifications(this.url, true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await setSpaceNotifications(this.url, false)
|
||||
}
|
||||
}
|
||||
|
||||
async completeJoin(notifications: boolean) {
|
||||
await this.configureNotifications(notifications)
|
||||
await roomLists.get().addRelay(this.url).then(publish)
|
||||
this.clearRestricted()
|
||||
syncApplicationData()
|
||||
app
|
||||
.get()
|
||||
.use(Sync)
|
||||
.push({
|
||||
relays: [this.url],
|
||||
filters: [
|
||||
{
|
||||
kinds: [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE],
|
||||
authors: [User.require(app.get()).pubkey],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async joinSpace({
|
||||
claim = "",
|
||||
notifications,
|
||||
alreadyJoined = false,
|
||||
}: {
|
||||
claim?: string
|
||||
notifications: boolean
|
||||
alreadyJoined?: boolean
|
||||
}) {
|
||||
if (!alreadyJoined) {
|
||||
const error = await this.attempt(claim)
|
||||
|
||||
if (error) {
|
||||
return error
|
||||
}
|
||||
|
||||
await this.completeJoin(notifications)
|
||||
}
|
||||
}
|
||||
|
||||
async joinRoom(h: string, code?: string) {
|
||||
if (!code) return
|
||||
|
||||
const thunk = await publishRoomJoinRequest(this.url, h, code)
|
||||
const message = await thunk.waitForError()
|
||||
|
||||
if (message && !message.startsWith("duplicate:")) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
async acceptInvite(data: InviteData, notifications: boolean) {
|
||||
const spaceUrls = roomLists.get().urls(User.require(app.get()).pubkey).get()
|
||||
const error = await this.joinSpace({
|
||||
claim: data.claim,
|
||||
notifications,
|
||||
alreadyJoined: spaceUrls.includes(data.url),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return error
|
||||
}
|
||||
|
||||
if (data.h && data.code) {
|
||||
return this.joinRoom(data.h, data.code)
|
||||
}
|
||||
}
|
||||
|
||||
async prepareInvite(h?: string) {
|
||||
this.loading.set(true)
|
||||
|
||||
// A request that times out or hits a closed socket resolves with no events, so an
|
||||
// empty result is the only signal we get that the relay never answered.
|
||||
const [events, roomInviteResult] = await Promise.all([
|
||||
network.get().request({
|
||||
relays: [this.url],
|
||||
autoClose: true,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
filters: [{kinds: [RELAY_INVITE]}],
|
||||
}),
|
||||
h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}),
|
||||
// Keep the spinner up long enough that a fast relay doesn't make it flash
|
||||
sleep(300),
|
||||
])
|
||||
|
||||
if (events[0]) {
|
||||
const eventReader = reader(RelayInvite)
|
||||
const invite = await eventReader(events[0])
|
||||
|
||||
this.claim.set(invite.claim() ?? "")
|
||||
}
|
||||
|
||||
this.claimFailed.set(events.length === 0)
|
||||
|
||||
if (h) {
|
||||
this.roomCode.set(roomInviteResult.code)
|
||||
this.roomInviteError.set(roomInviteResult.error)
|
||||
}
|
||||
|
||||
this.loading.set(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import {derived} from "svelte/store"
|
||||
import {first, groupBy, removeUndefined, spec} from "@welshman/lib"
|
||||
import {
|
||||
REPORT,
|
||||
ROOM_ADD_MEMBER,
|
||||
|
|
@ -7,48 +5,28 @@ import {
|
|||
ROOM_LEAVE,
|
||||
ROOM_MEMBERS,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
hexTags,
|
||||
sortEventsAsc,
|
||||
getPubkeyTagValues,
|
||||
getTagValue,
|
||||
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"
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
import {getRoomMembers} from "@app/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) =>
|
||||
tagValue(tagSpec(e.kind === ROOM_MEMBERS ? "d" : "h"), e.tags)
|
||||
const reports = $events.filter(spec({kind: REPORT}))
|
||||
getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
|
||||
const reports = $events.filter(e => e.kind === REPORT)
|
||||
const pendingJoins: TrustedEvent[] = []
|
||||
|
||||
// Room-level join requests — most recent per pubkey+h
|
||||
|
|
@ -75,7 +53,7 @@ export const deriveSpaceActionItems = (url: string) =>
|
|||
}
|
||||
}
|
||||
|
||||
const roomMembers = getRoomMembers(roomMembershipEvents)
|
||||
const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents))
|
||||
|
||||
pendingJoins.push(
|
||||
...removeUndefined(
|
||||
|
|
@ -94,7 +72,7 @@ export const deriveSpaceActionItems = (url: string) =>
|
|||
return true
|
||||
}
|
||||
|
||||
return tagValues(hexTags("p"), event.tags).includes(pubkey)
|
||||
return getPubkeyTagValues(event.tags).includes(pubkey)
|
||||
})
|
||||
) {
|
||||
return false
|
||||
|
|
|
|||
75
src/app/call/stores.ts
Normal file
75
src/app/call/stores.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import {Room as LiveKitRoom} from "livekit-client"
|
||||
import {derived, writable} from "svelte/store"
|
||||
import type {Room} from "@app/groups"
|
||||
|
||||
export type VoiceSession = {
|
||||
url: string
|
||||
h: string
|
||||
room: LiveKitRoom
|
||||
cameraOn: boolean
|
||||
screenShareOn: boolean
|
||||
}
|
||||
|
||||
/** Mic mute state is separate so toggling it does not re-render video tiles. */
|
||||
export const voiceMicMuted = writable(true)
|
||||
|
||||
export type Pubkey = string
|
||||
|
||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||
|
||||
export type ParticipantMediaState = {
|
||||
muted: boolean
|
||||
cameraOn: boolean
|
||||
}
|
||||
|
||||
export enum VoiceState {
|
||||
Joining = "joining",
|
||||
Connected = "connected",
|
||||
Disconnected = "disconnected",
|
||||
}
|
||||
|
||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||
|
||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||
|
||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||
|
||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
||||
|
||||
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||
return pk ? {pubkey: pk, identity} : {identity}
|
||||
}
|
||||
|
||||
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
||||
|
||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
||||
|
||||
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
|
||||
|
||||
export const mediaStateByIdentity = derived(
|
||||
[participantMediaState, currentVoiceSession, voiceMicMuted],
|
||||
([$media, $session, $micMuted]) =>
|
||||
(identity: string) => {
|
||||
if ($session?.room.localParticipant.identity === identity) {
|
||||
return {muted: $micMuted, cameraOn: $session.cameraOn}
|
||||
}
|
||||
return $media.get(identity) ?? {muted: true, cameraOn: false}
|
||||
},
|
||||
)
|
||||
|
||||
export const isParticipantSpeaking = derived(
|
||||
speakingParticipants,
|
||||
$participants => (p: VoiceParticipant) =>
|
||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||
)
|
||||
|
||||
export const isLocalSpeaking = derived(
|
||||
[currentVoiceSession, speakingParticipants],
|
||||
([$session, $speaking]) => {
|
||||
if (!$session?.room) return false
|
||||
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
|
||||
return $speaking.some(sp => participantKey(sp) === participantKey(local))
|
||||
},
|
||||
)
|
||||
99
src/app/call/video.ts
Normal file
99
src/app/call/video.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import {Track} from "livekit-client"
|
||||
import {MediaQuery} from "svelte/reactivity"
|
||||
import {derived, get, writable} from "svelte/store"
|
||||
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
export enum VideoCallLayout {
|
||||
Chat = "chat",
|
||||
Video = "video",
|
||||
Split = "split",
|
||||
}
|
||||
|
||||
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
|
||||
|
||||
export enum ViewportSize {
|
||||
Desktop = "desktop",
|
||||
Mobile = "mobile",
|
||||
}
|
||||
|
||||
export const videoCallViewportSync = {
|
||||
previousLayout: undefined as ViewportSize | undefined,
|
||||
}
|
||||
|
||||
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
||||
|
||||
export const resetVideoCallLayout = () => {
|
||||
videoCallViewportSync.previousLayout = undefined
|
||||
videoCallLayout.set(VideoCallLayout.Chat)
|
||||
}
|
||||
|
||||
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
||||
|
||||
export const toggleVideoPrimaryTile = (key: string) => {
|
||||
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
||||
}
|
||||
|
||||
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
||||
|
||||
const countLiveVisualFeeds = (session: VoiceSession): number => {
|
||||
const room = session.room
|
||||
let n = 0
|
||||
const lp = room.localParticipant
|
||||
if (session.cameraOn) {
|
||||
const pub = lp.getTrackPublication(Track.Source.Camera)
|
||||
if (pub?.track) n += 1
|
||||
}
|
||||
if (session.screenShareOn) {
|
||||
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||
if (pub?.track) n += 1
|
||||
}
|
||||
for (const rp of room.remoteParticipants.values()) {
|
||||
for (const source of VISUAL_SOURCES) {
|
||||
const pub = rp.getTrackPublication(source)
|
||||
if (pub?.isSubscribed && pub.track) n += 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
export const triggerVideoFeedCount = () => {
|
||||
currentVoiceSession.update(s => (s ? {...s} : s))
|
||||
}
|
||||
|
||||
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
|
||||
if ($state !== VoiceState.Connected || !$session) return 0
|
||||
return countLiveVisualFeeds($session)
|
||||
})
|
||||
|
||||
export const toggleCamera = async () => {
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
const cameraOn = !session.cameraOn
|
||||
try {
|
||||
await session.room.localParticipant.setCameraEnabled(cameraOn)
|
||||
currentVoiceSession.set({...session, cameraOn})
|
||||
} catch {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleScreenShare = async () => {
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
const screenShareOn = !session.screenShareOn
|
||||
try {
|
||||
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
|
||||
currentVoiceSession.set({...session, screenShareOn})
|
||||
} catch {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
|
||||
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
|
||||
*/
|
||||
import {MediaQuery} from "svelte/reactivity"
|
||||
import {
|
||||
DisconnectReason,
|
||||
LocalParticipant,
|
||||
|
|
@ -15,60 +14,39 @@ import {
|
|||
supportsAudioOutputSelection,
|
||||
type AudioCaptureOptions,
|
||||
} from "livekit-client"
|
||||
import {derived, get, writable} from "svelte/store"
|
||||
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 {derived, get} 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 {getLivekitEndpoint} from "$lib/livekit"
|
||||
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {network, user} from "@app/core"
|
||||
import {deriveEventsForUrl} from "@app/repository"
|
||||
import {
|
||||
currentVoiceRoom,
|
||||
currentVoiceSession,
|
||||
voiceMicMuted,
|
||||
participantFromLiveKitIdentity,
|
||||
participantKey,
|
||||
participantMediaState,
|
||||
speakingParticipants,
|
||||
VoiceState,
|
||||
type ParticipantMediaState,
|
||||
type VoiceParticipant,
|
||||
voiceState,
|
||||
} from "@app/call/stores"
|
||||
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
||||
import {deriveLatestEventForUrl} from "@app/repository"
|
||||
import {deriveRoom, makeRoomId} from "@app/groups"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
export const LIVEKIT_PARTICIPANTS = 39004
|
||||
|
||||
export {checkRelayHasLivekit} from "$lib/livekit"
|
||||
|
||||
export {supportsAudioOutputSelection}
|
||||
|
||||
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
|
||||
export type CallSession = {
|
||||
url: string
|
||||
h: string
|
||||
livekit: LiveKitRoom
|
||||
cameraOn: boolean
|
||||
screenShareOn: boolean
|
||||
}
|
||||
|
||||
/** Mic mute state is separate so toggling it does not re-render video tiles. */
|
||||
export const callMicMuted = writable(true)
|
||||
|
||||
export type CallParticipant = {pubkey?: string; liveKitIdentity: string}
|
||||
|
||||
export type ParticipantMediaState = {
|
||||
muted: boolean
|
||||
cameraOn: boolean
|
||||
}
|
||||
|
||||
export enum CallState {
|
||||
Joining = "joining",
|
||||
Connected = "connected",
|
||||
Disconnected = "disconnected",
|
||||
}
|
||||
|
||||
export enum VideoCallLayout {
|
||||
Chat = "chat",
|
||||
Video = "video",
|
||||
Split = "split",
|
||||
}
|
||||
|
||||
export enum ViewportSize {
|
||||
Desktop = "desktop",
|
||||
Mobile = "mobile",
|
||||
}
|
||||
|
||||
export enum DeviceKind {
|
||||
AudioInput = "audioinput",
|
||||
|
|
@ -76,56 +54,312 @@ export enum DeviceKind {
|
|||
VideoInput = "videoinput",
|
||||
}
|
||||
|
||||
export const currentCallSession = writable<CallSession | undefined>(undefined)
|
||||
export const switchVoiceActiveDevice = async (
|
||||
kind: DeviceKind,
|
||||
targetDeviceId: string,
|
||||
): Promise<void> => {
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
|
||||
try {
|
||||
await session.room.switchActiveDevice(kind, id)
|
||||
} catch {
|
||||
let label: string
|
||||
switch (kind) {
|
||||
case DeviceKind.AudioInput:
|
||||
label = "microphone"
|
||||
break
|
||||
case DeviceKind.AudioOutput:
|
||||
label = "speaker"
|
||||
break
|
||||
case DeviceKind.VideoInput:
|
||||
label = "camera"
|
||||
break
|
||||
}
|
||||
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||
}
|
||||
}
|
||||
|
||||
export const callState = writable<CallState>(CallState.Disconnected)
|
||||
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
|
||||
muted: !participant.isMicrophoneEnabled,
|
||||
cameraOn: participant.isCameraEnabled,
|
||||
})
|
||||
|
||||
export const callTargetRoom = writable<Room | undefined>(undefined)
|
||||
const deleteParticipant = (identity: string) => {
|
||||
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
|
||||
}
|
||||
|
||||
export const speakingParticipants = writable<CallParticipant[]>([])
|
||||
const syncParticipantMedia = (participant: Participant) => {
|
||||
const state = participantMediaFrom(participant)
|
||||
participantMediaState.update(m => {
|
||||
const prev = m.get(participant.identity)
|
||||
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
|
||||
const next = new Map(m)
|
||||
next.set(participant.identity, state)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
|
||||
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
|
||||
const resyncAfterReconnect = (room: LiveKitRoom) => {
|
||||
if (room !== activeRoom) return
|
||||
|
||||
export const mediaStateByIdentity = derived(
|
||||
[participantMediaState, currentCallSession, callMicMuted],
|
||||
([$media, $session, $micMuted]) =>
|
||||
(liveKitIdentity: string) => {
|
||||
if ($session?.livekit.localParticipant.identity === liveKitIdentity) {
|
||||
return {muted: $micMuted, cameraOn: $session.cameraOn}
|
||||
const next = new Map<string, ParticipantMediaState>()
|
||||
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) {
|
||||
next.set(p.identity, participantMediaFrom(p))
|
||||
}
|
||||
participantMediaState.set(next)
|
||||
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
const {localParticipant} = room
|
||||
voiceMicMuted.set(!localParticipant.isMicrophoneEnabled)
|
||||
currentVoiceSession.set({
|
||||
...session,
|
||||
cameraOn: localParticipant.isCameraEnabled,
|
||||
screenShareOn: localParticipant.isScreenShareEnabled,
|
||||
})
|
||||
triggerVideoFeedCount()
|
||||
}
|
||||
|
||||
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
|
||||
syncParticipantMedia(participant)
|
||||
}
|
||||
|
||||
const fetchLivekitToken = async (
|
||||
url: string,
|
||||
groupId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{server_url: string; participant_token: string}> => {
|
||||
const endpoint = getLivekitEndpoint(url, groupId)
|
||||
|
||||
const $signer = signer.get()
|
||||
if (!$signer) throw new Error("No signer available")
|
||||
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
||||
|
||||
const template = await makeHttpAuth(endpoint, "GET")
|
||||
const signedEvent = await $signer.sign(template)
|
||||
const authHeader = makeHttpAuthHeader(signedEvent)
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {Authorization: authHeader},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(`Token request failed (${response.status}): ${text}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const loadVoiceParticipants = (url: string, h: string) =>
|
||||
load({
|
||||
relays: [url],
|
||||
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
|
||||
})
|
||||
|
||||
export const deriveVoiceParticipants = (url: string, h: string) =>
|
||||
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
||||
derived(
|
||||
[
|
||||
participantMediaState,
|
||||
currentVoiceRoom,
|
||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||
],
|
||||
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||
|
||||
if (inCall) {
|
||||
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
||||
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
||||
} else {
|
||||
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
||||
if (!latestEvent) return []
|
||||
const participants = removeUndefined(
|
||||
map(
|
||||
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
|
||||
getTags("participant", latestEvent.tags),
|
||||
),
|
||||
)
|
||||
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
||||
}
|
||||
return $media.get(liveKitIdentity) ?? {muted: true, cameraOn: false}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
export const isParticipantSpeaking = derived(
|
||||
speakingParticipants,
|
||||
$participants => (p: CallParticipant) =>
|
||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||
)
|
||||
const setUpMicrophone = async (
|
||||
startMuted: boolean,
|
||||
preferredMicId: string | undefined,
|
||||
participant: LocalParticipant,
|
||||
signal?: AbortSignal,
|
||||
settleSignal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
if (startMuted) {
|
||||
return true
|
||||
}
|
||||
|
||||
export const videoTrackRevision = writable(0)
|
||||
|
||||
export const triggerVideoTrackRevision = () => {
|
||||
videoTrackRevision.update(n => n + 1)
|
||||
let muted = true
|
||||
let capture: AudioCaptureOptions | undefined = undefined
|
||||
if (preferredMicId) {
|
||||
capture = {deviceId: preferredMicId}
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
participant.setMicrophoneEnabled(true, capture),
|
||||
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
muted = false
|
||||
} catch (e) {
|
||||
// Timeout or microphone rejection: join muted, the call is still usable. A
|
||||
// genuine abort is surfaced to the caller so it can tear down the room.
|
||||
if (e instanceof AbortError) throw e
|
||||
if (!(e instanceof TimeoutError)) {
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
}
|
||||
}
|
||||
return muted
|
||||
}
|
||||
|
||||
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
|
||||
// The room whose events are allowed to mutate shared state. Abandoned rooms
|
||||
// (after switching calls or an engine reconnect give-up) must not clobber it.
|
||||
let activeRoom: LiveKitRoom | undefined
|
||||
|
||||
export const videoCallViewportSync = {
|
||||
previousLayout: undefined as ViewportSize | undefined,
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let reconnectAttempt = 0
|
||||
|
||||
const clearReconnectSchedule = () => {
|
||||
if (reconnectTimeout !== undefined) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = undefined
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
}
|
||||
|
||||
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
||||
const attemptReconnect = async () => {
|
||||
const target = get(currentVoiceRoom)
|
||||
if (!target) return
|
||||
|
||||
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
||||
try {
|
||||
await joinVoiceRoom(target.url, target.h)
|
||||
} catch {
|
||||
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
||||
pushToast({theme: "error", message: "Voice connection lost."})
|
||||
clearReconnectSchedule()
|
||||
return
|
||||
}
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
export const videoTileCount = derived(
|
||||
[currentCallSession, callState, videoTrackRevision, participantMediaState],
|
||||
([$session, $state]) => {
|
||||
if ($state !== CallState.Connected || !$session) return 0
|
||||
return countLiveVisualFeeds($session)
|
||||
},
|
||||
)
|
||||
const scheduleReconnect = () => {
|
||||
if (reconnectTimeout !== undefined) return
|
||||
if (!get(currentVoiceRoom)) return
|
||||
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
||||
pushToast({theme: "error", message: "Voice connection lost."})
|
||||
return
|
||||
}
|
||||
|
||||
const delay = RECONNECT_DELAYS[reconnectAttempt]!
|
||||
reconnectAttempt++
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
reconnectTimeout = undefined
|
||||
void attemptReconnect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const makeOnRoomReconnected = (room: LiveKitRoom) => () => {
|
||||
if (room !== activeRoom) return
|
||||
resyncAfterReconnect(room)
|
||||
}
|
||||
|
||||
const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => {
|
||||
// Ignore disconnects from rooms that are no longer the active session.
|
||||
if (room !== activeRoom) return
|
||||
|
||||
activeRoom = undefined
|
||||
room.removeAllListeners()
|
||||
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
voiceMicMuted.set(true)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
if (reason === DisconnectReason.JOIN_FAILURE) {
|
||||
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
|
||||
} else if (get(currentVoiceRoom)) {
|
||||
clearReconnectSchedule()
|
||||
scheduleReconnect()
|
||||
} else {
|
||||
pushToast({theme: "error", message: "Voice connection lost."})
|
||||
}
|
||||
}
|
||||
speakingParticipants.set([])
|
||||
participantMediaState.set(new Map())
|
||||
}
|
||||
|
||||
const onTrackSubscribed = (track: Track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
const element = track.attach()
|
||||
element.style.display = "none"
|
||||
document.body.appendChild(element)
|
||||
element.play().catch(() => {})
|
||||
} else if (track.kind === Track.Kind.Video) {
|
||||
triggerVideoFeedCount()
|
||||
}
|
||||
}
|
||||
|
||||
const onTrackUnsubscribed = (track: Track) => {
|
||||
track.detach().forEach(el => el.remove())
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
triggerVideoFeedCount()
|
||||
}
|
||||
}
|
||||
|
||||
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
|
||||
}
|
||||
|
||||
const playJoinSound = () => {
|
||||
const audio = new Audio("/join-voice-room.mp3")
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
const onParticipantConnected = (participant: Participant) => {
|
||||
syncParticipantMedia(participant)
|
||||
playJoinSound()
|
||||
}
|
||||
|
||||
const onParticipantDisconnected = (participant: {identity: string}) => {
|
||||
deleteParticipant(participant.identity)
|
||||
}
|
||||
|
||||
const onLocalTrackUnpublished = (
|
||||
publication: LocalTrackPublication,
|
||||
participant: LocalParticipant,
|
||||
) => {
|
||||
if (publication.source !== Track.Source.ScreenShare) return
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session || participant.identity !== session.room.localParticipant.identity) return
|
||||
if (!session.screenShareOn) return
|
||||
currentVoiceSession.set({...session, screenShareOn: false})
|
||||
}
|
||||
|
||||
let joinAbortController: AbortController | undefined
|
||||
|
||||
const abortJoinVoiceRoom = () => {
|
||||
joinAbortController?.abort()
|
||||
}
|
||||
|
||||
export const cancelJoinVoiceRoom = () => {
|
||||
clearReconnectSchedule()
|
||||
abortJoinVoiceRoom()
|
||||
}
|
||||
|
||||
export const joinVoiceRoom = async (
|
||||
url: string,
|
||||
|
|
@ -135,8 +369,8 @@ export const joinVoiceRoom = async (
|
|||
): Promise<void> => {
|
||||
abortJoinVoiceRoom()
|
||||
|
||||
callTargetRoom.set({url, h, id: makeRoomKey(url, h)})
|
||||
callState.set(CallState.Joining)
|
||||
currentVoiceRoom.set(get(deriveRoom(url, h)))
|
||||
voiceState.set(VoiceState.Joining)
|
||||
|
||||
const controller = new AbortController()
|
||||
joinAbortController = controller
|
||||
|
|
@ -150,7 +384,7 @@ export const joinVoiceRoom = async (
|
|||
try {
|
||||
// Tear down any existing session before joining. Bound it so a slow leave
|
||||
// (camera/screenshare renegotiation can take ~15s) cannot block this join.
|
||||
if (get(currentCallSession)) {
|
||||
if (get(currentVoiceSession)) {
|
||||
await Promise.race([
|
||||
leaveVoiceRoom(),
|
||||
whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}),
|
||||
|
|
@ -159,8 +393,8 @@ export const joinVoiceRoom = async (
|
|||
if (e instanceof AbortError) throw e
|
||||
})
|
||||
|
||||
// leaveVoiceRoom flips callState to Disconnected; re-assert Joining.
|
||||
callState.set(CallState.Joining)
|
||||
// leaveVoiceRoom flips voiceState to Disconnected; re-assert Joining.
|
||||
voiceState.set(VoiceState.Joining)
|
||||
}
|
||||
|
||||
if (signal.aborted) throw new AbortError()
|
||||
|
|
@ -203,7 +437,9 @@ export const joinVoiceRoom = async (
|
|||
whenAborted(signal),
|
||||
])
|
||||
} catch (e) {
|
||||
teardownRoom(liveKitRoom)
|
||||
if (activeRoom === liveKitRoom) activeRoom = undefined
|
||||
liveKitRoom.removeAllListeners()
|
||||
liveKitRoom.disconnect()
|
||||
throw e
|
||||
}
|
||||
|
||||
|
|
@ -226,23 +462,25 @@ export const joinVoiceRoom = async (
|
|||
// A cancel during the mic step must tear down the connected room rather
|
||||
// than leaking it.
|
||||
if (signal.aborted) {
|
||||
teardownRoom(liveKitRoom)
|
||||
if (activeRoom === liveKitRoom) activeRoom = undefined
|
||||
liveKitRoom.removeAllListeners()
|
||||
liveKitRoom.disconnect()
|
||||
throw new AbortError()
|
||||
}
|
||||
|
||||
callMicMuted.set(muted)
|
||||
currentCallSession.set({
|
||||
voiceMicMuted.set(muted)
|
||||
currentVoiceSession.set({
|
||||
url,
|
||||
h,
|
||||
livekit: liveKitRoom,
|
||||
room: liveKitRoom,
|
||||
cameraOn: false,
|
||||
screenShareOn: false,
|
||||
})
|
||||
callState.set(CallState.Connected)
|
||||
voiceState.set(VoiceState.Connected)
|
||||
clearReconnectSchedule()
|
||||
playJoinSound()
|
||||
} catch (e) {
|
||||
if (isActive()) callState.set(CallState.Disconnected)
|
||||
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
||||
if (e instanceof AbortError) {
|
||||
clearReconnectSchedule()
|
||||
return
|
||||
|
|
@ -256,7 +494,7 @@ export const joinVoiceRoom = async (
|
|||
|
||||
export const leaveVoiceRoom = async () => {
|
||||
clearReconnectSchedule()
|
||||
const session = get(currentCallSession)
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
const audio = new Audio("/leave-voice-room.mp3")
|
||||
|
|
@ -264,7 +502,7 @@ export const leaveVoiceRoom = async () => {
|
|||
|
||||
if (session.cameraOn) {
|
||||
try {
|
||||
await session.livekit.localParticipant.setCameraEnabled(false)
|
||||
await session.room.localParticipant.setCameraEnabled(false)
|
||||
} catch {
|
||||
pushToast({theme: "error", message: "Error turning off camera."})
|
||||
}
|
||||
|
|
@ -272,438 +510,46 @@ export const leaveVoiceRoom = async () => {
|
|||
|
||||
if (session.screenShareOn) {
|
||||
try {
|
||||
await session.livekit.localParticipant.setScreenShareEnabled(false)
|
||||
await session.room.localParticipant.setScreenShareEnabled(false)
|
||||
} catch {
|
||||
pushToast({theme: "error", message: "Error turning off screen sharing."})
|
||||
}
|
||||
}
|
||||
|
||||
// Always tear down this room's connection and listeners.
|
||||
teardownRoom(session.livekit)
|
||||
if (activeRoom === session.room) activeRoom = undefined
|
||||
session.room.removeAllListeners()
|
||||
session.room.disconnect()
|
||||
|
||||
// Only reset shared UI state if this session is still current. A slow leave
|
||||
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
|
||||
// must not clobber the freshly-joined session when it finally completes.
|
||||
if (get(currentCallSession) === session) {
|
||||
callState.set(CallState.Disconnected)
|
||||
callMicMuted.set(true)
|
||||
currentCallSession.set(undefined)
|
||||
if (get(currentVoiceSession) === session) {
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
voiceMicMuted.set(true)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
speakingParticipants.set([])
|
||||
participantMediaState.set(new Map())
|
||||
}
|
||||
}
|
||||
|
||||
export const cancelJoinVoiceRoom = () => {
|
||||
clearReconnectSchedule()
|
||||
abortJoinVoiceRoom()
|
||||
}
|
||||
|
||||
export const toggleMute = async () => {
|
||||
const session = get(currentCallSession)
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
callMicMuted.update(not)
|
||||
|
||||
const muted = get(callMicMuted)
|
||||
try {
|
||||
await session.livekit.localParticipant.setMicrophoneEnabled(!muted)
|
||||
} catch {
|
||||
callMicMuted.set(!muted)
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: muted ? "Could not mute microphone" : "Could not access microphone",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleCamera = async () => {
|
||||
const session = get(currentCallSession)
|
||||
if (!session) return
|
||||
|
||||
const cameraOn = !session.cameraOn
|
||||
try {
|
||||
await session.livekit.localParticipant.setCameraEnabled(cameraOn)
|
||||
currentCallSession.update(s => s && {...s, cameraOn})
|
||||
} catch {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleScreenShare = async () => {
|
||||
const session = get(currentCallSession)
|
||||
if (!session) return
|
||||
|
||||
const screenShareOn = !session.screenShareOn
|
||||
try {
|
||||
await session.livekit.localParticipant.setScreenShareEnabled(screenShareOn)
|
||||
currentCallSession.update(s => s && {...s, screenShareOn})
|
||||
} catch {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const switchCallActiveDevice = async (
|
||||
kind: DeviceKind,
|
||||
targetDeviceId: string,
|
||||
): Promise<void> => {
|
||||
const session = get(currentCallSession)
|
||||
if (!session) return
|
||||
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
|
||||
try {
|
||||
await session.livekit.switchActiveDevice(kind, id)
|
||||
} catch {
|
||||
let label: string
|
||||
switch (kind) {
|
||||
case DeviceKind.AudioInput:
|
||||
label = "microphone"
|
||||
break
|
||||
case DeviceKind.AudioOutput:
|
||||
label = "speaker"
|
||||
break
|
||||
case DeviceKind.VideoInput:
|
||||
label = "camera"
|
||||
break
|
||||
}
|
||||
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||
}
|
||||
}
|
||||
|
||||
export const resetVideoCallLayout = () => {
|
||||
videoCallViewportSync.previousLayout = undefined
|
||||
videoCallLayout.set(VideoCallLayout.Chat)
|
||||
}
|
||||
|
||||
export const toggleVideoPrimaryTile = (key: string) => {
|
||||
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
||||
}
|
||||
|
||||
export const loadCallParticipants = (url: string, h: string) =>
|
||||
network.get().load({
|
||||
relays: [url],
|
||||
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
|
||||
})
|
||||
|
||||
export const deriveCallParticipants = (url: string, h: string) =>
|
||||
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
||||
derived(
|
||||
[
|
||||
participantMediaState,
|
||||
callTargetRoom,
|
||||
deriveDeduplicated(
|
||||
deriveEventsForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||
events => first(sortEventsDesc(events)),
|
||||
),
|
||||
],
|
||||
([$participantMediaState, $callTargetRoom, $publishedParticipantList]) => {
|
||||
const inCall = $participantMediaState.size > 0 && $callTargetRoom?.id === makeRoomKey(url, h)
|
||||
|
||||
const identities = inCall
|
||||
? [...$participantMediaState.keys()]
|
||||
: tagValues(tagSpec("participant"), $publishedParticipantList?.tags ?? [])
|
||||
|
||||
return uniqBy(
|
||||
(p: CallParticipant) => participantKey(p),
|
||||
identities.map(participantFromLiveKitIdentity),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const pubkeyFromLiveKitIdentity = (liveKitIdentity: string): string | undefined =>
|
||||
/^[a-f0-9]{64}$/.test(liveKitIdentity.slice(0, 64)) ? liveKitIdentity.slice(0, 64) : undefined
|
||||
|
||||
export const participantFromLiveKitIdentity = (liveKitIdentity: string): CallParticipant => {
|
||||
const pk = pubkeyFromLiveKitIdentity(liveKitIdentity)
|
||||
return pk ? {pubkey: pk, liveKitIdentity} : {liveKitIdentity}
|
||||
}
|
||||
|
||||
export const participantKey = (p: CallParticipant) => p.pubkey ?? p.liveKitIdentity
|
||||
|
||||
// The room whose events are allowed to mutate shared state. Abandoned rooms
|
||||
// (after switching calls or an engine reconnect give-up) must not clobber it.
|
||||
let activeRoom: LiveKitRoom | undefined
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let reconnectAttempt = 0
|
||||
let joinAbortController: AbortController | undefined
|
||||
let hadCallSession = false
|
||||
|
||||
currentCallSession.subscribe(session => {
|
||||
if (session) {
|
||||
hadCallSession = true
|
||||
voiceMicMuted.update(not)
|
||||
if (get(voiceMicMuted)) {
|
||||
// Disable and re-enable microphone to trigger permission prompt
|
||||
session.room.localParticipant.setMicrophoneEnabled(false)
|
||||
return
|
||||
}
|
||||
if (!hadCallSession) return
|
||||
hadCallSession = false
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
})
|
||||
|
||||
const teardownRoom = (livekit: LiveKitRoom) => {
|
||||
if (activeRoom === livekit) activeRoom = undefined
|
||||
|
||||
// Dropping the listeners keeps TrackUnsubscribed from removing the hidden
|
||||
// audio elements onTrackSubscribed appended, so detach them here instead.
|
||||
for (const participant of livekit.remoteParticipants.values()) {
|
||||
for (const publication of participant.audioTrackPublications.values()) {
|
||||
publication.track?.detach().forEach(el => el.remove())
|
||||
}
|
||||
}
|
||||
|
||||
livekit.removeAllListeners()
|
||||
livekit.disconnect()
|
||||
}
|
||||
|
||||
const countLiveVisualFeeds = (session: CallSession): number => {
|
||||
const livekit = session.livekit
|
||||
let n = 0
|
||||
const lp = livekit.localParticipant
|
||||
if (session.cameraOn) {
|
||||
const pub = lp.getTrackPublication(Track.Source.Camera)
|
||||
if (pub?.track) n += 1
|
||||
}
|
||||
if (session.screenShareOn) {
|
||||
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||
if (pub?.track) n += 1
|
||||
}
|
||||
for (const rp of livekit.remoteParticipants.values()) {
|
||||
for (const source of VISUAL_SOURCES) {
|
||||
const pub = rp.getTrackPublication(source)
|
||||
if (pub?.isSubscribed && pub.track && !pub.isMuted) n += 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
|
||||
muted: !participant.isMicrophoneEnabled,
|
||||
cameraOn: participant.isCameraEnabled,
|
||||
})
|
||||
|
||||
const deleteParticipant = (liveKitIdentity: string) => {
|
||||
participantMediaState.update(m => new Map(reject(nthEq(0, liveKitIdentity), [...m])))
|
||||
}
|
||||
|
||||
const syncParticipantMedia = (participant: Participant) => {
|
||||
const state = participantMediaFrom(participant)
|
||||
participantMediaState.update(m => {
|
||||
const prev = m.get(participant.identity)
|
||||
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
|
||||
const next = new Map(m)
|
||||
next.set(participant.identity, state)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
|
||||
const resyncAfterReconnect = (livekit: LiveKitRoom) => {
|
||||
if (livekit !== activeRoom) return
|
||||
|
||||
const next = new Map<string, ParticipantMediaState>()
|
||||
for (const p of [livekit.localParticipant, ...livekit.remoteParticipants.values()]) {
|
||||
next.set(p.identity, participantMediaFrom(p))
|
||||
}
|
||||
participantMediaState.set(next)
|
||||
|
||||
const session = get(currentCallSession)
|
||||
if (!session) return
|
||||
|
||||
const {localParticipant} = livekit
|
||||
callMicMuted.set(!localParticipant.isMicrophoneEnabled)
|
||||
currentCallSession.set({
|
||||
...session,
|
||||
cameraOn: localParticipant.isCameraEnabled,
|
||||
screenShareOn: localParticipant.isScreenShareEnabled,
|
||||
})
|
||||
triggerVideoTrackRevision()
|
||||
}
|
||||
|
||||
const fetchLivekitToken = async (
|
||||
url: string,
|
||||
roomId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{server_url: string; participant_token: string}> => {
|
||||
const endpoint = getLivekitEndpoint(url, roomId)
|
||||
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
||||
|
||||
const template = await makeHttpAuth(endpoint, "GET")
|
||||
const signedEvent = await user.get().signer.sign(template)
|
||||
const authHeader = makeHttpAuthHeader(signedEvent)
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {Authorization: authHeader},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(`Token request failed (${response.status}): ${text}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const setUpMicrophone = async (
|
||||
startMuted: boolean,
|
||||
preferredMicId: string | undefined,
|
||||
participant: LocalParticipant,
|
||||
signal?: AbortSignal,
|
||||
settleSignal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
if (startMuted) {
|
||||
return true
|
||||
}
|
||||
|
||||
let muted = true
|
||||
let capture: AudioCaptureOptions | undefined = undefined
|
||||
if (preferredMicId) {
|
||||
capture = {deviceId: preferredMicId}
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
participant.setMicrophoneEnabled(true, capture),
|
||||
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
muted = false
|
||||
await session.room.localParticipant.setMicrophoneEnabled(true)
|
||||
} catch (e) {
|
||||
// Timeout or microphone rejection: join muted, the call is still usable. A
|
||||
// genuine abort is surfaced to the caller so it can tear down the room.
|
||||
if (e instanceof AbortError) throw e
|
||||
if (!(e instanceof TimeoutError)) {
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
}
|
||||
}
|
||||
return muted
|
||||
}
|
||||
|
||||
const clearReconnectSchedule = () => {
|
||||
if (reconnectTimeout !== undefined) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = undefined
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
}
|
||||
|
||||
const attemptReconnect = async () => {
|
||||
const target = get(callTargetRoom)
|
||||
if (!target) return
|
||||
|
||||
try {
|
||||
await joinVoiceRoom(target.url, target.h)
|
||||
} catch {
|
||||
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
||||
pushToast({theme: "error", message: "Voice connection lost."})
|
||||
clearReconnectSchedule()
|
||||
return
|
||||
}
|
||||
scheduleReconnect()
|
||||
voiceMicMuted.set(true)
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (reconnectTimeout !== undefined) return
|
||||
if (!get(callTargetRoom)) return
|
||||
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
||||
pushToast({theme: "error", message: "Voice connection lost."})
|
||||
return
|
||||
}
|
||||
|
||||
const delay = RECONNECT_DELAYS[reconnectAttempt]!
|
||||
reconnectAttempt++
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
reconnectTimeout = undefined
|
||||
void attemptReconnect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const makeOnRoomReconnected = (livekit: LiveKitRoom) => () => {
|
||||
if (livekit !== activeRoom) return
|
||||
resyncAfterReconnect(livekit)
|
||||
}
|
||||
|
||||
const makeOnRoomDisconnected = (livekit: LiveKitRoom) => (reason?: DisconnectReason) => {
|
||||
// Ignore disconnects from rooms that are no longer the active session.
|
||||
if (livekit !== activeRoom) return
|
||||
|
||||
// Livekit unsubscribes remote tracks before emitting Disconnected, so
|
||||
// onTrackUnsubscribed has already removed their audio elements by now.
|
||||
activeRoom = undefined
|
||||
livekit.removeAllListeners()
|
||||
|
||||
callMicMuted.set(true)
|
||||
currentCallSession.set(undefined)
|
||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||
callState.set(CallState.Disconnected)
|
||||
if (reason === DisconnectReason.JOIN_FAILURE) {
|
||||
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
|
||||
} else if (get(callTargetRoom)) {
|
||||
clearReconnectSchedule()
|
||||
scheduleReconnect()
|
||||
} else {
|
||||
pushToast({theme: "error", message: "Voice connection lost."})
|
||||
}
|
||||
}
|
||||
speakingParticipants.set([])
|
||||
participantMediaState.set(new Map())
|
||||
}
|
||||
|
||||
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
|
||||
syncParticipantMedia(participant)
|
||||
}
|
||||
|
||||
const onTrackSubscribed = (track: Track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
const element = track.attach()
|
||||
element.style.display = "none"
|
||||
document.body.appendChild(element)
|
||||
element.play().catch(() => {})
|
||||
} else if (track.kind === Track.Kind.Video) {
|
||||
triggerVideoTrackRevision()
|
||||
}
|
||||
}
|
||||
|
||||
const onTrackUnsubscribed = (track: Track) => {
|
||||
track.detach().forEach(el => el.remove())
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
triggerVideoTrackRevision()
|
||||
}
|
||||
}
|
||||
|
||||
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
|
||||
}
|
||||
|
||||
const playJoinSound = () => {
|
||||
const audio = new Audio("/join-voice-room.mp3")
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
const onParticipantConnected = (participant: Participant) => {
|
||||
syncParticipantMedia(participant)
|
||||
playJoinSound()
|
||||
}
|
||||
|
||||
const onParticipantDisconnected = (participant: {identity: string}) => {
|
||||
deleteParticipant(participant.identity)
|
||||
}
|
||||
|
||||
const onLocalTrackUnpublished = (
|
||||
publication: LocalTrackPublication,
|
||||
participant: LocalParticipant,
|
||||
) => {
|
||||
if (publication.source !== Track.Source.ScreenShare) return
|
||||
const session = get(currentCallSession)
|
||||
if (!session || participant.identity !== session.livekit.localParticipant.identity) return
|
||||
if (!session.screenShareOn) return
|
||||
currentCallSession.set({...session, screenShareOn: false})
|
||||
}
|
||||
|
||||
const abortJoinVoiceRoom = () => {
|
||||
joinAbortController?.abort()
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
import {derived, readable} from "svelte/store"
|
||||
import {DELETE, PROFILE, getPubkeyTagValues} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {append, call, on, reject, remove, sort, sortBy, spec, uniq, uniqBy} from "@welshman/lib"
|
||||
import type {Override} from "@welshman/lib"
|
||||
import {DELETE, PROFILE, hexTags, tagValues} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {createSearch, displayProfileByPubkey, pubkey, repository} from "@welshman/app"
|
||||
import {derived, readable} from "svelte/store"
|
||||
import {DM_KINDS} from "@app/content"
|
||||
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[]
|
||||
|
|
@ -17,13 +15,13 @@ export type Chat = {
|
|||
search_text: string
|
||||
}
|
||||
|
||||
export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(user.get().pubkey, pubkeys)))
|
||||
export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(pubkey.get()!, pubkeys)))
|
||||
|
||||
export const getChatPubkeysFromEvent = (event: TrustedEvent) =>
|
||||
getChatPubkeys(tagValues(hexTags("p"), event.tags).concat(event.pubkey))
|
||||
getChatPubkeys(getPubkeyTagValues(event.tags).concat(event.pubkey))
|
||||
|
||||
export const makeChatId = (pubkeys: string[]) => {
|
||||
const userPubkey = user.get().pubkey
|
||||
const userPubkey = pubkey.get()!
|
||||
const otherPubkeys = remove(userPubkey, uniq(pubkeys))
|
||||
const visiblePubkeys = otherPubkeys.length === 0 ? [userPubkey] : otherPubkeys
|
||||
|
||||
|
|
@ -36,13 +34,11 @@ 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
|
||||
? displayProfile(chat.pubkeys[0]) + " note to self"
|
||||
: remove(user.get().pubkey, chat.pubkeys).map(displayProfile).join(" ")
|
||||
? displayProfileByPubkey(chat.pubkeys[0]) + " note to self"
|
||||
: remove(pubkey.get()!, chat.pubkeys).map(displayProfileByPubkey).join(" ")
|
||||
|
||||
return chat as Chat
|
||||
}
|
||||
|
|
@ -95,7 +91,7 @@ export const chatsById = call(() => {
|
|||
let dirty = false
|
||||
|
||||
for (const id of removed) {
|
||||
const event = app.get().repository.getEvent(id)
|
||||
const event = repository.getEvent(id)
|
||||
|
||||
if (event && DM_KINDS.includes(event.kind)) {
|
||||
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
|
||||
|
|
@ -114,10 +110,10 @@ export const chatsById = call(() => {
|
|||
}
|
||||
}
|
||||
|
||||
addEvents(app.get().repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
|
||||
addEvents(repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
|
||||
|
||||
const unsubscribers = [
|
||||
on(app.get().repository, "update", ({added, removed}: RepositoryUpdate) => {
|
||||
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
|
||||
// Do this async so that profiles are populated
|
||||
setTimeout(() => {
|
||||
addEvents(added)
|
||||
|
|
|
|||
16
src/app/comments.ts
Normal file
16
src/app/comments.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT, makeEvent} from "@welshman/util"
|
||||
import {publishThunk, tagEventForComment} from "@welshman/app"
|
||||
|
||||
export type CommentParams = {
|
||||
event: TrustedEvent
|
||||
content: string
|
||||
tags?: string[][]
|
||||
url?: string
|
||||
}
|
||||
|
||||
export const makeComment = ({url, event, content, tags = []}: CommentParams) =>
|
||||
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event, url)]})
|
||||
|
||||
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
|
||||
publishThunk({event: makeComment({url: relays[0], ...params}), relays})
|
||||
|
|
@ -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"
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
children: Snippet
|
||||
}
|
||||
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
</script>
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
{#if $app.user?.pubkey}
|
||||
{#if $pubkey}
|
||||
<PrimaryNav>
|
||||
{@render children?.()}
|
||||
</PrimaryNav>
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {PinboardReader} from "@welshman/domain"
|
||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import {shareEventToChat} from "@app/share"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
board: PinboardReader
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const {url, board, onClick}: Props = $props()
|
||||
|
||||
const showInfo = () => pushModal(EventInfo, {url, event: board.event})
|
||||
|
||||
const share = () => shareEventToChat(url, "Shelf", board.event)
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<li>
|
||||
<Button onclick={showInfo}>
|
||||
<Icon icon={Code2} />
|
||||
Shelf details
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={share}>
|
||||
<Icon icon={ShareCircle} />
|
||||
Share to chat
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
<p>Bunker Link*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-group flex w-full items-center gap-2">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={CpuBolt} />
|
||||
<input disabled={$loading} bind:value={$bunker} class="grow" placeholder="bunker://" />
|
||||
<Button onclick={toggleScanner}>
|
||||
|
|
@ -45,8 +45,7 @@
|
|||
{#snippet info()}
|
||||
<p>
|
||||
A login link provided by a nostr signing app.
|
||||
<Button class="button button-link" onclick={() => pushModal(InfoBunker)}
|
||||
>What is a bunker link?</Button>
|
||||
<Button class="link" onclick={() => pushModal(InfoBunker)}>What is a bunker link?</Button>
|
||||
</p>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {getAddress, tagSpec, tagValue} from "@welshman/util"
|
||||
import {getTagValue, getAddress} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
|
||||
import 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 {user} from "@app/core"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
import {publishReaction} from "@app/reactions"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {makeCalendarPath, makeSpacePath} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
|
|
@ -25,30 +27,33 @@
|
|||
|
||||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const h = tagValue(tagSpec("h"), event.tags)
|
||||
const h = getTagValue("h", event.tags)
|
||||
const path = makeCalendarPath(url, getAddress(event))
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
|
||||
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
|
||||
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
|
||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip tip-left" />
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Event">
|
||||
{#snippet customActions()}
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
{#if event.pubkey === $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 room members to events online or in real life.</ModalSubtitle>
|
||||
<ModalSubtitle>Invite other group members to events online or in real life.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
{/snippet}
|
||||
</CalendarEventForm>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,20 @@
|
|||
<script lang="ts">
|
||||
import {LOCALE, secondsToDate} from "@welshman/lib"
|
||||
import {fromPairs, 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 timeEvent = reader(TimeEvent)(event)
|
||||
|
||||
const start = timeEvent.start()
|
||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
||||
const start = $derived(parseInt(meta.start))
|
||||
</script>
|
||||
|
||||
{#if start}
|
||||
{#if !isNaN(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">
|
||||
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
|
||||
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
|
||||
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
|
||||
<span class="text-xs opacity-75"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {TimeEvent} from "@welshman/domain"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
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 = {
|
||||
|
|
@ -14,27 +13,21 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
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(),
|
||||
},
|
||||
)
|
||||
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,
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {randomId} from "@welshman/lib"
|
||||
import {publish} from "@welshman/app"
|
||||
import {TimeEvent} from "@welshman/domain"
|
||||
import {randomId, HOUR} from "@welshman/lib"
|
||||
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
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"
|
||||
|
|
@ -15,13 +16,13 @@
|
|||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import DateTimeRangeInput from "@lib/components/DateTimeRangeInput.svelte"
|
||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {publishRoomQuote} from "@app/rooms"
|
||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {command, relays, writer} from "@app/core"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
|
||||
type Values = {
|
||||
d: string
|
||||
|
|
@ -48,7 +49,7 @@
|
|||
initialValues = draftKey.get()
|
||||
}
|
||||
|
||||
const shouldProtect = $relays.hasNip(url, 70)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const uploading = writable(false)
|
||||
|
||||
|
|
@ -82,29 +83,32 @@
|
|||
|
||||
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
|
||||
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) {
|
||||
eventWriter.setRoom(url, h)
|
||||
} else {
|
||||
eventWriter.forceRelays(url)
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
const calendarThunk = await command(eventWriter).then(publish)
|
||||
const error = await calendarThunk.waitForError()
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
const event = makeEvent(EVENT_TIME, {content, tags})
|
||||
const calendarThunk = publishThunk({event, relays: [url]})
|
||||
const error = await waitForThunkError(calendarThunk)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
|
|
@ -130,6 +134,7 @@
|
|||
let location = $state(initialValues?.location ?? "")
|
||||
let start: number | undefined = $state(initialValues?.start)
|
||||
let end: number | undefined = $state(initialValues?.end)
|
||||
let endDirty = $state(Boolean(initialValues?.end))
|
||||
let content = $state(initialValues?.content ?? "")
|
||||
|
||||
const onChange = (json: object) => {
|
||||
|
|
@ -141,6 +146,14 @@
|
|||
$effect(() => {
|
||||
draftKey.set({d, title, location, start, end, content})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!endDirty && start) {
|
||||
end = start + HOUR
|
||||
} else if (end) {
|
||||
endDirty = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal tag="form" novalidate onsubmit={preventDefault(submit)}>
|
||||
|
|
@ -151,7 +164,7 @@
|
|||
<p>Title*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<input bind:value={title} class="grow" type="text" />
|
||||
</label>
|
||||
{/snippet}
|
||||
|
|
@ -161,17 +174,18 @@
|
|||
<p>Summary</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="relative z-feature flex gap-2">
|
||||
<div
|
||||
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
||||
<div class="input-editor grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="button button-neutral button-input tip"
|
||||
class="center btn tooltip"
|
||||
onclick={selectFiles}
|
||||
disabled={loading}>
|
||||
{#if $uploading}
|
||||
<Spinner size="xs" />
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Icon icon={GallerySend} />
|
||||
{/if}
|
||||
|
|
@ -181,10 +195,18 @@
|
|||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Date and time*</p>
|
||||
Start*
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<DateTimeRangeInput bind:start bind:end />
|
||||
<DateTimeInput bind:value={start} />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
End*
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<DateTimeInput bind:value={end} />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
|
|
@ -192,7 +214,7 @@
|
|||
<p>Location (optional)</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={MapPoint} />
|
||||
<input bind:value={location} class="grow" type="text" />
|
||||
</label>
|
||||
|
|
@ -200,11 +222,11 @@
|
|||
</Field>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back} disabled={loading}>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button class="button button-primary" type="submit" disabled={$uploading || loading}>
|
||||
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
|
||||
<Spinner loading={$uploading || loading}>Save Event</Spinner>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
<script lang="ts">
|
||||
import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/lib"
|
||||
import {
|
||||
fromPairs,
|
||||
formatTimestamp,
|
||||
formatTimestampAsDate,
|
||||
formatTimestampAsTime,
|
||||
} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {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 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()
|
||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
||||
const start = $derived(parseInt(meta.start))
|
||||
const end = $derived(parseInt(meta.end))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col justify-between gap-1">
|
||||
<p class="text-lg">{title}</p>
|
||||
{#if start && end}
|
||||
{@const isSingleDay = formatTimestampAsDate(start) === formatTimestampAsDate(end)}
|
||||
<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}
|
||||
<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 {getAddress, tagValue, tagSpec} from "@welshman/util"
|
||||
import {getTagValue, getAddress} 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,12 +15,10 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const h = tagValue(tagSpec("h"), event.tags)
|
||||
const h = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="cv flex flex-col gap-3 card card-interactive w-full"
|
||||
href={makeCalendarPath(url, getAddress(event))}>
|
||||
<Link class="cv col-3 card2 w-full cursor-pointer" href={makeCalendarPath(url, getAddress(event))}>
|
||||
<CalendarEventHeader {event} />
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
<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 = {
|
||||
|
|
@ -13,10 +12,7 @@
|
|||
}
|
||||
|
||||
const {event, url}: Props = $props()
|
||||
|
||||
const timeEvent = reader(TimeEvent)(event)
|
||||
|
||||
const location = timeEvent.location()
|
||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1 text-sm opacity-75">
|
||||
|
|
@ -24,10 +20,10 @@
|
|||
<Icon icon={UserCircle} size={4} />
|
||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||
</span>
|
||||
{#if location}
|
||||
{#if meta.location}
|
||||
<span class="flex items-start gap-1">
|
||||
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
||||
<span class="wrap-break-word">{location}</span>
|
||||
<span class="wrap-break-word">{meta.location}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,14 +18,28 @@
|
|||
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 {MessagingRelayLists, Thunks} from "@welshman/app"
|
||||
import {
|
||||
makeEvent,
|
||||
tagsFromIMeta,
|
||||
getTags,
|
||||
DIRECT_MESSAGE,
|
||||
DIRECT_MESSAGE_FILE,
|
||||
} from "@welshman/util"
|
||||
import {
|
||||
pubkey,
|
||||
tagPubkey,
|
||||
sendWrapped,
|
||||
mergeThunks,
|
||||
loadMessagingRelayList,
|
||||
messagingRelayListsByPubkey,
|
||||
} from "@welshman/app"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
|
|
@ -39,12 +53,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 {prependParent} from "@app/rooms"
|
||||
import {makeDelete} from "@app/deletes"
|
||||
import {prependParent} from "@app/groups"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -57,9 +71,8 @@
|
|||
const chatId = makeChatId(pubkeys)
|
||||
const chat = deriveChat(chatId)
|
||||
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`)
|
||||
const others = remove($user.pubkey, pubkeys)
|
||||
const messagingRelayLists = $app.use(MessagingRelayLists).index.$
|
||||
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk)))
|
||||
const others = remove($pubkey!, pubkeys)
|
||||
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
||||
|
||||
const showMembers = () =>
|
||||
others.length === 1
|
||||
|
|
@ -83,26 +96,28 @@
|
|||
|
||||
const onSubmit = async (params: EventContent) => {
|
||||
try {
|
||||
const ptags = others.map(pk => ["p", pk])
|
||||
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
|
||||
|
||||
// Remove p tags since they result in forking the conversation
|
||||
params.tags = params.tags.filter(nthNe(0, "p"))
|
||||
|
||||
// Add our reply quote to content
|
||||
params = await prependParent(parent, params)
|
||||
params = prependParent(parent, params)
|
||||
|
||||
if (eventToEdit) {
|
||||
if (eventToEdit.content === params.content) {
|
||||
return
|
||||
}
|
||||
|
||||
const command = await $deletes.deleteEvent(eventToEdit)
|
||||
|
||||
await $wraps.publish({event: command.event, recipients: pubkeys, pow: 16})
|
||||
await sendWrapped({
|
||||
event: makeDelete({event: eventToEdit, protect: false}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
}
|
||||
|
||||
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
|
||||
const imetas = imetaTags.map(tag => tag.slice(1).map(entry => entry.split(" ")))
|
||||
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
|
||||
const templates: EventTemplate[] = []
|
||||
const buffer = []
|
||||
|
||||
|
|
@ -127,7 +142,11 @@
|
|||
|
||||
if (isLink(p) && imeta) {
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
addTemplate(DIRECT_MESSAGE_FILE, p.value.url.toString(), imeta.filter(nthNe(0, "url")))
|
||||
addTemplate(
|
||||
DIRECT_MESSAGE_FILE,
|
||||
p.value.url.toString(),
|
||||
imeta.slice(1).filter(nthNe(0, "url")),
|
||||
)
|
||||
} else {
|
||||
buffer.push(p.raw)
|
||||
}
|
||||
|
|
@ -139,7 +158,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]) =>
|
||||
$wraps.publish({
|
||||
sendWrapped({
|
||||
event,
|
||||
recipients: pubkeys,
|
||||
delay: $userSettingsValues.send_delay + ms(i),
|
||||
|
|
@ -152,7 +171,7 @@
|
|||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk: $app.use(Thunks).merge(thunks)},
|
||||
props: {thunk: mergeThunks(thunks)},
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
|
|
@ -167,7 +186,7 @@
|
|||
}
|
||||
|
||||
const canEditEvent = (event: TrustedEvent) =>
|
||||
event.pubkey === $user.pubkey &&
|
||||
event.pubkey === $pubkey &&
|
||||
event.kind === DIRECT_MESSAGE &&
|
||||
event.created_at >= ago(500, MINUTE)
|
||||
|
||||
|
|
@ -215,7 +234,7 @@
|
|||
|
||||
onMount(() => {
|
||||
for (const pubkey of others) {
|
||||
$app.use(MessagingRelayLists).load(pubkey)
|
||||
loadMessagingRelayList(pubkey)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -230,15 +249,15 @@
|
|||
<Icon icon={ArrowLeft} size={7} />
|
||||
</Button>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="truncate min-w-0 flex items-center gap-4 whitespace-nowrap">
|
||||
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
|
||||
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
|
||||
{#if others.length === 0}
|
||||
<div class="flex gap-2">
|
||||
<ProfileCircle pubkey={$user.pubkey} size={5} />
|
||||
<ProfileName pubkey={$user.pubkey} />
|
||||
<div class="row-2">
|
||||
<ProfileCircle pubkey={$pubkey!} size={5} />
|
||||
<ProfileName pubkey={$pubkey!} />
|
||||
</div>
|
||||
{:else if others.length === 1}
|
||||
<div class="flex gap-2">
|
||||
<div class="row-2">
|
||||
<ProfileCircle pubkey={others[0]} size={5} />
|
||||
<ProfileName pubkey={others[0]} />
|
||||
</div>
|
||||
|
|
@ -263,11 +282,11 @@
|
|||
</div>
|
||||
</PageBar>
|
||||
|
||||
<div class="flex flex-col-reverse gap-4 py-2 bg-surface scroll-container h-screen overflow-y-auto">
|
||||
<PageContent class="flex flex-col-reverse gap-2 py-2 !mb-0">
|
||||
{#if missingRelayLists.length > 0}
|
||||
<div class="py-12">
|
||||
<div class="card flex flex-col gap-2 m-auto max-w-md items-center text-center">
|
||||
<p class="flex gap-2 text-lg text-error">
|
||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||
<p class="row-2 text-lg text-error">
|
||||
<Icon icon={Danger} />
|
||||
Direct messages are not enabled
|
||||
</p>
|
||||
|
|
@ -276,7 +295,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>
|
||||
|
|
@ -305,9 +324,9 @@
|
|||
{@render info?.()}
|
||||
</p>
|
||||
<div class="h-screen"></div>
|
||||
</div>
|
||||
</PageContent>
|
||||
|
||||
<div class="room__compose bg-surface">
|
||||
<div class="chat__compose bg-base-200">
|
||||
<div>
|
||||
{#if parent}
|
||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
import Plane from "@assets/icons/plane-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {type DraftKey} from "@app/drafts"
|
||||
|
|
@ -114,11 +113,11 @@
|
|||
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="button button-neutral button-square tip tip-right h-10 w-10 min-w-10 rounded-2xl transition-colors"
|
||||
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
|
||||
disabled={$uploading || disabled}
|
||||
onclick={uploadFiles}>
|
||||
{#if $uploading}
|
||||
<Spinner size="xs" />
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Icon icon={GallerySend} />
|
||||
{/if}
|
||||
|
|
@ -128,7 +127,7 @@
|
|||
</div>
|
||||
<Button
|
||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||
class="button button-primary button-circle tip tip-left h-10 w-10 min-w-10"
|
||||
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
||||
disabled={$uploading || disabled}
|
||||
onclick={submit}>
|
||||
<Icon icon={Plane} />
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class="bg-surface-more relative flex h-8 items-center justify-between border-l-2 border-solid px-2 pr-7 text-xs"
|
||||
style="border-color: var(--primary)"
|
||||
class="relative flex h-8 items-center justify-between border-l-2 border-solid border-primary bg-base-300 px-2 pr-7 text-xs"
|
||||
transition:slide>
|
||||
<p class="text-primary">Editing message</p>
|
||||
<Button onclick={clear} class="flex items-center">
|
||||
|
|
|
|||
|
|
@ -1,28 +1,27 @@
|
|||
<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"
|
||||
|
||||
type Props = {
|
||||
const {
|
||||
verb,
|
||||
event,
|
||||
clear,
|
||||
}: {
|
||||
verb: string
|
||||
event: TrustedEvent
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const {verb, event, clear}: Props = $props()
|
||||
|
||||
const display = $profiles.display(event.pubkey).$
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-surface-more relative border-l-2 border-solid px-2 py-1 pr-8"
|
||||
style="border-color: var(--primary)"
|
||||
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8"
|
||||
transition:slide>
|
||||
<p class="text-primary text-xs">{verb} @{$display}</p>
|
||||
<p class="text-xs text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
|
||||
{#key event.id}
|
||||
<NoteContentMinimal trimParent {event} />
|
||||
{/key}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import {getRelaysFromList} from "@welshman/util"
|
||||
import {waitForThunkError, setMessagingRelays, userRelayList, setRelays} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {RelayLists} from "@welshman/app"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -11,7 +12,6 @@
|
|||
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 +21,7 @@
|
|||
|
||||
const {next}: Props = $props()
|
||||
|
||||
const userRelayList = deriveUserItem(RelayLists)
|
||||
let loading = $state(false)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
|
|
@ -29,14 +29,8 @@
|
|||
loading = true
|
||||
|
||||
try {
|
||||
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 (getRelaysFromList($userRelayList).length === 0) {
|
||||
const error = await waitForThunkError(await setRelays(DEFAULT_RELAYS.map(r => ["r", r])))
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
|
|
@ -44,8 +38,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
const command = await $messagingRelayLists.setUrls(DEFAULT_MESSAGING_RELAYS)
|
||||
const error = await command.publish().waitForError()
|
||||
const error = await waitForThunkError(await setMessagingRelays(DEFAULT_MESSAGING_RELAYS))
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
|
|
@ -57,8 +50,6 @@
|
|||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(enable)}>
|
||||
|
|
@ -69,11 +60,11 @@
|
|||
<p>Direct messaging isn't currently enabled. Would you like to turn it on?</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="button button-primary" disabled={loading}>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Enable direct messaging</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import cx from "classnames"
|
||||
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"
|
||||
|
|
@ -11,7 +11,6 @@
|
|||
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,31 +21,27 @@
|
|||
|
||||
const {...props}: Props = $props()
|
||||
|
||||
const others = uniq(remove($user.pubkey, props.pubkeys))
|
||||
const others = uniq(remove($pubkey!, props.pubkeys))
|
||||
const active = $derived($page.params.chat === props.id)
|
||||
const path = makeChatPath(props.pubkeys)
|
||||
const openChat = () => goToChat(props.pubkeys)
|
||||
|
||||
onMount(() => {
|
||||
for (const pk of others) {
|
||||
$messagingRelayLists.load(pk)
|
||||
loadMessagingRelayList(pk)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
|
||||
<div
|
||||
class={cx(
|
||||
"cursor-pointer border-t border-solid px-3 py-2 transition-colors hover:bg-surface",
|
||||
props.class,
|
||||
{"bg-surface": active},
|
||||
)}
|
||||
style="border-color: var(--line)">
|
||||
class="cursor-pointer border-t border-solid border-base-100 px-3 py-2 transition-colors hover:bg-base-100 {props.class}"
|
||||
class:bg-base-100={active}>
|
||||
<div class="flex flex-col justify-start gap-1">
|
||||
<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={$user.pubkey} size={5} />
|
||||
<ProfileCircle pubkey={$pubkey!} size={5} />
|
||||
Note to self
|
||||
{:else if others.length === 1}
|
||||
<ProfileCircle pubkey={others[0]} size={5} />
|
||||
|
|
@ -61,12 +56,12 @@
|
|||
{/if}
|
||||
</div>
|
||||
{#if !active && $notifications.has(path)}
|
||||
<div class="h-2 w-2 rounded-full bg-primary text-primary-content" transition:fade></div>
|
||||
<div class="h-2 w-2 rounded-full bg-primary" transition:fade></div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
<span class="opacity-70">
|
||||
{#if props.messages[0].pubkey === $user.pubkey}
|
||||
{#if props.messages[0].pubkey === $pubkey}
|
||||
You:
|
||||
{/if}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -7,13 +7,11 @@
|
|||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
pubkeys: string[]
|
||||
}
|
||||
|
||||
const {pubkeys}: Props = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
|
|
@ -23,13 +21,13 @@
|
|||
</ModalHeader>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pubkeys as pubkey (pubkey)}
|
||||
<div class="card">
|
||||
<div class="card2 bg-alt">
|
||||
<Profile {pubkey} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-primary" onclick={back}>Got it</Button>
|
||||
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -23,17 +23,17 @@
|
|||
<Modal>
|
||||
<ModalBody>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button class="button button-neutral" onclick={markAsRead}>
|
||||
<Button class="btn btn-neutral" onclick={markAsRead}>
|
||||
<Icon size={5} icon={Check} />
|
||||
Mark all read
|
||||
</Button>
|
||||
{#if $notificationSettings.messages}
|
||||
<Button class="button button-neutral" onclick={disableAlerts}>
|
||||
<Button class="btn btn-neutral" onclick={disableAlerts}>
|
||||
<Icon size={4} icon={BellOff} />
|
||||
Disable alerts
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="button button-neutral" onclick={enableAlerts}>
|
||||
<Button class="btn btn-neutral" onclick={enableAlerts}>
|
||||
<Icon size={4} icon={Bell} />
|
||||
Enable alerts
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {type Instance} from "tippy.js"
|
||||
import {hash, formatTimestampAsTime} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {Thunks} from "@welshman/app"
|
||||
import {thunks, mergeThunks, pubkey, deriveProfileDisplay, sendWrapped} from "@welshman/app"
|
||||
import {isMobile} from "@lib/html"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -11,15 +10,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,18 +32,23 @@
|
|||
|
||||
const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props()
|
||||
|
||||
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 isOwn = event.pubkey === $pubkey
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||
const thunk = mergeThunks($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 = (reaction: TrustedEvent) => retractWrappedReaction(reaction, pubkeys)
|
||||
const deleteReaction = (event: TrustedEvent) =>
|
||||
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys, pow: 16})
|
||||
|
||||
const createReaction = (values: EventContent) => publishWrappedReaction(event, values, pubkeys)
|
||||
const createReaction = (template: EventContent) =>
|
||||
sendWrapped({
|
||||
event: makeReaction({event, protect: false, ...template}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
||||
|
||||
|
|
@ -62,10 +66,15 @@
|
|||
let popoverIsVisible = $state(false)
|
||||
</script>
|
||||
|
||||
<ThunkFailure showToastOnRetry {thunk} class="mt-1" />
|
||||
{#if thunk}
|
||||
<ThunkFailure showToastOnRetry {thunk} class="mt-1" />
|
||||
{/if}
|
||||
<div
|
||||
data-event={event.id}
|
||||
class={cx("group flex items-center justify-end gap-1 px-2", {"flex-row-reverse": !isOwn})}>
|
||||
class="group chat flex items-center justify-end gap-1 px-2"
|
||||
class:chat-start={!isOwn}
|
||||
class:flex-row-reverse={!isOwn}
|
||||
class:chat-end={isOwn}>
|
||||
{#if !isMobile}
|
||||
<Tippy
|
||||
bind:popover
|
||||
|
|
@ -91,15 +100,16 @@
|
|||
</Tippy>
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
|
||||
<TapTarget class={cx("chat-bubble", {"chat-bubble--user": isOwn})} onTap={showMobileMenu}>
|
||||
<TapTarget
|
||||
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl min-w-[100px]"
|
||||
onTap={showMobileMenu}>
|
||||
{#if showPubkey}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !isOwn}
|
||||
<Button onclick={openProfile} class="flex items-center gap-1">
|
||||
<ProfileCircle
|
||||
pubkey={event.pubkey}
|
||||
class="border border-solid"
|
||||
style="border-color: var(--line)"
|
||||
class="border border-solid border-base-content"
|
||||
size={4} />
|
||||
<div class="flex items-center gap-2">
|
||||
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
|
||||
|
|
@ -116,7 +126,7 @@
|
|||
<Content showEntire {event} />
|
||||
</div>
|
||||
</TapTarget>
|
||||
<div class="flex gap-2 z-feature -mt-4 ml-4">
|
||||
<div class="row-2 z-feature -mt-4 ml-4">
|
||||
<ReactionSummary {event} {deleteReaction} {createReaction} noTooltip />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,27 @@
|
|||
<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 {reactions, wraps} from "@app/core"
|
||||
import {makeReaction} from "@app/reactions"
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
event: TrustedEvent
|
||||
pubkeys: string[]
|
||||
}
|
||||
|
||||
const {event, pubkeys}: Props = $props()
|
||||
|
||||
const onEmoji = async (emoji: NativeEmoji) => {
|
||||
const reaction = await $reactions.react(event, emoji.unicode)
|
||||
|
||||
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
|
||||
}
|
||||
const onEmoji = (emoji: NativeEmoji) =>
|
||||
sendWrapped({
|
||||
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
</script>
|
||||
|
||||
<EmojiButton {onEmoji} class="button button-neutral button-xs join-item">
|
||||
<EmojiButton {onEmoji} class="btn join-item btn-xs">
|
||||
<Icon icon={SmileCircle} size={4} />
|
||||
</EmojiButton>
|
||||
|
|
|
|||
|
|
@ -19,19 +19,19 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="join text-xs">
|
||||
<div class="join border border-solid border-neutral text-xs">
|
||||
<ChatMessageEmojiButton {event} {pubkeys} />
|
||||
{#if replyTo}
|
||||
<Button class="button button-neutral button-xs join-item" onclick={reply}>
|
||||
<Button class="btn join-item btn-xs" onclick={reply}>
|
||||
<Icon size={4} icon={Reply} />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if edit}
|
||||
<Button class="button button-neutral button-xs join-item" onclick={onEdit}>
|
||||
<Button class="btn join-item btn-xs" onclick={onEdit}>
|
||||
<Icon size={4} icon={Pen} />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button class="button button-neutral button-xs join-item" onclick={showInfo}>
|
||||
<Button class="btn join-item btn-xs" onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<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"
|
||||
|
|
@ -12,7 +13,7 @@
|
|||
import Button from "@lib/components/Button.svelte"
|
||||
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import {reactions, wraps} from "@app/core"
|
||||
import {makeReaction} from "@app/reactions"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {clip} from "@app/toast"
|
||||
|
||||
|
|
@ -25,13 +26,14 @@
|
|||
|
||||
const {event, pubkeys, reply, edit}: Props = $props()
|
||||
|
||||
const onEmoji = async (emoji: NativeEmoji) => {
|
||||
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
|
||||
history.back()
|
||||
|
||||
const reaction = await $reactions.react(event, emoji.unicode)
|
||||
|
||||
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
|
||||
}
|
||||
sendWrapped({
|
||||
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
}).bind(undefined, event, pubkeys)
|
||||
|
||||
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
||||
|
||||
|
|
@ -56,25 +58,25 @@
|
|||
<Modal>
|
||||
<ModalBody>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button class="button button-neutral" onclick={showInfo}>
|
||||
<Button class="btn btn-neutral" onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
Message Info
|
||||
</Button>
|
||||
<Button class="button button-neutral w-full" onclick={copyText}>
|
||||
<Button class="btn btn-neutral w-full" onclick={copyText}>
|
||||
<Icon size={4} icon={Copy} />
|
||||
Copy Text
|
||||
</Button>
|
||||
<Button class="button button-neutral w-full" onclick={sendReply}>
|
||||
<Button class="btn btn-neutral w-full" onclick={sendReply}>
|
||||
<Icon size={4} icon={Reply} />
|
||||
Send Reply
|
||||
</Button>
|
||||
{#if edit}
|
||||
<Button class="button button-neutral w-full" onclick={sendEdit}>
|
||||
<Button class="btn btn-neutral w-full" onclick={sendEdit}>
|
||||
<Icon size={4} icon={Pen} />
|
||||
Edit Message
|
||||
</Button>
|
||||
{/if}
|
||||
<Button class="button button-primary w-full" onclick={showEmojiPicker}>
|
||||
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
|
||||
<Icon size={4} icon={SmileCircle} />
|
||||
Send Reaction
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<script lang="ts">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {uniq} from "@welshman/lib"
|
||||
import {decodePubkey} from "@lib/util"
|
||||
import {tryCatch, uniq} from "@welshman/lib"
|
||||
import {fromNostrURI} from "@welshman/util"
|
||||
import {loadMessagingRelayList} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -17,7 +19,6 @@
|
|||
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,15 +34,27 @@
|
|||
let pubkeys: string[] = $state([])
|
||||
|
||||
$effect(() => {
|
||||
pubkeys.forEach(pubkey => $messagingRelayLists.load(pubkey))
|
||||
pubkeys.forEach(pubkey => loadMessagingRelayList(pubkey))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
return term.subscribe(t => {
|
||||
const pubkey = decodePubkey(t)
|
||||
if (t.match(/^[0-9a-f]{64}$/)) {
|
||||
addPubkey(t)
|
||||
}
|
||||
|
||||
if (pubkey) {
|
||||
addPubkey(pubkey)
|
||||
if (t.match(/^(nostr:)?(npub1|nprofile1)/)) {
|
||||
tryCatch(() => {
|
||||
const {type, data} = nip19.decode(fromNostrURI(t))
|
||||
|
||||
if (type === "npub") {
|
||||
addPubkey(data)
|
||||
}
|
||||
|
||||
if (type === "nprofile") {
|
||||
addPubkey(data.pubkey)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -60,11 +73,11 @@
|
|||
</Field>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="button button-primary" disabled={pubkeys.length === 0}>
|
||||
<Button type="submit" class="btn btn-primary" disabled={pubkeys.length === 0}>
|
||||
Create Chat
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,27 @@
|
|||
<script lang="ts">
|
||||
import {uniq} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {getAddress} from "@welshman/util"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import {getTagValue, getTagValues, getAddress} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
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 {reader, user} from "@app/core"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
import {publishReaction} from "@app/reactions"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {makeClassifiedPath, makeSpacePath} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
showRoom?: boolean
|
||||
|
|
@ -29,33 +30,34 @@
|
|||
|
||||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const classified = reader(Classified)(event)
|
||||
|
||||
const h = classified.room()
|
||||
const topics = classified.topics() ?? []
|
||||
const h = getTagValue("h", event.tags)
|
||||
const topics = getTagValues("t", event.tags)
|
||||
const path = makeClassifiedPath(url, getAddress(event))
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const editClassified = () => pushModal(ClassifiedEdit, {url, event})
|
||||
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
|
||||
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
|
||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-wrap gap-2">
|
||||
{#each uniq(topics) as topic (topic)}
|
||||
<button type="button" class="button button-xs rounded-full font-normal">
|
||||
<button type="button" class="btn btn-xs rounded-full font-normal">
|
||||
#{normalizeTopic(topic)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event}>
|
||||
<ClassifiedStatus {event} />
|
||||
</ThunkStatusOrDeleted>
|
||||
|
|
@ -64,7 +66,7 @@
|
|||
{/if}
|
||||
<EventActions {url} {event} noun="Listing">
|
||||
{#snippet customActions()}
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
{#if event.pubkey === $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 {Classified} from "@welshman/domain"
|
||||
import {getTag, getTagValues} from "@welshman/util"
|
||||
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,29 +14,19 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
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(),
|
||||
},
|
||||
)
|
||||
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}
|
||||
</script>
|
||||
|
||||
{#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}
|
||||
<ClassifiedForm {url} {initialValues}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
<ModalTitle>Edit this Listing</ModalTitle>
|
||||
<ModalSubtitle>Advertise a job, sale, or need.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
{/snippet}
|
||||
</ClassifiedForm>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {removeUndefined, randomId, uniq} from "@welshman/lib"
|
||||
import {publish} from "@welshman/app"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import {makeEvent, CLASSIFIED} from "@welshman/util"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import {normalizeTopic} from "@lib/util"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
|
|
@ -17,12 +17,12 @@
|
|||
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 {publishRoomQuote} from "@app/rooms"
|
||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {compressFileForUpload, uploadFile} from "@app/uploads"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {uploadFile} from "@app/uploads"
|
||||
|
||||
type Values = {
|
||||
d: string
|
||||
|
|
@ -51,6 +51,8 @@
|
|||
initialValues = draftKey.get()
|
||||
}
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const submit = async () => {
|
||||
|
|
@ -74,13 +76,34 @@
|
|||
})
|
||||
}
|
||||
|
||||
const imageUrls: string[] = []
|
||||
const tags = [
|
||||
["d", d],
|
||||
["title", title],
|
||||
["summary", content],
|
||||
["price", String(price), currency],
|
||||
["status", status],
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
]
|
||||
|
||||
for (const topic of topics) {
|
||||
tags.push(["t", topic])
|
||||
}
|
||||
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
for (const image of images) {
|
||||
if (typeof image === "string") {
|
||||
imageUrls.push(image)
|
||||
tags.push(["image", image])
|
||||
} else {
|
||||
const {result, error} = await uploadFile(await compressFileForUpload(image), {url})
|
||||
const {result, error} = await uploadFile(image, {url})
|
||||
|
||||
if (error) {
|
||||
return pushToast({
|
||||
|
|
@ -90,33 +113,17 @@
|
|||
}
|
||||
|
||||
if (result) {
|
||||
imageUrls.push(result.url)
|
||||
tags.push(["image", result.url])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const protect = await $relays.hasNip(url, 70)
|
||||
const classifiedThunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(CLASSIFIED, {content, tags}),
|
||||
})
|
||||
|
||||
const eventWriter = writer(Classified)
|
||||
.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()
|
||||
const error = await waitForThunkError(classifiedThunk)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
|
|
@ -157,13 +164,13 @@
|
|||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
<ModalBody>
|
||||
{@render header?.()}
|
||||
<div class="flex flex-col gap-8 relative">
|
||||
<div class="col-8 relative">
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Title*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus={!isMobile}
|
||||
|
|
@ -198,10 +205,10 @@
|
|||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="join grid grid-cols-2">
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<label class="join-item input input-bordered flex w-full items-center gap-2">
|
||||
<input bind:value={price} class="grow w-32" type="number" />
|
||||
</label>
|
||||
<CurrencyInput bind:value={currency} />
|
||||
<CurrencyInput class="join-item" bind:value={currency} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
|
|
@ -211,7 +218,7 @@
|
|||
<p>Status*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<select class="select input w-full" bind:value={status}>
|
||||
<select class="select select-bordered w-full" bind:value={status}>
|
||||
<option value="active">Active</option>
|
||||
<option value="sold">Sold</option>
|
||||
</select>
|
||||
|
|
@ -229,11 +236,11 @@
|
|||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onclick={back} disabled={loading} class="button button-link">
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading} class="button button-primary">
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Save Listing</Spinner>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
<script lang="ts">
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getAddress} from "@welshman/util"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import {getTag, getAddress, getTagValue, getTagValues} from "@welshman/util"
|
||||
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"
|
||||
|
|
@ -20,22 +18,20 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const classified = reader(Classified)(event)
|
||||
|
||||
const title = classified.title()
|
||||
const h = classified.room()
|
||||
const images = new Set(classified.images())
|
||||
const price = classified.price()
|
||||
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) || []
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="cv flex flex-col gap-2 card card-interactive w-full"
|
||||
class="cv col-2 card2 w-full cursor-pointer"
|
||||
href={makeClassifiedPath(url, getAddress(event))}>
|
||||
{#if title}
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<p class="text-xl">
|
||||
{title} —
|
||||
<CurrencySymbol code={price?.currency ?? "SAT"} />{price?.amount ?? 0}
|
||||
<CurrencySymbol code={currency} />{price}
|
||||
</p>
|
||||
<p class="text-sm opacity-75">
|
||||
{formatTimestamp(event.created_at)}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import {ucFirst} from "@lib/util"
|
||||
import Badge from "@lib/components/Badge.svelte"
|
||||
import {reader} from "@app/core"
|
||||
|
||||
type Props = {
|
||||
event: TrustedEvent
|
||||
|
|
@ -11,13 +10,11 @@
|
|||
|
||||
const {event}: Props = $props()
|
||||
|
||||
const classified = reader(Classified)(event)
|
||||
|
||||
const status = classified.status()
|
||||
const status = getTagValue("status", event.tags)
|
||||
</script>
|
||||
|
||||
{#if status}
|
||||
<Badge variant={status !== "active" ? "primary" : "neutral"} class="rounded-full">
|
||||
<div class={cx("btn btn-xs rounded-full", {"btn-primary": status !== "active"})}>
|
||||
{ucFirst(status)}
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
<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"
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
segment: string
|
||||
|
|
@ -17,18 +18,20 @@
|
|||
|
||||
const {url, event, segment, showActivity = false}: Props = $props()
|
||||
|
||||
const h = tagValue(tagSpec("h"), event.tags)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const path = makeSpacePath(url, segment, event.id)
|
||||
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
|
||||
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md" bind:this={ul}>
|
||||
<li>
|
||||
<Button onclick={createGoal}>
|
||||
<Icon size={4} icon={StarFallMinimalistic} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import {fromNostrURI, tagSpec, tagValue} from "@welshman/util"
|
||||
import {fromNostrURI} from "@welshman/util"
|
||||
import {nthEq} from "@welshman/lib"
|
||||
import {
|
||||
parse,
|
||||
truncate,
|
||||
|
|
@ -24,7 +25,6 @@
|
|||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Card from "@lib/components/Card.svelte"
|
||||
import ContentToken from "@app/components/ContentToken.svelte"
|
||||
import ContentEmoji from "@app/components/ContentEmoji.svelte"
|
||||
import ContentEmail from "@app/components/ContentEmail.svelte"
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
import {entityLink} from "@app/env"
|
||||
import {userSettingsValues} from "@app/settings"
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
event: any
|
||||
minLength?: number
|
||||
maxLength?: number
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
url,
|
||||
}: Props = $props()
|
||||
|
||||
const fullContent = $derived(parse(event))
|
||||
const fullContent = parse(event)
|
||||
|
||||
const expand = () => {
|
||||
showEntire = true
|
||||
|
|
@ -97,16 +97,12 @@
|
|||
|
||||
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
|
||||
|
||||
let warningDismissed = $state(false)
|
||||
|
||||
const ignoreWarning = () => {
|
||||
warningDismissed = true
|
||||
warning = null
|
||||
}
|
||||
|
||||
const warning = $derived(
|
||||
warningDismissed
|
||||
? undefined
|
||||
: $userSettingsValues.hide_sensitive && tagValue(tagSpec("content-warning"), event.tags),
|
||||
let warning = $state(
|
||||
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
|
||||
)
|
||||
|
||||
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
|
||||
|
|
@ -148,13 +144,13 @@
|
|||
|
||||
<div class="relative">
|
||||
{#if warning}
|
||||
<Card sm class="shadow-none flex gap-2">
|
||||
<div class="card2 card2-sm shadow-none row-2">
|
||||
<Icon icon={Danger} />
|
||||
<p>
|
||||
This note has been flagged by the author as "{warning}".<br />
|
||||
<Button onclick={ignoreWarning} class="button button-link">Show anyway</Button>
|
||||
<Button class="link" onclick={ignoreWarning}>Show anyway</Button>
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="overflow-hidden text-ellipsis wrap-break-word"
|
||||
|
|
@ -208,9 +204,12 @@
|
|||
</div>
|
||||
{#if expandBlock}
|
||||
<div class="relative z-feature -mt-6 flex justify-center py-2">
|
||||
<Button onclick={stopPropagation(preventDefault(expand))} class="button button-neutral">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-neutral"
|
||||
onclick={stopPropagation(preventDefault(expand))}>
|
||||
See more
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
</script>
|
||||
|
||||
<code
|
||||
class="bg-surface-more text-content w-full overflow-auto whitespace-pre rounded px-1 text-sm"
|
||||
class="w-full overflow-auto whitespace-pre rounded bg-neutral px-1 text-neutral-content text-sm"
|
||||
class:block={isBlock}>
|
||||
{value.trim()}
|
||||
</code>
|
||||
|
|
|
|||
|
|
@ -1,33 +1,16 @@
|
|||
<script module lang="ts">
|
||||
import {postJson, simpleCache} from "@welshman/lib"
|
||||
import {dufflepud} from "@app/env"
|
||||
|
||||
// Cache previews by url so the same link isn't re-fetched across renders/instances.
|
||||
const loadPreview = simpleCache(async ([url]: [string]) => {
|
||||
const json = await postJson(dufflepud("link/preview"), {url})
|
||||
|
||||
if (!json?.title && !json?.image) {
|
||||
throw new Error("Failed to load link preview")
|
||||
}
|
||||
|
||||
return json
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import {call, ellipsize, displayUrl} from "@welshman/lib"
|
||||
import {isRelayUrl, tagSpec, tagValue} from "@welshman/util"
|
||||
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
||||
import {isRelayUrl, getTagValue} from "@welshman/util"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import {preventDefault, stopPropagation} from "@lib/html"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
|
||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
|
||||
import {dufflepud, PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
|
||||
import {IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/content"
|
||||
import {isRoomId} from "@app/rooms"
|
||||
import {isRoomId} from "@app/groups"
|
||||
|
||||
const {value, event} = $props()
|
||||
|
||||
|
|
@ -41,7 +24,7 @@
|
|||
return [url, true]
|
||||
})
|
||||
|
||||
const fileType = tagValue(tagSpec("file-type"), event.tags) || ""
|
||||
const fileType = getTagValue("file-type", event.tags) || ""
|
||||
|
||||
const getVideoPoster = (videoUrl: string): string | undefined => {
|
||||
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
|
||||
|
|
@ -51,6 +34,16 @@
|
|||
return undefined
|
||||
}
|
||||
|
||||
const loadPreview = async () => {
|
||||
const json = await postJson(dufflepud("link/preview"), {url})
|
||||
|
||||
if (!json?.title && !json?.image) {
|
||||
throw new Error("Failed to load link preview")
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
const onError = () => {
|
||||
hideImage = true
|
||||
}
|
||||
|
|
@ -59,51 +52,52 @@
|
|||
</script>
|
||||
|
||||
{#if isRoomOrRelay}
|
||||
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
||||
<div>
|
||||
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
||||
</div>
|
||||
{:else}
|
||||
<Link {external} {href} class="my-2 block">
|
||||
{#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 class="overflow-hidden rounded-box">
|
||||
{#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-box 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-box" />
|
||||
</button>
|
||||
{:else}
|
||||
{#await loadPreview()}
|
||||
<div class="center my-12 w-full">
|
||||
<span class="loading loading-spinner"></span>
|
||||
</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}
|
||||
{:then preview}
|
||||
<div
|
||||
class="border border-solid border-base-content/20 flex max-w-xl flex-col leading-normal rounded-box">
|
||||
{#if preview.image && !hideImage}
|
||||
<img
|
||||
alt=""
|
||||
onerror={onError}
|
||||
src={preview.image}
|
||||
class="bg-alt max-h-72 rounded-t-box 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 border-base-content/20 p-12 text-center leading-normal">
|
||||
Unable to load a preview for {url}
|
||||
</p>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
</Link>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -2,35 +2,34 @@
|
|||
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 =
|
||||
matchTags(tagSpec("imeta"), event.tags)
|
||||
.map(([, ...values]: string[]) => values.map(value => value.split(" ")))
|
||||
.find(meta => tagValue(tagSpec("url"), meta) === url) || event.tags
|
||||
getTags("imeta", event.tags)
|
||||
.map(tagsFromIMeta)
|
||||
.find(meta => getTagValue("url", meta) === url) || event.tags
|
||||
|
||||
// Fallback to filename if hash was omitted from the message for interoperability
|
||||
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 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 fileName =
|
||||
tagValue(tagSpec("filename"), meta) ||
|
||||
tagValue(tagSpec("name"), meta) ||
|
||||
getTagValue("filename", meta) ||
|
||||
getTagValue("name", meta) ||
|
||||
decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).at(-1) || "image")
|
||||
|
||||
const revokeSrc = () => {
|
||||
|
|
@ -46,10 +45,10 @@
|
|||
|
||||
const onError = once(async () => {
|
||||
// If the image failed to load, try authenticating
|
||||
if (hash) {
|
||||
if (hash && $signer) {
|
||||
const server = new URL(url).origin
|
||||
const template = makeBlossomAuthEvent({action: "get", server, hashes: [hash]})
|
||||
const authEvent = await $user.signer.sign(template)
|
||||
const authEvent = await $signer.sign(template)
|
||||
const res = await getBlob(server, hash, {authEvent})
|
||||
|
||||
if (res.status === 200) {
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@
|
|||
</script>
|
||||
|
||||
<Button class="m-auto h-screen w-screen cursor-pointer p-4" onclick={back}>
|
||||
<ContentLinkBlockImage {value} {event} class="m-auto max-h-full max-w-full rounded-2xl" />
|
||||
<ContentLinkBlockImage {value} {event} class="m-auto max-h-full max-w-full rounded-box" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import {displayUrl} from "@welshman/lib"
|
||||
import {tagSpec, tagValue} from "@welshman/util"
|
||||
import {getTagValue} 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 = tagValue(tagSpec("file-type"), event.tags) || ""
|
||||
const fileType = getTagValue("file-type", event.tags) || ""
|
||||
|
||||
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,27 @@
|
|||
<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} from "@app/rooms"
|
||||
import {displayRoom, isRoomId, splitRoomId} from "@app/groups"
|
||||
import {makeRoomPath, makeSpacePath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
const {
|
||||
url,
|
||||
class: className = "",
|
||||
}: {
|
||||
url: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {url, class: className = ""}: Props = $props()
|
||||
} = $props()
|
||||
|
||||
const roomReference = call(() => {
|
||||
if (!isRoomId(url)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [roomUrl, h] = splitRoomKey(url)
|
||||
const [roomUrl, h] = splitRoomId(url)
|
||||
|
||||
if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
|
||||
return undefined
|
||||
|
|
@ -50,7 +49,8 @@
|
|||
|
||||
<Link {external} {href} class={className}>
|
||||
{#if roomReference}
|
||||
#<RoomName {...roomReference} />
|
||||
~<span class="text-primary">{displayRelayUrl(roomReference.url)}</span> /
|
||||
{displayRoom(roomReference.url, roomReference.h)}
|
||||
{:else if relayReference}
|
||||
<span class="text-primary">{displayRelayUrl(relayReference)}</span>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
<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
|
||||
|
|
@ -12,7 +13,7 @@
|
|||
|
||||
const {value, url}: Props = $props()
|
||||
|
||||
const display = $profiles.display(value.pubkey).$
|
||||
const display = deriveProfileDisplay(value.pubkey, removeUndefined([url]))
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey, url})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {fromNostrURI, tagValue, tagSpec} from "@welshman/util"
|
||||
import {fromNostrURI} from "@welshman/util"
|
||||
import {nthEq} from "@welshman/lib"
|
||||
import {
|
||||
parse,
|
||||
truncate,
|
||||
|
|
@ -43,7 +44,7 @@
|
|||
|
||||
const {event, trimParent = false, singleLine = false, url}: Props = $props()
|
||||
|
||||
const fullContent = $derived(parse(event))
|
||||
const fullContent = parse(event)
|
||||
|
||||
const isBoundary = (i: number) => {
|
||||
const parsed = fullContent[i]
|
||||
|
|
@ -62,16 +63,12 @@
|
|||
|
||||
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
|
||||
|
||||
let warningDismissed = $state(false)
|
||||
|
||||
const ignoreWarning = () => {
|
||||
warningDismissed = true
|
||||
warning = null
|
||||
}
|
||||
|
||||
const warning = $derived(
|
||||
warningDismissed
|
||||
? undefined
|
||||
: $userSettingsValues.hide_sensitive && tagValue(tagSpec("content-warning"), event.tags),
|
||||
let warning = $state(
|
||||
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
|
||||
)
|
||||
|
||||
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
|
||||
|
|
@ -101,11 +98,11 @@
|
|||
|
||||
<div class="relative">
|
||||
{#if warning}
|
||||
<div class="card card-sm shadow-none flex gap-2">
|
||||
<div class="card2 card2-sm shadow-none row-2">
|
||||
<Icon icon={Danger} />
|
||||
<p>
|
||||
This note has been flagged by the author as "{warning}".<br />
|
||||
<Button onclick={ignoreWarning} class="button button-link">Show anyway</Button>
|
||||
<Button class="link" onclick={ignoreWarning}>Show anyway</Button>
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,13 @@
|
|||
<script lang="ts">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {derived, writable} from "svelte/store"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import {Router} from "@welshman/router"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
Address,
|
||||
MESSAGE,
|
||||
eventOutbox,
|
||||
getIdFilters,
|
||||
relays as relaySelections,
|
||||
seen,
|
||||
} from "@welshman/util"
|
||||
import {Address, MESSAGE} 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 {network, router} from "@app/core"
|
||||
import {deriveEvent} from "@app/repository"
|
||||
import {entityLink} from "@app/env"
|
||||
import {goToEvent} from "@app/routes"
|
||||
|
|
@ -31,43 +22,22 @@
|
|||
|
||||
const {id, identifier, kind, pubkey, relays = []} = value
|
||||
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
|
||||
const ref = {id, pubkey, kind, identifier, relays}
|
||||
const hints = removeUndefined([...relays, url])
|
||||
const mergedRelays = Router.get().Quote(event, idOrAddress, relays).getUrls()
|
||||
|
||||
const mergedRelays = writable(hints)
|
||||
if (url) {
|
||||
mergedRelays.push(url)
|
||||
}
|
||||
|
||||
const quote = deriveEvent(idOrAddress, hints)
|
||||
|
||||
// Start with the hints we were handed, then widen to everything the router can work out:
|
||||
// where the quoted event has been seen, its author's outbox, and — since whoever quoted it
|
||||
// must have seen it — the relays the quoting event came from. Resolving that may take a
|
||||
// round trip for the author's relay list, so only load again if the hints came up empty.
|
||||
$router.resolver
|
||||
.relays([
|
||||
...relaySelections(removeUndefined([url])),
|
||||
seen(ref),
|
||||
eventOutbox(ref),
|
||||
seen(event, 0.5),
|
||||
])
|
||||
.then(urls => {
|
||||
mergedRelays.set(urls)
|
||||
|
||||
if (!$quote) {
|
||||
$network.load({filters: getIdFilters([idOrAddress]), relays: urls})
|
||||
}
|
||||
})
|
||||
|
||||
const entity = derived(mergedRelays, $mergedRelays =>
|
||||
id
|
||||
? nip19.neventEncode({id, relays: $mergedRelays})
|
||||
: new Address(kind, pubkey, identifier, $mergedRelays).toNaddr(),
|
||||
)
|
||||
const quote = deriveEvent(idOrAddress, mergedRelays)
|
||||
const entity = 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>
|
||||
|
|
@ -78,21 +48,20 @@
|
|||
<NoteContent {url} event={$quote} />
|
||||
{:else if $quote.kind === MESSAGE}
|
||||
<div
|
||||
class="border-l-2 border-solid py-1 pl-2 opacity-90"
|
||||
style="border-left-color: var(--primary); background-color: color-mix(in srgb, var(--primary) 10%, var(--surface-more) 90%);">
|
||||
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
||||
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
|
||||
<NoteContentMinimal trimParent {url} event={$quote} />
|
||||
</div>
|
||||
{:else}
|
||||
<NoteCard
|
||||
event={$quote}
|
||||
{url}
|
||||
class="border border-solid rounded-2xl p-4"
|
||||
style="border-color: var(--line)">
|
||||
class="border border-solid border-base-content/20 rounded-box p-4">
|
||||
<NoteContentMinimal {url} event={$quote} />
|
||||
</NoteCard>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="rounded-2xl p-4">
|
||||
<div class="rounded-box p-4">
|
||||
<Spinner loading>Loading event...</Spinner>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {spec} from "@welshman/lib"
|
||||
import cx from "classnames"
|
||||
import {writable} from "svelte/store"
|
||||
import type {Writable} from "svelte/store"
|
||||
|
|
@ -46,12 +45,12 @@
|
|||
}
|
||||
|
||||
const onKeyDown = (e: Event) => {
|
||||
if (instance?.onKeyDown(e)) {
|
||||
if (instance.onKeyDown(e)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const currency = $derived(currencyOptions.find(spec({code: value})))
|
||||
const currency = $derived(currencyOptions.find(c => c.code === value))
|
||||
|
||||
let wrapper: Element | undefined = $state()
|
||||
let popover: Instance | undefined = $state()
|
||||
|
|
@ -67,13 +66,17 @@
|
|||
</script>
|
||||
|
||||
<button
|
||||
class={cx(props.class, {"bg-surface": currency}, "input flex items-center gap-2 cursor-pointer")}
|
||||
class={cx(
|
||||
props.class,
|
||||
{"bg-base-200": currency},
|
||||
"input input-bordered flex items-center gap-2 cursor-pointer",
|
||||
)}
|
||||
bind:this={wrapper}
|
||||
onfocus={preventDefault(clearAndFocus)}
|
||||
onclick={preventDefault(clearAndFocus)}>
|
||||
<Icon icon={AltArrowDown} />
|
||||
{#if currency}
|
||||
<span class="min-w-0 truncate text-sm whitespace-nowrap">
|
||||
<span class="text-sm ellipsize whitespace-nowrap">
|
||||
{displayCurrency(currency)}
|
||||
</span>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,7 @@
|
|||
loading = true
|
||||
|
||||
try {
|
||||
const thunk = await setFeaturedContent(url, content)
|
||||
const error = await thunk.waitForError()
|
||||
const error = await setFeaturedContent(url, content)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
|
|
@ -40,9 +39,6 @@
|
|||
pushToast({message: "Featured content updated!"})
|
||||
back()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
pushToast({theme: "error", message: "Failed to update featured content."})
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
|
@ -72,11 +68,11 @@
|
|||
</Field>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button class="button button-primary" onclick={submit} disabled={loading}>
|
||||
<Button class="btn btn-primary" onclick={submit} disabled={loading}>
|
||||
<Spinner {loading}>Save changes</Spinner>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
|
|
|||
|
|
@ -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 EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
import ZapButton from "@app/components/ZapButton.svelte"
|
||||
import EmojiButton from "@lib/components/EmojiButton.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,46 +26,40 @@
|
|||
|
||||
const {url, noun, event, hideZap, customActions}: Props = $props()
|
||||
|
||||
const shouldProtect = $relays.hasNip(url, 70)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const showPopover = () => popover?.show()
|
||||
|
||||
const hidePopover = () => popover?.hide()
|
||||
|
||||
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)
|
||||
}
|
||||
const onEmoji = async (emoji: NativeEmoji) =>
|
||||
publishReaction({
|
||||
event,
|
||||
content: emoji.unicode,
|
||||
relays: [url],
|
||||
protect: await shouldProtect,
|
||||
})
|
||||
|
||||
return command.publishToRelays([url])
|
||||
}
|
||||
|
||||
let popover: Instance | undefined = $state()
|
||||
</script>
|
||||
|
||||
<div class="items-center join">
|
||||
<div class="join items-center rounded-full">
|
||||
{#if ENABLE_ZAPS && !hideZap}
|
||||
<ZapButton {url} {event} class="button button-neutral button-xs join-item">
|
||||
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
|
||||
<Icon icon={Bolt} size={4} />
|
||||
</ZapButton>
|
||||
{/if}
|
||||
<EmojiButton {onEmoji} class="button button-neutral button-xs join-item">
|
||||
<EmojiButton {onEmoji} class="btn join-item btn-neutral btn-xs">
|
||||
<Icon icon={SmileCircle} size={4} />
|
||||
</EmojiButton>
|
||||
<Button onclick={showPopover} class="flex join-item button button-neutral button-xs">
|
||||
<Tippy
|
||||
bind:popover
|
||||
component={EventMenu}
|
||||
props={{url, noun, event, customActions, onClick: hidePopover}}
|
||||
params={{trigger: "manual", interactive: true}}>
|
||||
<Tippy
|
||||
class="flex"
|
||||
bind:popover
|
||||
component={EventMenu}
|
||||
props={{url, noun, event, customActions, onClick: hidePopover}}
|
||||
params={{trigger: "manual", interactive: true}}>
|
||||
<Button class="btn join-item btn-neutral btn-xs" onclick={showPopover}>
|
||||
<Icon icon={MenuDots} size={4} />
|
||||
</Tippy>
|
||||
</Button>
|
||||
</Button>
|
||||
</Tippy>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,32 +2,33 @@
|
|||
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 = deriveEvents(filters)
|
||||
const replies = deriveArray(deriveEventsById({repository, filters}))
|
||||
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
||||
|
||||
onMount(() => {
|
||||
$network.load({relays: [url], filters})
|
||||
load({relays: [url], filters})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex-inline button button-neutral button-xs gap-1 rounded-full">
|
||||
<div class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full">
|
||||
<Icon icon={Reply} />
|
||||
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
|
||||
</div>
|
||||
<div class="button button-neutral button-xs relative rounded-full">
|
||||
<div class="btn btn-neutral btn-xs relative rounded-full">
|
||||
{#if gt(lastActive, $checked)}
|
||||
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
|
||||
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
||||
{/if}
|
||||
Active {formatTimestampRelative(lastActive)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import {deletes, relays} from "@app/core"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {clearModals} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -11,13 +12,10 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const shouldProtect = $relays.hasNip(url, 70)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const confirm = async () => {
|
||||
const protect = await shouldProtect
|
||||
const command = await $deletes.deleteEvent(event, writer => writer.setProtected(protect))
|
||||
|
||||
command.publishToRelays([url])
|
||||
await publishDelete({event, relays: [url], protect: await shouldProtect})
|
||||
|
||||
clearModals()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
<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, seen, toNostrURI} from "@welshman/util"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {tracker} from "@welshman/app"
|
||||
import FileText from "@assets/icons/file-text.svg?dataurl"
|
||||
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Badge from "@lib/components/Badge.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
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,26 +26,19 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const seenOn = $app.tracker.getRelays(event.id)
|
||||
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
||||
const nevent1 = nip19.neventEncode({...event, relays})
|
||||
const seenOn = tracker.getRelays(event.id)
|
||||
const npub1 = nip19.npubEncode(event.pubkey)
|
||||
const json = JSON.stringify(event, null, 2)
|
||||
const copyLink = () => clip(nevent1)
|
||||
const copyPubkey = () => clip(npub1)
|
||||
const copyJson = () => clip(json)
|
||||
const back = () => history.back()
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(LOCALE, {
|
||||
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>
|
||||
|
|
@ -68,9 +60,9 @@
|
|||
<p>Event Link</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={FileText} />
|
||||
<input type="text" class="truncate min-w-0 grow" value={nevent1} />
|
||||
<input type="text" class="ellipsize min-w-0 grow" value={nevent1} />
|
||||
<Button onclick={copyLink} class="flex items-center">
|
||||
<Icon icon={Copy} />
|
||||
</Button>
|
||||
|
|
@ -82,9 +74,9 @@
|
|||
<p>Author Pubkey</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={UserCircle} />
|
||||
<input type="text" class="truncate min-w-0 grow" value={npub1} />
|
||||
<input type="text" class="ellipsize min-w-0 grow" value={npub1} />
|
||||
<Button onclick={copyPubkey} class="flex items-center">
|
||||
<Icon icon={Copy} />
|
||||
</Button>
|
||||
|
|
@ -99,24 +91,24 @@
|
|||
{#snippet input()}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each seenOn as url, i (url)}
|
||||
<Badge class="bg-surface flex gap-1">
|
||||
<span class="bg-alt badge flex gap-1">
|
||||
{displayRelayUrl(url)}
|
||||
</Badge>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{/if}
|
||||
<div class="relative">
|
||||
<pre class="card card-sm overflow-auto text-xs"><code>{json}</code></pre>
|
||||
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
|
||||
<p class="absolute right-2 top-2 flex grow items-center justify-between">
|
||||
<Button onclick={copyJson} class="button button-neutral button-sm flex items-center">
|
||||
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
|
||||
<Icon icon={Copy} /> Copy
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-primary grow" onclick={back}>Got it</Button>
|
||||
<Button class="btn btn-primary grow" onclick={() => history.back()}>Got it</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,27 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {Snippet} from "svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||
import {COMMENT, ManagementMethod} from "@welshman/util"
|
||||
import {pubkey, repository, relaysByUrl, manageRelay} from "@welshman/app"
|
||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import {setKey} from "@lib/implicit"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import Report from "@app/components/Report.svelte"
|
||||
import EventShare from "@app/components/EventShare.svelte"
|
||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||
import PinboardSelect from "@app/components/PinboardSelect.svelte"
|
||||
import {shareEventToChat} from "@app/share"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/management"
|
||||
import {hasNip29} from "@app/relays"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/members"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {app, relayManagement, user} from "@app/core"
|
||||
import {makeSpaceChatPath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
|
@ -38,9 +40,14 @@
|
|||
|
||||
const showInfo = () => pushModal(EventInfo, {url, event})
|
||||
|
||||
const addToLibrary = () => pushModal(PinboardSelect, {url, event})
|
||||
|
||||
const share = () => shareEventToChat(url, noun, event)
|
||||
const share = async () => {
|
||||
if (hasNip29($relaysByUrl.get(url))) {
|
||||
pushModal(EventShare, {url, event})
|
||||
} else {
|
||||
setKey("share", event)
|
||||
goto(makeSpaceChatPath(url))
|
||||
}
|
||||
}
|
||||
|
||||
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
||||
|
||||
|
|
@ -49,13 +56,16 @@
|
|||
title: `Delete ${noun}`,
|
||||
message: `Are you sure you want to delete this ${noun.toLowerCase()} from the space?`,
|
||||
confirm: async () => {
|
||||
const {error} = await $relayManagement.forUrl(url).banEvent(event.id)
|
||||
const {error} = await manageRelay(url, {
|
||||
method: ManagementMethod.BanEvent,
|
||||
params: [event.id],
|
||||
})
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Event has successfully been deleted!"})
|
||||
$app.repository.removeEvent(event.id)
|
||||
repository.removeEvent(event.id)
|
||||
history.back()
|
||||
}
|
||||
},
|
||||
|
|
@ -68,13 +78,7 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<li>
|
||||
<Button onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
{noun} Details
|
||||
</Button>
|
||||
</li>
|
||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md" bind:this={ul}>
|
||||
{#if isRoot}
|
||||
<li>
|
||||
<Button onclick={share}>
|
||||
|
|
@ -84,13 +88,13 @@
|
|||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<Button onclick={addToLibrary}>
|
||||
<Icon size={4} icon={GalleryWide} />
|
||||
Add to Library
|
||||
<Button onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
{noun} Details
|
||||
</Button>
|
||||
</li>
|
||||
{@render customActions?.()}
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
{#if event.pubkey === $pubkey}
|
||||
<li>
|
||||
<Button onclick={showDelete} class="text-error">
|
||||
<Icon size={4} icon={TrashBin2} />
|
||||
|
|
|
|||
|
|
@ -2,21 +2,19 @@
|
|||
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"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||
import {prependParent} from "@app/rooms"
|
||||
import {publishComment} from "@app/comments"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {PROTECTED, prependParent} from "@app/groups"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {command, relays, writer} from "@app/core"
|
||||
|
||||
type Values = {
|
||||
content?: string | object
|
||||
|
|
@ -34,6 +32,7 @@
|
|||
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,6 +45,10 @@
|
|||
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",
|
||||
|
|
@ -54,20 +57,11 @@
|
|||
}
|
||||
|
||||
if (parent) {
|
||||
;({content, tags} = await prependParent(parent, {content, tags}, url))
|
||||
;({content, tags} = prependParent(parent, {content, tags}, url))
|
||||
}
|
||||
|
||||
const eventWriter = writer(Comment)
|
||||
.setContent(content)
|
||||
.addTags(...tags)
|
||||
.setRootFromEvent(event)
|
||||
.setParentFromEvent(event)
|
||||
.setProtected(await $relays.hasNip(url, 70))
|
||||
|
||||
const thunk = await command(eventWriter).then(publishToRelays([url]))
|
||||
|
||||
draftKey.clear()
|
||||
onSubmit(thunk)
|
||||
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
||||
}
|
||||
|
||||
let form: HTMLElement
|
||||
|
|
@ -107,7 +101,7 @@
|
|||
bind:this={form}
|
||||
onsubmit={preventDefault(submit)}
|
||||
class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
|
||||
<div class="card mx-2 my-2 shadow-md">
|
||||
<div class="card2 mx-2 my-2 bg-alt shadow-md">
|
||||
{#if parent}
|
||||
<ChatComposeParent event={parent} clear={() => onClearParent?.()} verb="Replying to" />
|
||||
{/if}
|
||||
|
|
@ -117,18 +111,18 @@
|
|||
</div>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="tip tip-left absolute bottom-1 right-2"
|
||||
class="tooltip tooltip-left absolute bottom-1 right-2"
|
||||
onclick={selectFiles}>
|
||||
{#if $uploading}
|
||||
<Spinner size="xs" />
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Icon icon={Paperclip} size={3} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex justify-between pt-3">
|
||||
<Button class="button button-link" onclick={onClose}>Cancel</Button>
|
||||
<Button type="submit" class="button button-primary">Post Reply</Button>
|
||||
<Button class="btn btn-link" onclick={onClose}>Cancel</Button>
|
||||
<Button type="submit" class="btn btn-primary">Post Reply</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -13,19 +13,11 @@
|
|||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {roomsByUrl} from "@app/groups"
|
||||
import {makeRoomPath} from "@app/routes"
|
||||
import {rooms} from "@app/core"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
noun: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, noun, event}: Props = $props()
|
||||
|
||||
const spaceRooms = $rooms.forUrl(url).$
|
||||
const {url, noun, event}: {url: string; noun: string; event: TrustedEvent} = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
|
|
@ -47,25 +39,25 @@
|
|||
<ModalTitle>Share {noun}</ModalTitle>
|
||||
<ModalSubtitle>Which room would you like to share this event to?</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each $spaceRooms as room (room.h)}
|
||||
<Button
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each $roomsByUrl.get(url) || [] as room (room.h)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex justify-center button {selection === room.h
|
||||
? 'button-primary'
|
||||
: 'button-neutral'}"
|
||||
class="btn"
|
||||
class:btn-neutral={selection !== room.h}
|
||||
class:btn-primary={selection === room.h}
|
||||
onclick={() => toggleRoom(room.h)}>
|
||||
<RoomNameWithImage {url} h={room.h} />
|
||||
</Button>
|
||||
#<RoomName {...room} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="button button-primary" disabled={!selection}>
|
||||
<Button type="submit" class="btn btn-primary" disabled={!selection}>
|
||||
Share {noun}
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {ZapGoal} from "@welshman/domain"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import {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 {
|
||||
|
|
@ -21,22 +22,23 @@
|
|||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const path = makeGoalPath(url, event.id)
|
||||
const goal = reader(ZapGoal)(event)
|
||||
const h = getTagValue("h", event.tags)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const h = goal.room()
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
|
||||
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url})
|
||||
|
||||
const createReaction = (values: EventContent) => publishReaction(event, values, {url})
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
|
||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import {writable} from "svelte/store"
|
||||
import {ZapGoal} from "@welshman/domain"
|
||||
import {publish} from "@welshman/app"
|
||||
import {makeEvent, ZAP_GOAL} from "@welshman/util"
|
||||
import {publishThunk, waitForThunkError} 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 {publishRoomQuote} from "@app/rooms"
|
||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
||||
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 = $relays.hasNip(url, 70)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const uploading = writable(false)
|
||||
|
||||
|
|
@ -73,25 +73,32 @@
|
|||
})
|
||||
}
|
||||
|
||||
const tags = [
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
["summary", content],
|
||||
["amount", String(amount)],
|
||||
["relays", url],
|
||||
]
|
||||
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const protect = await shouldProtect
|
||||
const eventWriter = writer(ZapGoal)
|
||||
.setTitle(title)
|
||||
.setSummary(content)
|
||||
.setAmount(amount)
|
||||
.setUrls([url])
|
||||
.setProtected(protect)
|
||||
.addTags(...ed.storage.nostr.getEditorTags())
|
||||
.forceRelays(url)
|
||||
|
||||
if (h) {
|
||||
eventWriter.setRoom(url, h)
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
const goalThunk = await command(eventWriter).then(publish)
|
||||
const error = await goalThunk.waitForError()
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
const goalThunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(ZAP_GOAL, {content: title, tags}),
|
||||
})
|
||||
|
||||
const error = await waitForThunkError(goalThunk)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
|
|
@ -138,13 +145,13 @@
|
|||
<ModalTitle>Create a Funding Goal</ModalTitle>
|
||||
<ModalSubtitle>Request contributions for your fundraiser.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="flex flex-col gap-8 relative">
|
||||
<div class="col-8 relative">
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Title*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus={!isMobile}
|
||||
|
|
@ -168,24 +175,24 @@
|
|||
</Field>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="tip tip-left absolute bottom-1 right-2"
|
||||
class="tooltip tooltip-left absolute bottom-1 right-2"
|
||||
onclick={selectFiles}
|
||||
disabled={loading}>
|
||||
{#if $uploading}
|
||||
<Spinner size="xs" />
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Icon icon={Paperclip} size={3} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
Goal Amount (sats)*
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex grow justify-end">
|
||||
<label class="input flex w-auto items-center gap-2">
|
||||
<label class="input input-bordered flex w-auto items-center gap-2">
|
||||
<Icon icon={Bolt} />
|
||||
<input bind:value={amount} type="number" class="w-28 grow" />
|
||||
<p class="shrink-0 opacity-50">sats</p>
|
||||
|
|
@ -194,7 +201,7 @@
|
|||
{/snippet}
|
||||
</FieldInline>
|
||||
<input
|
||||
class="range w-full"
|
||||
class="range range-primary -mt-2 w-full"
|
||||
type="range"
|
||||
min="1000"
|
||||
max="100000"
|
||||
|
|
@ -204,11 +211,11 @@
|
|||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back} disabled={loading}>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="button button-primary" disabled={$uploading || loading}>
|
||||
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
|
||||
<Spinner {loading}>Create Goal</Spinner>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {ZapGoal} from "@welshman/domain"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
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"
|
||||
|
|
@ -17,17 +16,12 @@
|
|||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const goal = reader(ZapGoal)(event)
|
||||
|
||||
const title = goal.title()
|
||||
const summary = goal.summary()
|
||||
const h = goal.room()
|
||||
const summary = getTagValue("summary", event.tags)
|
||||
const h = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="cv flex flex-col gap-2 card card-interactive w-full"
|
||||
href={makeGoalPath(url, event.id)}>
|
||||
<p class="text-2xl">{title}</p>
|
||||
<Link class="cv col-2 card2 w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
|
||||
<p class="text-2xl">{event.content}</p>
|
||||
<Content
|
||||
event={{content: summary, tags: event.tags}}
|
||||
{url}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
<script lang="ts">
|
||||
import {derived} from "svelte/store"
|
||||
import {now, DAY, uniq, sum} from "@welshman/lib"
|
||||
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 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 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
|
||||
|
|
@ -20,23 +16,18 @@
|
|||
|
||||
const {url, event, ...props}: Props = $props()
|
||||
|
||||
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 zaps = deriveArray(
|
||||
deriveItemsByKey<Zap>({
|
||||
repository,
|
||||
getKey: zap => zap.response.id,
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
|
||||
}),
|
||||
)
|
||||
|
||||
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 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 daysOld = Math.ceil((now() - event.created_at) / DAY)
|
||||
</script>
|
||||
|
||||
|
|
@ -55,8 +46,8 @@
|
|||
<p class="text-sm opacity-75">{daysOld === 1 ? "day" : "days"} old</p>
|
||||
</div>
|
||||
</div>
|
||||
<progress class="progress" value={zapAmount} max={goalAmount}></progress>
|
||||
<ZapButton {url} {event} class="button button-primary lg:m-auto lg:px-20">
|
||||
<progress class="progress progress-primary" value={zapAmount} max={goalAmount}></progress>
|
||||
<ZapButton {url} {event} class="btn btn-primary lg:m-auto lg:px-20">
|
||||
<Icon icon={Bolt} />
|
||||
Contribute to this goal
|
||||
</ZapButton>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Magnifier} />
|
||||
<input bind:value={searchTerm} class="grow" type="text" placeholder="Search icons..." />
|
||||
</label>
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
<button
|
||||
type="button"
|
||||
title={icon.name}
|
||||
class="flex aspect-square items-center justify-center rounded-2xl transition-colors hover:bg-primary hover:text-primary-content"
|
||||
class="flex aspect-square items-center justify-center rounded-box transition-colors hover:bg-primary hover:text-primary-content"
|
||||
onclick={() => handleSelect(icon.url)}>
|
||||
<Icon icon={icon.url} class="h-6 w-6" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,6 @@
|
|||
const {onSelect}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="w-96 rounded-2xl bg-surface p-4 shadow-2xl">
|
||||
<div class="w-96 rounded-box bg-base-100 p-4 shadow-2xl">
|
||||
<IconPicker {onSelect} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import {PLATFORM_NAME} from "@app/env"
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
|
|
@ -39,5 +37,5 @@
|
|||
href="https://nostrapps.com#signers">nostrapps.com</Link
|
||||
>.
|
||||
</p>
|
||||
<Button class="button button-primary" onclick={back}>Got it</Button>
|
||||
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
|
||||
</ModalBody></Modal>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import {PLATFORM_NAME} from "@app/env"
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
|
|
@ -29,5 +27,5 @@
|
|||
class="underline">nostr.how</Link
|
||||
>.
|
||||
</p>
|
||||
<Button class="button button-primary" onclick={back}>Got it</Button>
|
||||
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
|
||||
</ModalBody></Modal>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<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"
|
||||
|
|
@ -10,10 +11,8 @@
|
|||
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()
|
||||
|
||||
|
|
@ -37,7 +36,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 isPomadeSession($session)}
|
||||
{#if $session?.email}
|
||||
<p>
|
||||
It's very important to keep private keys safe, but this can sometimes be tricky, which is
|
||||
why {PLATFORM_NAME}
|
||||
|
|
@ -45,16 +44,16 @@
|
|||
</p>
|
||||
<p>If you'd like to switch to self-custody, please click below to get started.</p>
|
||||
{:else}
|
||||
<Button class="button button-primary" onclick={back}>Got it</Button>
|
||||
<Button class="btn btn-primary" onclick={back}>Got it</Button>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
{#if isPomadeSession($session)}
|
||||
{#if $session?.email}
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button class="button button-primary" onclick={startRecoveryRequest}>
|
||||
<Button class="btn btn-primary" onclick={startRecoveryRequest}>
|
||||
<Icon icon={CheckCircle} />
|
||||
I want to hold my own keys
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
|
|
@ -30,5 +28,5 @@
|
|||
To learn more about how to manage your keys, or to set up an account, try
|
||||
<Link external class="link" href="https://nosta.me/">nosta.me</Link>.
|
||||
</p>
|
||||
<Button class="button button-primary" onclick={back}>Got it</Button>
|
||||
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
|
||||
</ModalBody></Modal>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@
|
|||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import {PLATFORM_NAME} from "@app/env"
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
|
|
@ -29,6 +27,6 @@
|
|||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-primary" onclick={back}>Got it</Button>
|
||||
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -30,5 +30,5 @@
|
|||
information flowing through the network — instead, your client software can prove that a given
|
||||
piece of data is authentic.
|
||||
</p>
|
||||
<Button class="button button-primary" onclick={back}>Got it</Button>
|
||||
<Button class="btn btn-primary" onclick={back}>Got it</Button>
|
||||
</ModalBody></Modal>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import {Zappers} from "@welshman/app"
|
||||
import {deriveZapperForPubkey} 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,15 +9,10 @@
|
|||
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"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
}
|
||||
const {pubkey} = $props()
|
||||
|
||||
const {pubkey}: Props = $props()
|
||||
|
||||
const zapper = $app.use(Zappers).forPubkey(pubkey).$
|
||||
const zapper = deriveZapperForPubkey(pubkey)
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
|
@ -37,7 +32,7 @@
|
|||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<script lang="ts">
|
||||
import {randomId} from "@welshman/lib"
|
||||
import {preventDefault, stopPropagation} from "@lib/html"
|
||||
import {randomId, call} from "@welshman/lib"
|
||||
import {preventDefault, stopPropagation, compressFile} from "@lib/html"
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import {compressFileForUpload, uploadFileOrFallback} from "@app/uploads"
|
||||
import {uploadFile} from "@app/uploads"
|
||||
|
||||
interface Props {
|
||||
file?: File | undefined
|
||||
|
|
@ -17,10 +16,6 @@
|
|||
|
||||
const id = randomId()
|
||||
|
||||
// Profile pictures are shown small (avatar-sized), so compress hard: this keeps
|
||||
// the base64 fallback below small enough to embed directly in the profile event.
|
||||
const compressOptions = {maxWidth: 128, maxHeight: 128, quality: 0.6, mimeType: "image/jpeg"}
|
||||
|
||||
const onDragEnter = () => {
|
||||
active = true
|
||||
}
|
||||
|
|
@ -36,27 +31,11 @@
|
|||
const onDrop = async (e: any) => {
|
||||
active = false
|
||||
|
||||
submit(e.dataTransfer.files[0])
|
||||
file = await compressFile(e.dataTransfer.files[0])
|
||||
}
|
||||
|
||||
const onChange = async (e: any) => {
|
||||
submit(e.target.files[0])
|
||||
}
|
||||
|
||||
const submit = async (newFile: File) => {
|
||||
if (!newFile) {
|
||||
url = initialUrl
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
|
||||
try {
|
||||
file = await compressFileForUpload(newFile, compressOptions)
|
||||
url = (await uploadFileOrFallback(file)).url
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
file = await compressFile(e.target.files[0])
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
|
|
@ -66,42 +45,65 @@
|
|||
}
|
||||
|
||||
let active = $state(false)
|
||||
let loading = $state(false)
|
||||
let initialUrl = $state(url)
|
||||
|
||||
$effect(() => {
|
||||
call(async () => {
|
||||
if (file) {
|
||||
const {result} = await uploadFile(file)
|
||||
|
||||
if (result?.url) {
|
||||
url = result.url
|
||||
} else {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
url = reader.result as string
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
} else {
|
||||
url = initialUrl
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<form>
|
||||
<input
|
||||
{id}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onchange={onChange}
|
||||
class="hidden" />
|
||||
<input {id} type="file" accept="image/*" onchange={onChange} class="hidden" />
|
||||
<label
|
||||
for={id}
|
||||
aria-label="Drag and drop files here."
|
||||
style="background-image: url({url}); border-color: {active ? 'var(--primary)' : 'var(--line)'};"
|
||||
class="bg-surface-more relative flex h-24 w-24 shrink-0 cursor-pointer items-center justify-center rounded-full border-2 border-solid bg-cover bg-center transition-all"
|
||||
style="background-image: url({url});"
|
||||
class="relative flex h-24 w-24 shrink-0 cursor-pointer items-center justify-center rounded-full border-2 border-solid border-base-content bg-base-300 bg-cover bg-center transition-all"
|
||||
class:transparent={!url}
|
||||
class:border-primary={active}
|
||||
ondragenter={stopPropagation(preventDefault(onDragEnter))}
|
||||
ondragover={stopPropagation(preventDefault(onDragOver))}
|
||||
ondragleave={stopPropagation(preventDefault(onDragLeave))}
|
||||
ondrop={stopPropagation(preventDefault(onDrop))}>
|
||||
<button
|
||||
onclick={url ? stopPropagation(onClear) : undefined}
|
||||
class="absolute right-0 top-0 h-5 w-5 overflow-hidden rounded-full flex items-center justify-center"
|
||||
style="background: {url ? 'var(--error)' : 'var(--primary)'};">
|
||||
<div
|
||||
class="absolute right-0 top-0 h-5 w-5 overflow-hidden rounded-full bg-primary"
|
||||
class:bg-error={url}
|
||||
class:bg-primary={!url}>
|
||||
{#if url}
|
||||
<Icon icon={CloseCircle} class="scale-150 bg-surface-more!" />
|
||||
<span
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onmousedown={stopPropagation(onClear)}
|
||||
ontouchstart={stopPropagation(onClear)}>
|
||||
<Icon icon={CloseCircle} class="scale-150 bg-base-300!" />
|
||||
</span>
|
||||
{:else}
|
||||
<Icon icon={AddCircle} class="scale-150 bg-surface-more!" />
|
||||
<Icon icon={AddCircle} class="scale-150 bg-base-300!" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if loading}
|
||||
<div class="absolute inset-0 flex items-center justify-center rounded-full bg-black/40">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else if !url}
|
||||
</div>
|
||||
{#if !url}
|
||||
<Icon icon={GallerySend} size={7} />
|
||||
{/if}
|
||||
</label>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue